初始化

This commit is contained in:
2026-03-03 09:36:51 +08:00
commit 76c1a8668f
510 changed files with 28241 additions and 0 deletions

15
saiadmin-vue/src/App.vue Normal file
View File

@@ -0,0 +1,15 @@
<script setup>
import cn from '@arco-design/web-vue/es/locale/lang/zh-cn'
import en from '@arco-design/web-vue/es/locale/lang/en-us'
import { ref } from 'vue'
import { useAppStore } from './store'
const appStore = useAppStore()
const lang = ref(appStore.language === 'zh_CN' ? cn : en)
</script>
<template>
<a-config-provider :locale="lang" :update-at-scroll="true">
<router-view />
</a-config-provider>
</template>

View File

@@ -0,0 +1,220 @@
import { request } from '@/utils/request.js'
export default {
/**
* 获取用户列表
* @returns
*/
getUserList(params = {}) {
return request({
url: '/core/system/getUserList',
method: 'get',
params
})
},
/**
* 通过id 列表获取用户基础信息
* @returns
*/
getUserInfoByIds(data = {}) {
return request({
url: '/core/system/getUserInfoByIds',
method: 'post',
data
})
},
/**
* 获取公告列表
* @returns
*/
getNoticeList(params = {}) {
return request({
url: '/core/system/notice',
method: 'get',
params
})
},
/**
* 获取基础统计
* @returns
*/
getStatistics(params = {}) {
return request({
url: '/core/system/statistics',
method: 'get',
params
})
},
/**
* 获取登录图表统计
* @returns
*/
loginChart(params = {}) {
return request({
url: '/core/system/loginChart',
method: 'get',
params
})
},
/**
* 清除所有缓存
* @returns
*/
clearAllCache() {
return request({
url: '/core/system/clearAllCache',
method: 'get'
})
},
/**
* 上传图片接口
* @returns
*/
uploadImage(data = {}) {
return request({
url: '/core/system/uploadImage',
method: 'post',
timeout: 30000,
// headers: { 'Content-Type': 'multipart/form-data' },
data
})
},
/**
* 上传文件接口
* @returns
*/
uploadFile(data = {}) {
return request({
url: '/core/system/uploadFile',
method: 'post',
timeout: 30000,
// headers: { 'Content-Type': 'multipart/form-data' },
data
})
},
/**
* 切片上传接口
* @returns
*/
chunkUpload(data = {}) {
return request({
url: '/core/system/chunkUpload',
method: 'post',
timeout: 30000,
// headers: { 'Content-Type': 'multipart/form-data' },
data
})
},
/**
* 保存网络图片
* @returns
*/
saveNetWorkImage(data = {}) {
return request({
url: '/core/system/saveNetworkImage',
method: 'post',
data
})
},
/**
* 获取登录日志列表
*/
getLoginLogList(params = {}) {
return request({
url: '/core/system/getLoginLogList',
method: 'get',
params
})
},
/**
* 获取操作日志列表
*/
getOperationLogList(params = {}) {
return request({
url: '/core/system/getOperationLogList',
method: 'get',
params
})
},
/**
* 获取资源列表
*/
getResourceList(params = {}) {
return request({
url: '/core/system/getResourceList',
method: 'get',
params
})
},
/**
* 通用导入Excel
*/
importExcel(url, data) {
return request({
url,
method: 'post',
data,
timeout: 30 * 1000
// headers: { 'Content-Type': 'multipart/form-data' },
})
},
/**
* 下载通用方法
*/
download(url, method = 'post') {
return request({ url, method, responseType: 'blob' })
},
/**
* GET通用方法
*/
commonGet(url, params = {}) {
return request({ url, method: 'get', params })
},
/**
* 查询所有字典数据
*/
dictAll() {
return request({
url: '/core/system/dictAll',
method: 'get'
})
},
/**
* 根据id下载资源
*/
downloadById(id) {
return request({
url: '/core/system/downloadById?id=' + id,
responseType: 'blob',
method: 'get'
})
},
/**
* 根据hash下载资源
*/
downloadByHash(hash) {
return request({
url: '/core/system/downloadByHash?hash=' + hash,
responseType: 'blob',
method: 'get'
})
}
}

View File

@@ -0,0 +1,54 @@
import { request } from '@/utils/request.js'
export default {
/**
* 获取验证码
* @returns
*/
getCaptch() {
// return import.meta.env.VITE_APP_PROXY_PREFIX + '/core/captcha?' + Date.parse(new Date().toString())
return request({
url: '/core/captcha',
method: 'get',
})
},
/**
* 用户登录
* @param {object} params
* @returns
*/
login(params = {}) {
return request({
url: '/core/login',
method: 'post',
data: params,
})
},
/**
* 用户退出
* @param {object} params
* @returns
*/
logout(params = {}) {
return request({
url: '/core/logout',
method: 'post',
data: params,
})
},
/**
* 获取登录用户信息
* @param {object} params
* @returns
*/
getInfo(params = {}) {
return request({
url: '/core/system/user',
method: 'get',
data: params,
})
},
}

View File

@@ -0,0 +1,27 @@
import { request } from '@/utils/request.js'
export default {
/**
* 获取文件分页列表
* @returns
*/
getPageList(params = {}) {
return request({
url: '/core/attachment/index',
method: 'get',
params
})
},
/**
* 删除数据
* @returns
*/
destroy(data) {
return request({
url: '/core/attachment/destroy',
method: 'delete',
data
})
}
}

View File

@@ -0,0 +1,138 @@
import { request } from '@/utils/request.js'
/**
* 系统设置接口
*/
export default {
/**
* 获取配置列表
* @returns
*/
getConfigList(params) {
return request({
url: '/core/config/index',
method: 'get',
params
})
},
/**
* 删除配置
* @returns
*/
destroy(data) {
return request({
url: '/core/config/destroy',
method: 'delete',
data
})
},
/**
* 保存配置
* @returns
*/
save(data = {}) {
return request({
url: '/core/config/save',
method: 'post',
data
})
},
/**
* 修改配置
* @returns
*/
update(id, data = {}) {
return request({
url: '/core/config/update?id=' + id,
method: 'put',
data
})
},
/**
* 按 keys 更新配置
* @returns
*/
updateByKeys(data) {
return request({
url: '/core/config/updateByKeys',
method: 'post',
data
})
},
/**
* 批量修改配置值
* @returns
*/
batchUpdate(data) {
return request({
url: '/core/config/batchUpdate',
method: 'post',
data
})
},
/**
* 获取组列表
* @returns
*/
getConfigGroupList(params = {}) {
return request({
url: '/core/configGroup/index',
method: 'get',
params
})
},
/**
* 保存配置组
* @returns
*/
saveConfigGroup(data = {}) {
return request({
url: '/core/configGroup/save',
method: 'post',
data
})
},
/**
* 更新配置组
* @returns
*/
updateConfigGroup(id, data = {}) {
return request({
url: '/core/configGroup/update?id=' + id,
method: 'put',
data
})
},
/**
* 删除配置组
* @returns
*/
deleteConfigGroup(data = {}) {
return request({
url: '/core/configGroup/destroy',
method: 'delete',
data
})
},
/**
* 邮箱测试
* @returns
*/
testEmail(data = {}) {
return request({
url: '/core/configGroup/email',
method: 'post',
data
})
}
}

View File

@@ -0,0 +1,99 @@
import { request } from '@/utils/request.js'
export default {
/**
* 获取数据表分页列表
* @returns
*/
getPageList(params = {}) {
return request({
url: '/core/database/index',
method: 'get',
params
})
},
/**
* 获取数据源
* @returns
*/
getDataSource(params = {}) {
return request({
url: '/core/database/dataSource',
method: 'get',
params
})
},
/**
* 获取表字段列表
* @returns
*/
getDetailed(params = {}) {
return request({
url: '/core/database/detailed',
method: 'get',
params
})
},
/**
* 获取回收站数据
* @returns
*/
getRecycle(params = {}) {
return request({
url: '/core/database/recycle',
method: 'get',
params
})
},
/**
* 销毁数据
* @returns
*/
delete(data) {
return request({
url: '/core/database/delete',
method: 'delete',
data
})
},
/**
* 恢复数据
* @returns
*/
recovery(data) {
return request({
url: '/core/database/recovery',
method: 'post',
data
})
},
/**
* 优化表
* @returns
*/
optimize(data = {}) {
return request({
url: '/core/database/optimize',
method: 'post',
data
})
},
/**
* 清理表碎片
* @returns
*/
fragment(data = {}) {
return request({
url: '/core/database/fragment',
method: 'post',
data
})
}
}

View File

@@ -0,0 +1,110 @@
import { request } from '@/utils/request.js'
export default {
/**
* 获取部门树
* @returns
*/
getPageList(params = {}) {
return request({
url: '/core/dept/index',
method: 'get',
params
})
},
/**
* 获取部门领导列表
* @returns
*/
getLeaderList(params = {}) {
return request({
url: '/core/dept/leaders',
method: 'get',
params
})
},
/**
* 新增部门领导
* @returns
*/
addLeader(data = {}) {
return request({
url: '/core/dept/addLeader',
method: 'post',
data
})
},
/**
* 删除部门领导
* @returns
*/
delLeader(data = {}) {
return request({
url: '/core/dept/delLeader',
method: 'delete',
data
})
},
/**
* 获取部门选择树
* @returns
*/
tree() {
return request({
url: '/core/dept/index?tree=true',
method: 'get'
})
},
/**
* 添加数据
* @returns
*/
save(params = {}) {
return request({
url: '/core/dept/save',
method: 'post',
data: params
})
},
/**
* 删除数据
* @returns
*/
destroy(data) {
return request({
url: '/core/dept/destroy',
method: 'delete',
data
})
},
/**
* 修改数据
* @returns
*/
update(id, params = {}) {
return request({
url: '/core/dept/update?id=' + id,
method: 'put',
data: params
})
},
/**
* 更改状态
* @returns
*/
changeStatus(data = {}) {
return request({
url: '/core/dept/changeStatus',
method: 'post',
data
})
}
}

View File

@@ -0,0 +1,137 @@
import { request } from '@/utils/request.js'
export const dictType = {
/**
* 获取字典类型,无分页
* @returns
*/
getPageList(params = {}) {
return request({
url: '/core/dictType/index',
method: 'get',
params
})
},
/**
* 添加字典类型
* @returns
*/
save(params = {}) {
return request({
url: '/core/dictType/save',
method: 'post',
data: params
})
},
/**
* 删除字典类型
* @returns
*/
destroy(data) {
return request({
url: '/core/dictType/destroy',
method: 'delete',
data
})
},
/**
* 修改字典类型
* @returns
*/
update(id, data = {}) {
return request({
url: '/core/dictType/update?id=' + id,
method: 'put',
data
})
},
/**
* 更改字典类型状态
* @returns
*/
changeStatus(data = {}) {
return request({
url: '/core/dictType/changeStatus',
method: 'post',
data
})
}
}
export const dict = {
/**
* 快捷查询字典
* @param {*} params
* @returns
*/
getDict(code) {
return request({
url: '/core/dataDict/index?code=' + code,
method: 'get'
})
},
/**
* 获取字典数据分页列表
* @returns
*/
getPageList(params = {}) {
return request({
url: '/core/dictData/index',
method: 'get',
params
})
},
/**
* 添加字典数据
* @returns
*/
addDictData(data = {}) {
return request({
url: '/core/dictData/save',
method: 'post',
data
})
},
/**
* 删除字典数据
* @returns
*/
destroyDictData(data) {
return request({
url: '/core/dictData/destroy',
method: 'delete',
data
})
},
/**
* 更新字典数据
* @returns
*/
editDictData(id, data = {}) {
return request({
url: '/core/dictData/update?id=' + id,
method: 'put',
data
})
},
/**
* 更改字典状态
* @returns
*/
changeStatus(data = {}) {
return request({
url: '/core/dictData/changeStatus',
method: 'post',
data
})
}
}

View File

@@ -0,0 +1,30 @@
import { request } from '@/utils/request.js'
/**
* 邮件日志接口
*/
export default {
/**
* 数据列表
* @returns
*/
getPageList(params = {}) {
return request({
url: '/core/email/index',
method: 'get',
params
})
},
/**
* 删除数据
* @returns
*/
destroy(data) {
return request({
url: '/core/email/destroy',
method: 'delete',
data
})
}
}

View File

@@ -0,0 +1,30 @@
import { request } from '@/utils/request.js'
/**
* 登录日志接口
*/
export default {
/**
* 数据列表
* @returns
*/
getPageList(params = {}) {
return request({
url: '/core/logs/getLoginLogPageList',
method: 'get',
params
})
},
/**
* 删除数据
* @returns
*/
destroy(data) {
return request({
url: '/core/logs/deleteLoginLog',
method: 'delete',
data
})
}
}

View File

@@ -0,0 +1,63 @@
import { request } from '@/utils/request.js'
export default {
/**
* 获取数据
* @returns
*/
getList(params = {}) {
return request({
url: '/core/menu/index',
method: 'get',
params
})
},
/**
* 可操作菜单
* @returns
*/
accessMenu(params = {}) {
return request({
url: '/core/menu/accessMenu',
method: 'get',
params
})
},
/**
* 添加数据
* @returns
*/
save(params = {}) {
return request({
url: '/core/menu/save',
method: 'post',
data: params
})
},
/**
* 删除数据
* @returns
*/
destroy(data) {
return request({
url: '/core/menu/destroy',
method: 'delete',
data
})
},
/**
* 更新数据
* @returns
*/
update(id, data = {}) {
return request({
url: '/core/menu/update?id=' + id,
method: 'put',
data
})
}
}

View File

@@ -0,0 +1,17 @@
import { request } from '@/utils/request.js'
/**
* 服务监控接口
*/
export default {
/**
* 获取服务器信息
* @returns
*/
getServerInfo() {
return request({
url: '/core/system/monitor',
method: 'get'
})
}
}

View File

@@ -0,0 +1,77 @@
import { request } from '@/utils/request.js'
/**
* 通知公告接口
*/
export default {
/**
* 数据列表
* @returns
*/
getPageList(params = {}) {
return request({
url: '/core/notice/index',
method: 'get',
params
})
},
/**
* 添加数据
* @returns
*/
save(params = {}) {
return request({
url: '/core/notice/save',
method: 'post',
data: params
})
},
/**
* 读取数据
* @returns
*/
read(id) {
return request({
url: '/core/notice/read?id=' + id,
method: 'get'
})
},
/**
* 删除数据
* @returns
*/
destroy(data) {
return request({
url: '/core/notice/destroy',
method: 'delete',
data
})
},
/**
* 修改数据
* @returns
*/
update(id, data = {}) {
return request({
url: '/core/notice/update?id=' + id,
method: 'put',
data
})
},
/**
* 修改状态
* @returns
*/
changeStatus(data = {}) {
return request({
url: '/core/notice/changeStatus',
method: 'post',
data
})
}
}

View File

@@ -0,0 +1,30 @@
import { request } from '@/utils/request.js'
/**
* 操作日志接口
*/
export default {
/**
* 数据列表
* @returns
*/
getPageList(params = {}) {
return request({
url: '/core/logs/getOperLogPageList',
method: 'get',
params
})
},
/**
* 删除数据
* @returns
*/
destroy(data) {
return request({
url: '/core/logs/deleteOperLog',
method: 'delete',
data
})
}
}

View File

@@ -0,0 +1,77 @@
import { request } from '@/utils/request.js'
/**
* 岗位数据接口
*/
export default {
/**
* 数据列表
* @returns
*/
getPageList(params = {}) {
return request({
url: '/core/post/index',
method: 'get',
params
})
},
/**
* 读取数据
* @returns
*/
read(id) {
return request({
url: '/core/post/read?id=' + id,
method: 'get'
})
},
/**
* 添加数据
* @returns
*/
save(params = {}) {
return request({
url: '/core/post/save',
method: 'post',
data: params
})
},
/**
* 修改数据
* @returns
*/
update(id, data = {}) {
return request({
url: '/core/post/update?id=' + id,
method: 'put',
data
})
},
/**
* 更改状态
* @returns
*/
changeStatus(data = {}) {
return request({
url: '/core/post/changeStatus',
method: 'post',
data
})
},
/**
* 删除数据
* @returns
*/
destroy(data) {
return request({
url: '/core/post/destroy',
method: 'delete',
data
})
}
}

View File

@@ -0,0 +1,109 @@
import { request } from '@/utils/request.js'
export default {
/**
* 获取数据列表
* @returns
*/
getPageList(params = {}) {
return request({
url: '/core/role/index',
method: 'get',
params
})
},
/**
* 通过角色获取菜单
* @returns
*/
getMenuByRole(id) {
return request({
url: '/core/role/getMenuByRole?id=' + id,
method: 'get'
})
},
/**
* 通过角色获取部门
* @returns
*/
getDeptByRole(id) {
return request({
url: '/core/role/getDeptByRole?id=' + id,
method: 'get'
})
},
/**
* 添加数据
* @returns
*/
save(data = {}) {
return request({
url: '/core/role/save',
method: 'post',
data
})
},
/**
* 删除数据
* @returns
*/
destroy(data) {
return request({
url: '/core/role/destroy',
method: 'delete',
data
})
},
/**
* 更新数据
* @returns
*/
update(id, data = {}) {
return request({
url: '/core/role/update?id=' + id,
method: 'put',
data
})
},
/**
* 更新菜单权限
* @returns
*/
updateMenuPermission(id, data) {
return request({
url: '/core/role/menuPermission?id=' + id,
method: 'post',
data
})
},
/**
* 更新数据权限
* @returns
*/
updateDataPermission(id, data) {
return request({
url: '/core/role/dataPermission?id=' + id,
method: 'post',
data
})
},
/**
* 更改数据状态
* @returns
*/
changeStatus(params = {}) {
return request({
url: '/core/role/changeStatus',
method: 'post',
data: params
})
}
}

View File

@@ -0,0 +1,134 @@
import { request } from '@/utils/request.js'
export default {
/**
* 获取数据列表
* @returns
*/
getPageList(params = {}) {
return request({
url: '/core/user/index',
method: 'get',
params
})
},
/**
* 读取数据
* @returns
*/
read(id) {
return request({
url: '/core/user/read?id=' + id,
method: 'get'
})
},
/**
* 添加数据
* @returns
*/
save(params = {}) {
return request({
url: '/core/user/save',
method: 'post',
data: params
})
},
/**
* 删除数据
* @returns
*/
destroy(data) {
return request({
url: '/core/user/destroy',
method: 'delete',
data
})
},
/**
* 更新数据
* @returns
*/
update(id, data = {}) {
return request({
url: '/core/user/update?id=' + id,
method: 'put',
data
})
},
/**
* 更改数据状态
* @returns
*/
changeStatus(data = {}) {
return request({
url: '/core/user/changeStatus',
method: 'post',
data
})
},
/**
* 清除用户缓存
* @returns
*/
clearCache(params = {}) {
return request({
url: '/core/user/clearCache',
method: 'post',
data: params
})
},
/**
* 设置用户首页
* @returns
*/
setHomePage(data = {}) {
return request({
url: '/core/user/setHomePage',
method: 'post',
data
})
},
/**
* 初始化用户密码
* @returns
*/
initUserPassword(data) {
return request({
url: '/core/user/initUserPassword',
method: 'post',
data
})
},
/**
* 用户更新个人资料
* @returns
*/
updateInfo(data = {}) {
return request({
url: '/core/user/updateInfo',
method: 'post',
data
})
},
/**
* 用户修改密码
* @returns
*/
modifyPassword(data = {}) {
return request({
url: '/core/user/modifyPassword',
method: 'post',
data
})
}
}

View File

@@ -0,0 +1,113 @@
import { request } from '@/utils/request.js'
/**
* 定时任务接口
*/
export default {
/**
* 数据列表
* @returns
*/
getPageList(params = {}) {
return request({
url: '/tool/crontab/index',
method: 'get',
params
})
},
/**
* 日志列表
* @returns
*/
getLogPageList(params = {}) {
return request({
url: '/tool/crontab/logPageList',
method: 'get',
params
})
},
/**
* 删除定时任务日志
* @returns
*/
deleteLog(data) {
return request({
url: '/tool/crontab/deleteCrontabLog',
method: 'delete',
data
})
},
/**
* 立刻执行一次定时任务
* @returns
*/
run(data = {}) {
return request({
url: '/tool/crontab/run',
method: 'post',
data
})
},
/**
* 读取数据
* @returns
*/
read(id) {
return request({
url: '/tool/crontab/read?id=' + id,
method: 'get'
})
},
/**
* 添加
* @returns
*/
save(data = {}) {
return request({
url: '/tool/crontab/save',
method: 'post',
data
})
},
/**
* 删除
* @returns
*/
destroy(data) {
return request({
url: '/tool/crontab/destroy',
method: 'delete',
data
})
},
/**
* 更新数据
* @returns
*/
update(id, params = {}) {
return request({
url: '/tool/crontab/update?id=' + id,
method: 'put',
data: params
})
},
/**
* 更改状态
* @returns
*/
changeStatus(data = {}) {
return request({
url: '/tool/crontab/changeStatus',
method: 'post',
data
})
}
}

View File

@@ -0,0 +1,135 @@
import { request } from '@/utils/request.js'
export default {
/**
* 数据列表
* @returns
*/
getPageList(params = {}) {
return request({
url: '/tool/code/index',
method: 'get',
params
})
},
/**
* 删除数据
* @returns
*/
destroy(data) {
return request({
url: '/tool/code/destroy',
method: 'delete',
data
})
},
/**
* 编辑信息
* @returns
*/
update(id, data = {}) {
return request({
url: '/tool/code/update?id=' + id,
method: 'put',
data
})
},
/**
* 读取信息
*/
readTable(id) {
return request({
url: '/tool/code/read?id=' + id,
method: 'get'
})
},
/**
* 生成代码
* @returns
*/
generateCode(data = {}) {
return request({
url: '/tool/code/generate',
method: 'post',
responseType: 'blob',
timeout: 20 * 1000,
data
})
},
/**
* 生成到文件
* @returns
*/
generateFile(data = {}) {
return request({
url: '/tool/code/generateFile',
method: 'post',
data
})
},
/**
* 装载数据表
* @returns
*/
loadTable(data = {}) {
return request({
url: '/tool/code/loadTable',
method: 'post',
data
})
},
/**
* 同步数据表
* @returns
*/
sync(id) {
return request({
url: '/tool/code/sync?id=' + id,
method: 'post'
})
},
/**
* 预览代码
* @returns
*/
preview(id) {
return request({
url: '/tool/code/preview?id=' + id,
method: 'get'
})
},
// 获取表中字段信息
getTableColumns(params = {}) {
return request({
url: '/tool/code/getTableColumns',
method: 'get',
params
})
},
// 获取数据源列表
getDataSourceList(params = {}) {
return request({
url: '/tool/code/getDataSourceList',
method: 'get',
params
})
},
// 获取所有模型
getModels() {
return request({
url: '/tool/code/getModels',
method: 'get'
})
}
}

View File

@@ -0,0 +1,66 @@
import { request } from '@/utils/request.js'
/**
* saipackage安装器
*/
export default {
/**
* 数据列表
* @returns
*/
getAppList(params = {}) {
return request({
url: '/app/saipackage/install/index',
method: 'get',
params
})
},
/**
* 应用上传
* @returns
*/
uploadApp(data = {}) {
return request({
url: '/app/saipackage/install/upload',
method: 'post',
data
})
},
/**
* 应用安装
* @returns
*/
installApp(data = {}) {
return request({
url: '/app/saipackage/install/install',
method: 'post',
data
})
},
/**
* 应用卸载
* @returns
*/
uninstallApp(data = {}) {
return request({
url: '/app/saipackage/install/uninstall',
method: 'post',
data
})
},
/**
* 重启后端
* @returns
*/
reloadBackend(data = {}) {
return request({
url: '/app/saipackage/install/reload',
method: 'post',
data
})
}
}

View File

@@ -0,0 +1,176 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="770px" height="456px" viewBox="0 0 770 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 60.1 (88133) - https://sketch.com -->
<title>6</title>
<desc>Created with Sketch.</desc>
<defs>
<path d="M451.167458,89.4511247 C403.062267,29.8170416 338.891681,0 258.655699,0 C138.301726,0 69.263862,60.1782766 27.9579265,152.101254 C-13.3480089,244.024231 -12.6661889,369.858107 55.6494632,409.696073 C123.965115,449.534039 210.08756,459.743134 340.957927,438.489218 C471.828293,417.235303 508.472089,464.890133 589.496232,451.689675 C670.520376,438.489218 748.359885,414.0324 766.111966,329.133852 C783.864046,244.235303 714.426288,177.226358 677.67078,152.101254 C640.915272,126.97615 569.728461,175.208649 519.030321,160.235303 C485.231561,150.253072 462.610607,126.658346 451.167458,89.4511247 Z" id="path-1"></path>
<path d="M0.816264722,0 L370.714266,0 L370.714266,180.257104 L402.92544,180.257104 C424.638356,218.017298 440.878062,240.166012 451.644559,246.703245 L119.609274,243.521057 C112.14379,243.449507 105.100966,240.045172 100.407325,234.239285 C89.3772632,220.595444 81.4909058,210.013897 76.7482527,202.494643 C68.1135311,188.804698 66.7639588,180.257104 51.9095874,180.257104 C37.055216,180.257104 30.8879728,215.663472 26.2206784,229.536211 C21.5533841,243.408951 4.54747351e-13,240.685607 4.54747351e-13,229.536211 C4.54747351e-13,222.103281 0.272088241,145.59121 0.816264722,0 Z" id="path-3"></path>
<polygon id="path-5" points="0 25.9764499 26.0411111 2.29150032e-13 52.9088048 25.9764499"></polygon>
<polygon id="path-7" points="-2.27373675e-13 28.2395915 28.1433883 3.41060513e-13 54.0330976 28.2395915"></polygon>
<path d="M3.53184776,0 L61.4681522,0 C63.1250065,4.9985658e-15 64.4681522,1.34314575 64.4681522,3 C64.4681522,3.16257855 64.4549364,3.32488807 64.4286352,3.48532508 L55.4122418,58.4853251 C55.1745077,59.9355031 53.921294,61 52.4517588,61 L12.5482412,61 C11.078706,61 9.82549232,59.9355031 9.58775821,58.4853251 L0.571364767,3.48532508 C0.303327126,1.85029547 1.41149307,0.307554646 3.04652268,0.0395170047 C3.20695969,0.0132158559 3.36926922,-1.30240244e-15 3.53184776,0 Z" id="path-9"></path>
<path d="M-1.42108547e-14,115.48446 C1.32743544,94.0102656 2.89289856,78.9508436 4.69638937,70.3061937 C8.43003277,52.4097675 15.5176097,37.8448008 19.4787027,30.195863 C29.7253967,10.409323 39.7215535,5.31301339 44.6820442,2.63347577 C49.6425348,-0.0460618448 60.3007481,-1.62222357 66.327433,2.63347577 C72.3541179,6.88917511 74.5668372,13.0533931 73.7454921,23.1564165 C72.924147,33.2594398 65.469448,39.1497458 58.0193289,42.7343523 C50.5692098,46.3189588 31.0128594,60.1734323 19.4787027,74.1118722 C11.7892649,83.4041655 5.29636401,97.195028 -1.42108547e-14,115.48446 Z" id="path-11"></path>
<path d="M0,61.382873 C12.627563,35.4721831 22.8842273,18.9178104 30.7699929,11.7197549 C42.5986412,0.922671591 57.9238693,-1.5327187 66.3547392,0.814866828 C74.7856091,3.16245236 78.9526569,14.6315037 74.3469666,21.3628973 C69.7412762,28.0942909 65.4378728,28.0568843 50.8423324,30.6914365 C36.246792,33.3259886 29.5659376,36.8930178 23.8425136,39.4010039 C21.5824174,40.3913708 15.331987,43.4769377 10.1725242,48.4356558 C7.80517763,50.7108935 4.41433624,55.0266325 0,61.382873 Z" id="path-13"></path>
<path d="M-2.08995462,65.6474954 C12.5975781,38.2270573 23.8842273,20.9178104 31.7699929,13.7197549 C43.5986412,2.92267159 58.9238693,0.467281299 67.3547392,2.81486683 C75.7856091,5.16245236 79.9526569,16.6315037 75.3469666,23.3628973 C70.7412762,30.0942909 66.4378728,30.0568843 51.8423324,32.6914365 C37.246792,35.3259886 30.5659376,38.8930178 24.8425136,41.4010039 C22.5824174,42.3913708 13.2420323,47.7415601 8.08256956,52.7002782 C5.71522301,54.9755159 2.32438162,59.2912549 -2.08995462,65.6474954 Z" id="path-15"></path>
<path d="M70.3618111,117.305105 C65.1514723,93.5149533 59.5592828,76.7727476 53.5852425,67.0784883 C44.6241821,52.5370993 33.2521675,43.1631445 21.9273327,38.7089848 C10.6024978,34.2548251 1.37005489,28.3143707 0.166250333,19.5991494 C-1.03755422,10.8839281 4.30184276,1.89650161 15.9982131,0.359853321 C27.6945835,-1.17679496 39.680528,1.89650161 50.3232751,15.6556441 C60.9660221,29.4147866 71.7898492,71.0503233 71.7898492,87.5111312 C71.7898492,98.4850031 71.3138365,108.416328 70.3618111,117.305105 Z" id="path-17"></path>
<path d="M40.4361627,109.727577 C42.2080966,71.0333394 41.2052946,44.753324 37.4277569,30.8875312 C31.7614504,10.088842 22.8541813,-1.27827958 11.3728741,0.114578571 C-0.108432993,1.50743672 -2.5866861,11.539269 2.54272088,19.2423116 C7.67212787,26.9453541 22.1964111,48.5363293 27.3543068,61.4631547 C30.7929039,70.0810384 35.1535225,86.1691793 40.4361627,109.727577 Z" id="path-19"></path>
<path d="M86.8630745,43.7959111 C72.5806324,23.5140129 56.8667378,10.125403 39.7213908,3.6300812 C14.0033702,-6.11290144 -7.10542736e-15,5.90110838 -7.10542736e-15,14.52167 C-7.10542736e-15,23.1422316 6.80949202,28.0268155 17.0489556,28.0268155 C27.2884192,28.0268155 43.7234658,26.0070237 58.8280258,34.5737997 C68.8977326,40.2849837 79.1842128,49.927944 89.6874666,63.5026805 L86.8630745,43.7959111 Z" id="path-21"></path>
<circle id="path-23" cx="42" cy="42" r="42"></circle>
</defs>
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="画板" transform="translate(-2046.000000, -1809.000000)">
<g id="6" transform="translate(2046.223123, 1809.764697)">
<g id="编组-89" transform="translate(0.109175, 0.235303)">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<use id="路径-307" fill="#F3F7FF" xlink:href="#path-1"></use>
<rect id="矩形" fill="#D0DEFE" mask="url(#mask-2)" x="0" y="362" width="791" height="112"></rect>
<rect id="矩形" fill="#C4D6FF" mask="url(#mask-2)" transform="translate(395.500000, 353.500000) scale(1, -1) translate(-395.500000, -353.500000) " x="0" y="345" width="791" height="17"></rect>
</g>
<rect id="矩形" stroke="#979797" fill="#D8D8D8" x="632.609175" y="381.735303" width="39" height="10"></rect>
<rect id="矩形" stroke="#979797" fill="#D8D8D8" x="632.609175" y="402.735303" width="39" height="10"></rect>
<rect id="矩形" stroke="#979797" fill="#D8D8D8" x="628.609175" y="392.735303" width="39" height="10"></rect>
<g id="编组-88" transform="translate(547.109175, 141.235303)">
<rect id="矩形" fill="#BCD4FF" x="0" y="0" width="144" height="281"></rect>
<rect id="矩形" fill="#EDF4FF" x="5" y="10.5" width="131" height="262"></rect>
<rect id="矩形" fill="#9EBEF8" x="106" y="10.5" width="30" height="262"></rect>
<rect id="矩形" fill="#BCD4FF" x="56" y="136" width="80" height="8"></rect>
<rect id="矩形" fill="#BCD4FF" x="56" y="203" width="80" height="8"></rect>
<g id="编组-87" transform="translate(63.000000, 153.000000)">
<rect id="矩形" fill="#FFECC8" x="29" y="0" width="40" height="50"></rect>
<rect id="矩形" fill="#FFE2AC" x="58" y="0" width="11" height="50"></rect>
<rect id="矩形" fill="#D3E1FF" x="14" y="0" width="40" height="50"></rect>
<rect id="矩形" fill="#BDD2FF" x="43" y="0" width="11" height="50"></rect>
<rect id="矩形" fill="#FFECC8" x="0" y="8" width="40" height="42"></rect>
<rect id="矩形" fill="#FFE2AC" x="29" y="8" width="11" height="42"></rect>
</g>
<g id="编组-87" transform="translate(63.000000, 222.000000)">
<rect id="矩形" fill="#FFECC8" x="29" y="0" width="40" height="50"></rect>
<rect id="矩形" fill="#FFE2AC" x="58" y="0" width="11" height="50"></rect>
<rect id="矩形" fill="#D3E1FF" x="14" y="0" width="40" height="50"></rect>
<rect id="矩形" fill="#BDD2FF" x="43" y="0" width="11" height="50"></rect>
<rect id="矩形" fill="#FFECC8" x="0" y="8" width="40" height="42"></rect>
<rect id="矩形" fill="#FFE2AC" x="29" y="8" width="11" height="42"></rect>
</g>
<g id="编组-87" transform="translate(63.000000, 86.000000)">
<rect id="矩形" fill="#FFECC8" x="29" y="0" width="40" height="50"></rect>
<rect id="矩形" fill="#FFE2AC" x="58" y="0" width="11" height="50"></rect>
<rect id="矩形" fill="#D3E1FF" x="14" y="0" width="40" height="50"></rect>
<rect id="矩形" fill="#BDD2FF" x="43" y="0" width="11" height="50"></rect>
<rect id="矩形" fill="#FFECC8" x="0" y="8" width="40" height="42"></rect>
<rect id="矩形" fill="#FFE2AC" x="29" y="8" width="11" height="42"></rect>
</g>
</g>
<path d="M206.109175,130.235303 L586.109175,130.235303 C596.0503,130.235303 604.109175,138.294177 604.109175,148.235303 L604.109175,417.235303 L604.109175,417.235303 L188.109175,417.235303 L188.109175,148.235303 C188.109175,138.294177 196.168049,130.235303 206.109175,130.235303 Z" id="矩形" fill="#DDE9FF"></path>
<path d="M206.109175,130.235303 L586.109175,130.235303 C596.0503,130.235303 604.109175,138.294177 604.109175,148.235303 L604.109175,163.235303 L604.109175,163.235303 L188.109175,163.235303 L188.109175,148.235303 C188.109175,138.294177 196.168049,130.235303 206.109175,130.235303 Z" id="矩形" fill="#FFECC8"></path>
<path d="M206.109175,130.235303 L586.109175,130.235303 C596.0503,130.235303 604.109175,138.294177 604.109175,148.235303 L604.109175,160.235303 L604.109175,160.235303 L188.109175,160.235303 L188.109175,148.235303 C188.109175,138.294177 196.168049,130.235303 206.109175,130.235303 Z" id="矩形" fill="#A4C3FC"></path>
<circle id="椭圆形" fill="#FFBB3C" cx="210" cy="146" r="4"></circle>
<circle id="椭圆形" fill="#ECF2FF" cx="223.109175" cy="145.235303" r="4"></circle>
<g id="编组-86" transform="translate(210.109175, 178.235303)">
<mask id="mask-4" fill="white">
<use xlink:href="#path-3"></use>
</mask>
<use id="路径-289" fill="#FFFFFF" xlink:href="#path-3"></use>
<rect id="矩形" fill="#ECF2FF" mask="url(#mask-4)" x="50.8162647" y="180.401344" width="412" height="87"></rect>
<polygon id="路径" fill="#FFEAC2" fill-rule="nonzero" mask="url(#mask-4)" points="361.449861 8.85304449 361.449861 180.761462 360.449861 180.761462 360.449 9.853 14.449 9.853 14.449 223.853 27.9199219 223.853044 27.9199219 224.853044 13.4498606 224.853044 13.4498606 8.85304449"></polygon>
</g>
<path d="M333.259175,333.235303 L333.259175,308.935303 L350.659175,308.935303 L350.659175,298.885303 L333.259175,298.885303 L333.259175,226.135303 L321.709175,226.135303 L267.709175,297.235303 L267.709175,308.935303 L321.559175,308.935303 L321.559175,333.235303 L333.259175,333.235303 Z M321.559175,298.885303 L278.059175,298.885303 L321.109175,242.185303 L321.559175,242.185303 L321.559175,298.885303 Z M399.109175,335.335303 C411.859175,335.335303 421.459175,329.635303 428.059175,318.385303 C433.759175,308.785303 436.609175,295.885303 436.609175,279.685303 C436.609175,263.485303 433.759175,250.585303 428.059175,240.985303 C421.459175,229.585303 411.859175,224.035303 399.109175,224.035303 C386.209175,224.035303 376.609175,229.585303 370.159175,240.985303 C364.459175,250.585303 361.609175,263.485303 361.609175,279.685303 C361.609175,295.885303 364.459175,308.785303 370.159175,318.385303 C376.609175,329.635303 386.209175,335.335303 399.109175,335.335303 Z M399.109175,324.835303 C389.509175,324.835303 382.609175,319.585303 378.409175,309.385303 C375.409175,302.035303 373.909175,292.135303 373.909175,279.685303 C373.909175,267.085303 375.409175,257.185303 378.409175,249.985303 C382.609175,239.635303 389.509175,234.535303 399.109175,234.535303 C408.709175,234.535303 415.609175,239.635303 419.809175,249.985303 C422.809175,257.185303 424.459175,267.085303 424.459175,279.685303 C424.459175,292.135303 422.809175,302.035303 419.809175,309.385303 C415.609175,319.585303 408.709175,324.835303 399.109175,324.835303 Z M513.259175,333.235303 L513.259175,308.935303 L530.659175,308.935303 L530.659175,298.885303 L513.259175,298.885303 L513.259175,226.135303 L501.709175,226.135303 L447.709175,297.235303 L447.709175,308.935303 L501.559175,308.935303 L501.559175,333.235303 L513.259175,333.235303 Z M501.559175,298.885303 L458.059175,298.885303 L501.109175,242.185303 L501.559175,242.185303 L501.559175,298.885303 Z" id="404" fill="#FFEAC2" fill-rule="nonzero"></path>
<path d="M330.259175,330.235303 L330.259175,305.935303 L347.659175,305.935303 L347.659175,295.885303 L330.259175,295.885303 L330.259175,223.135303 L318.709175,223.135303 L264.709175,294.235303 L264.709175,305.935303 L318.559175,305.935303 L318.559175,330.235303 L330.259175,330.235303 Z M318.559175,295.885303 L275.059175,295.885303 L318.109175,239.185303 L318.559175,239.185303 L318.559175,295.885303 Z M396.109175,332.335303 C408.859175,332.335303 418.459175,326.635303 425.059175,315.385303 C430.759175,305.785303 433.609175,292.885303 433.609175,276.685303 C433.609175,260.485303 430.759175,247.585303 425.059175,237.985303 C418.459175,226.585303 408.859175,221.035303 396.109175,221.035303 C383.209175,221.035303 373.609175,226.585303 367.159175,237.985303 C361.459175,247.585303 358.609175,260.485303 358.609175,276.685303 C358.609175,292.885303 361.459175,305.785303 367.159175,315.385303 C373.609175,326.635303 383.209175,332.335303 396.109175,332.335303 Z M396.109175,321.835303 C386.509175,321.835303 379.609175,316.585303 375.409175,306.385303 C372.409175,299.035303 370.909175,289.135303 370.909175,276.685303 C370.909175,264.085303 372.409175,254.185303 375.409175,246.985303 C379.609175,236.635303 386.509175,231.535303 396.109175,231.535303 C405.709175,231.535303 412.609175,236.635303 416.809175,246.985303 C419.809175,254.185303 421.459175,264.085303 421.459175,276.685303 C421.459175,289.135303 419.809175,299.035303 416.809175,306.385303 C412.609175,316.585303 405.709175,321.835303 396.109175,321.835303 Z M510.259175,330.235303 L510.259175,305.935303 L527.659175,305.935303 L527.659175,295.885303 L510.259175,295.885303 L510.259175,223.135303 L498.709175,223.135303 L444.709175,294.235303 L444.709175,305.935303 L498.559175,305.935303 L498.559175,330.235303 L510.259175,330.235303 Z M498.559175,295.885303 L455.059175,295.885303 L498.109175,239.185303 L498.559175,239.185303 L498.559175,295.885303 Z" id="404" fill="#ACC9FF" fill-rule="nonzero"></path>
<polygon id="路径-298" fill="#6EA1FF" fill-rule="nonzero" points="369.741481 26.3549544 369.741481 145.784171 368.741481 145.784171 368.741481 26.3549544"></polygon>
<g id="编组-113" transform="translate(343.200370, 145.784171)">
<mask id="mask-6" fill="white">
<use xlink:href="#path-5"></use>
</mask>
<use id="路径-299" fill="#FFD078" xlink:href="#path-5"></use>
<polygon id="路径-299" fill="#FFBB3C" mask="url(#mask-6)" points="-3 25.9764499 23.0411111 1.77635684e-15 49.9088048 25.9764499"></polygon>
</g>
<polygon id="路径-300" fill="#6EA1FF" fill-rule="nonzero" points="254.30695 -0.00143864693 255.306945 0.00143864694 255.109173 68.7367415 254.109177 68.7338642"></polygon>
<g id="编组-112" transform="translate(226.663559, 65.717848)">
<mask id="mask-8" fill="white">
<use xlink:href="#path-7"></use>
</mask>
<use id="路径-301" fill="#D2E2FF" xlink:href="#path-7"></use>
<polygon id="路径-301" fill="#A4C3FC" mask="url(#mask-8)" points="-3 28.2395915 25.1433883 -1.13686838e-13 51.0330976 28.2395915"></polygon>
</g>
<path d="M464.109175,72.2353029 L574.109175,72.2353029 C578.527453,72.2353029 582.109175,75.8170249 582.109175,80.2353029 L582.109175,152.269143 L582.109175,152.269143 L602.747625,174.760621 L464.163722,175.18059 C454.222643,175.210716 446.139383,167.1763 446.109258,157.23522 C446.109203,157.217038 446.109175,157.198855 446.109175,157.180672 L446.109175,90.2353029 C446.109175,80.2941774 454.168049,72.2353029 464.109175,72.2353029 Z" id="矩形" fill="#FFECC8"></path>
<path d="M460.109175,69.2353029 L570.109175,69.2353029 C574.527453,69.2353029 578.109175,72.8170249 578.109175,77.2353029 L578.109175,149.269143 L578.109175,149.269143 L598.747625,171.760621 L460.163722,172.18059 C450.222643,172.210716 442.139383,164.1763 442.109258,154.23522 C442.109203,154.217038 442.109175,154.198855 442.109175,154.180672 L442.109175,87.2353029 C442.109175,77.2941774 450.168049,69.2353029 460.109175,69.2353029 Z" id="矩形" fill="#EBF2FF"></path>
<rect id="矩形" fill="#FFFFFF" x="480" y="95" width="7" height="64"></rect>
<rect id="矩形" fill="#FFFFFF" x="497.109175" y="95" width="7" height="64"></rect>
<rect id="矩形" fill="#FFFFFF" x="514.109175" y="95" width="7" height="64"></rect>
<rect id="矩形" fill="#FFFFFF" x="530.109175" y="95" width="7" height="64"></rect>
<rect id="矩形" fill="#FFFFFF" x="546.109175" y="95" width="7" height="64"></rect>
<polygon id="路径-302" fill="#A4C3FC" fill-rule="nonzero" points="466.970801 86.0695627 466.97 158.272 566.971883 158.272396 566.971883 159.272396 465.970801 159.272396 465.970801 86.0695627"></polygon>
<polygon id="路径-304" fill="#979797" fill-rule="nonzero" points="559.240013 152.472555 559.909745 151.729952 567.687435 158.744424 559.937708 166.917681 559.21205 166.229626 566.256 158.8"></polygon>
<path d="M547.776877,151.235303 L657.776877,151.235303 C662.195155,151.235303 665.776877,154.817025 665.776877,159.235303 L665.776877,231.269143 L665.776877,231.269143 L686.415326,253.760621 L547.831424,254.18059 C537.890344,254.210716 529.807085,246.1763 529.776959,236.23522 C529.776904,236.217038 529.776877,236.198855 529.776877,236.180672 L529.776877,169.235303 C529.776877,159.294177 537.835751,151.235303 547.776877,151.235303 Z" id="矩形" fill="#A4C3FC" transform="translate(608.096101, 202.735303) scale(-1, 1) translate(-608.096101, -202.735303) "></path>
<path d="M542.776877,150.235303 L652.776877,150.235303 C657.195155,150.235303 660.776877,153.817025 660.776877,158.235303 L660.776877,230.269143 L660.776877,230.269143 L681.415326,252.760621 L542.831424,253.18059 C532.890344,253.210716 524.807085,245.1763 524.776959,235.23522 C524.776904,235.217038 524.776877,235.198855 524.776877,235.180672 L524.776877,168.235303 C524.776877,158.294177 532.835751,150.235303 542.776877,150.235303 Z" id="矩形" fill="#FFCA67" transform="translate(603.096101, 201.735303) scale(-1, 1) translate(-603.096101, -201.735303) "></path>
<path d="M551.888365,105.031459 C555.290806,103.139777 558.513795,102.897668 562.237517,104.467631 L562.588104,104.620129 L562.17909,105.532657 C558.599379,103.928154 555.612548,104.105059 552.37429,105.905459 C550.368282,107.020755 548.58771,108.45472 545.16394,111.609463 L541.614214,114.898486 C538.015181,118.209826 536.087942,119.845252 533.11225,122.086913 C532.782184,122.335559 532.450805,122.581445 532.117803,122.824718 C528.104792,125.756407 523.934988,126.987135 519.313532,126.876779 C516.035171,126.798495 513.270144,126.221396 508.17289,124.737029 L505.737532,124.022849 C497.810115,121.733418 494.471662,121.366012 490.348408,122.889971 C482.286296,125.869735 475.026188,137.650266 468.664891,158.243664 L468.457777,158.918311 L467.501306,158.626481 C473.997113,137.336567 481.463921,125.107569 490.001728,121.951987 C494.55996,120.267261 498.129316,120.741123 506.945337,123.333314 L508.921,123.912647 C513.638819,125.272395 516.27064,125.803833 519.337404,125.877064 C523.744193,125.982294 527.697642,124.815424 531.527904,122.017241 L532.020296,121.654721 L532.510552,121.288189 C535.634194,118.935074 537.593599,117.254212 541.615416,113.537015 L544.497926,110.863327 C547.974881,107.659837 549.794043,106.195856 551.888365,105.031459 Z" id="路径-303" fill="#FFBB3C" fill-rule="nonzero"></path>
<polygon id="路径-305" fill="#A4C3FC" fill-rule="nonzero" points="458.750713 92.4640098 466.468831 85.3932668 474.275626 92.4620486 473.604426 93.2033249 466.472 86.745 459.42622 93.2013637"></polygon>
<g id="编组-81" transform="translate(50.109175, 134.235303)">
<g id="编组-63" transform="translate(63.914093, 222.107327)">
<mask id="mask-10" fill="white">
<use xlink:href="#path-9"></use>
</mask>
<use id="矩形" fill="#DDE9FF" xlink:href="#path-9"></use>
<path d="M1.20882698,0 L63.4052217,0 C64.4023405,3.36954592e-15 65.3342971,0.495421402 65.8920292,1.32196893 L67.6990928,4 C68.491521,5.17436234 68.1819023,6.76876112 67.0075399,7.56118935 C66.5836904,7.84719165 66.0840393,8 65.5727217,8 L2.06042429,8 C0.819095645,8 -0.294028853,7.23549708 -0.7396257,6.076903 L-1.5384054,4 C-2.12194354,2.48274545 -1.36501684,0.779716485 0.152237704,0.196178338 C0.489411271,0.0665009295 0.84757604,9.54539142e-16 1.20882698,0 Z" id="矩形" fill="#FFBB3C" mask="url(#mask-10)"></path>
</g>
<g id="编组-103" transform="translate(90.000000, 84.000000)">
<mask id="mask-12" fill="white">
<use xlink:href="#path-11"></use>
</mask>
<use id="路径-246" fill="#EAFFF3" xlink:href="#path-11"></use>
<path d="M-1.42108547e-14,119.48446 C1.32743544,98.0102656 2.89289856,82.9508436 4.69638937,74.3061937 C8.43003277,56.4097675 15.5176097,41.8448008 19.4787027,34.195863 C29.7253967,14.409323 39.7215535,9.31301339 44.6820442,6.63347577 C49.6425348,3.95393816 60.3007481,2.37777643 66.327433,6.63347577 C72.3541179,10.8891751 74.5668372,17.0533931 73.7454921,27.1564165 C72.924147,37.2594398 65.469448,43.1497458 58.0193289,46.7343523 C50.5692098,50.3189588 31.0128594,64.1734323 19.4787027,78.1118722 C11.7892649,87.4041655 5.29636401,101.195028 -1.42108547e-14,119.48446 Z" id="路径-246" fill="#D3F0E0" mask="url(#mask-12)"></path>
<path d="M61.0623172,22.0501917 C61.3287364,21.9775593 61.6035919,22.1346545 61.6762243,22.4010736 C61.7488567,22.6674927 61.5917615,22.9423483 61.3253424,23.0149807 C30.6460939,31.3788982 10.4195539,62.2160822 0.685726462,115.62224 C0.636212326,115.893907 0.375843567,116.073997 0.104176562,116.024483 C-0.167490443,115.974969 -0.347580926,115.7146 -0.29806679,115.442933 C9.49743654,61.6983811 29.9375632,30.535565 61.0623172,22.0501917 Z" id="路径-251" fill="#9FC8B1" fill-rule="nonzero" mask="url(#mask-12)"></path>
<path d="M53.2988281,45.3242187 C23.2203776,62.4189453 5.8733724,84.8946126 1.2578125,112.751221 C31.0439453,72.423808 48.9638672,50.9959922 55.0175781,48.4677734 C61.0712891,45.9395547 60.4983724,44.8917031 53.2988281,45.3242187 Z" id="路径-358" fill="#C4E0D1" mask="url(#mask-12)"></path>
</g>
<g id="编组-102" transform="translate(112.698267, 46.543175)">
<mask id="mask-14" fill="white">
<use xlink:href="#path-13"></use>
</mask>
<use id="路径-247" fill="#EAFFF3" xlink:href="#path-13"></use>
<mask id="mask-16" fill="white">
<use xlink:href="#path-15"></use>
</mask>
<use id="路径-247" fill="#D3F0E0" xlink:href="#path-15"></use>
<path d="M60.5426357,11.3128799 C60.8171154,11.2826219 61.064154,11.4806027 61.094412,11.7550823 C61.12467,12.0295619 60.9266892,12.2766006 60.6522096,12.3068585 C39.729997,14.6132741 18.9462607,31.6462845 -1.67037213,63.4563407 C-1.8205596,63.6880697 -2.13016408,63.7541722 -2.36189309,63.6039847 C-2.5936221,63.4537972 -2.65972458,63.1441927 -2.50953711,62.9124637 C18.2541689,30.8754836 39.2620175,13.6588053 60.5426357,11.3128799 Z" id="路径-253" fill="#9FC8B1" fill-rule="nonzero" mask="url(#mask-16)"></path>
<path d="M77,14.004188 C76.1582967,19.0507483 74.4123575,22.6778642 71.7621824,24.8855357 C67.7869198,28.1970428 61.7621824,29.629188 56.5004637,30.6914365 C51.2387449,31.753685 28.3095457,36.4578462 17.2245848,45.7839732 L39.1176512,38.3640513 L70.2081638,30.6914365 L79.9838621,20.4621958 L77,14.004188 Z" id="路径-357" fill="#C4E0D1" mask="url(#mask-16)"></path>
</g>
<g id="编组-105" transform="translate(17.048956, 30.887531)">
<mask id="mask-18" fill="white">
<use xlink:href="#path-17"></use>
</mask>
<use id="路径-248" fill="#EAFFF3" xlink:href="#path-17"></use>
<path d="M69.3618111,119.305105 C64.1514723,95.5149533 58.5592828,78.7727476 52.5852425,69.0784883 C43.6241821,54.5370993 32.2521675,45.1631445 20.9273327,40.7089848 C9.60249781,36.2548251 0.370054887,30.3143707 -0.833749667,21.5991494 C-2.03755422,12.8839281 3.30184276,3.89650161 14.9982131,2.35985332 C26.6945835,0.823205037 38.680528,3.89650161 49.3232751,17.6556441 C59.9660221,31.4147866 70.7898492,73.0503233 70.7898492,89.5111312 C70.7898492,100.485003 70.3138365,110.416328 69.3618111,119.305105 Z" id="路径-248" fill="#D3F0E0" mask="url(#mask-18)"></path>
<path d="M23.8031025,13.6524169 C23.9507563,13.4197035 24.2596222,13.3495935 24.4929738,13.4972473 C53.619825,31.9273315 69.2261583,67.7464847 71.3453163,120.899853 C71.3559107,121.175776 71.1411468,121.408372 70.8652236,121.419775 C70.5893003,121.43037 70.3567042,121.215606 70.3457056,120.939683 C68.238827,68.0842278 52.7653698,32.5700476 23.9582721,14.3422882 C23.7249205,14.1946344 23.6554487,13.8857685 23.8031025,13.6524169 Z" id="路径-254" fill="#9FC8B1" fill-rule="nonzero" mask="url(#mask-18)"></path>
<path d="M2.10955328,9.39371881 C-0.468124531,21.2459323 4.83890245,29.8251967 18.0306342,35.1315118 C37.8182319,43.0909844 45.0262397,50.6209933 54.8953803,65.9239922 C61.4748074,76.1259915 66.5415392,93.4846608 70.0955756,118 L-6.37610406,29.466961 L2.10955328,9.39371881 Z" id="路径-354" fill="#C4E0D1" mask="url(#mask-18)"></path>
</g>
<g id="编组-104" transform="translate(48.402642, -0.000000)">
<mask id="mask-20" fill="white">
<use xlink:href="#path-19"></use>
</mask>
<use id="路径-249" fill="#EAFFF3" xlink:href="#path-19"></use>
<path d="M38.4361627,109.727577 C40.2080966,71.0333394 39.2052946,44.753324 35.4277569,30.8875312 C29.7614504,10.088842 20.8541813,-1.27827958 9.37287413,0.114578571 C-2.10843299,1.50743672 -4.5866861,11.539269 0.542720885,19.2423116 C5.67212787,26.9453541 20.1964111,48.5363293 25.3543068,61.4631547 C28.7929039,70.0810384 33.1535225,86.1691793 38.4361627,109.727577 Z" id="路径-249" fill="#D3F0E0" mask="url(#mask-20)" transform="translate(18.642412, 54.863789) rotate(-2.000000) translate(-18.642412, -54.863789) "></path>
<path d="M1.96015082,4.87988281 C0.585845275,10.6699219 1.44620769,15.6948242 4.54123807,19.9545898 C9.18378363,26.3442383 28.9997016,54.6122295 32.8727485,77.5431753 L3.69550238,25.6259766 L-2.5784234,12.7954102 L1.96015082,4.87988281 Z" id="路径-356" fill="#C4E0D1" mask="url(#mask-20)"></path>
</g>
<g id="编组-106" transform="translate(-0.000000, 140.484501)">
<mask id="mask-22" fill="white">
<use xlink:href="#path-21"></use>
</mask>
<use id="路径-250" fill="#EAFFF3" xlink:href="#path-21"></use>
<path d="M86.8630745,45.7959111 C72.5806324,25.5140129 56.8667378,12.125403 39.7213908,5.6300812 C14.0033702,-4.11290144 -7.10542736e-15,7.90110838 -7.10542736e-15,16.52167 C-7.10542736e-15,25.1422316 6.80949202,30.0268155 17.0489556,30.0268155 C27.2884192,30.0268155 43.7234658,28.0070237 58.8280258,36.5737997 C68.8977326,42.2849837 79.1842128,51.927944 89.6874666,65.5026805 L86.8630745,45.7959111 Z" id="路径-250" fill="#D3F0E0" mask="url(#mask-22)"></path>
<path d="M1.1501319,20.1112023 C3.60224814,22.8815414 6.77648803,24.6116846 10.6728516,25.301632 C16.5173969,26.3365529 23.2104492,26.0726281 29.4458008,26.0726281 C47.5162559,26.0726281 66.1025391,30.4051476 89.6874666,63.5026805 L73.4389648,56.501339 L2.37988281,27.6380577 L1.1501319,20.1112023 Z" id="路径-355" fill="#C4E0D1" mask="url(#mask-22)"></path>
</g>
<path d="M63.5963011,11.6405216 C63.8094921,11.4650103 64.1245976,11.4955556 64.3001089,11.7087466 C74.8415791,24.513311 82.3205992,48.9543952 86.3331527,79.7677903 L86.4536838,80.7034746 C89.7765293,106.781999 90.2171879,135.285754 87.8968469,153.563607 L87.8038207,154.279761 C84.7801018,177.032488 87.8036828,199.576221 96.8773487,221.919195 C96.9812512,222.175044 96.858074,222.466681 96.6022247,222.570583 C96.3463753,222.674486 96.0547388,222.551308 95.9508363,222.295459 C86.945111,200.119782 83.8552445,177.733575 86.6839023,155.144723 L86.812536,154.148024 C89.2247883,135.99643 88.8179579,107.170593 85.4617038,80.8298697 L85.2198735,78.9727728 C81.2011719,48.7675619 73.814123,24.8386348 63.5280761,12.3443293 C63.3525648,12.1311384 63.3831102,11.8160329 63.5963011,11.6405216 Z" id="路径-245" fill="#9FC8B1" fill-rule="nonzero"></path>
<path d="M19.210295,152.513695 C50.2079907,155.741553 73.2591803,169.485692 88.3189956,193.730479 C88.4647019,193.965052 88.3926616,194.273329 88.1580891,194.419035 C87.9235165,194.564741 87.6152396,194.492701 87.4695333,194.258128 C72.5772256,170.283012 49.8045783,156.704952 19.1067228,153.508317 C18.8320656,153.479717 18.6325973,153.233878 18.6611979,152.95922 C18.6866207,152.715081 18.8836888,152.530349 19.1200684,152.512399 L19.210295,152.513695 Z" id="路径-252" fill="#9FC8B1" fill-rule="nonzero"></path>
</g>
<g id="编组-76" transform="translate(570.109175, 160.235303)">
<mask id="mask-24" fill="white">
<use xlink:href="#path-23"></use>
</mask>
<use id="椭圆形" fill="#FFFFFF" xlink:href="#path-23"></use>
<polygon id="路径-306" fill="#CCDEFF" mask="url(#mask-24)" points="42 44.0199101 77.9801299 18.2088648 90.719119 40.5 84 59.9151825"></polygon>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><g fill="none"><path fill="url(#fluentColorNews166)" d="M13 4a2 2 0 0 1 2 2v4.5a2.5 2.5 0 0 1-2.5 2.5l-.023-9z"/><path fill="url(#fluentColorNews160)" d="M1 4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8.95q-.243.05-.5.05h-9A2.5 2.5 0 0 1 1 10.5z"/><path fill="url(#fluentColorNews161)" d="M1 4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8.95q-.243.05-.5.05h-9A2.5 2.5 0 0 1 1 10.5z"/><path fill="url(#fluentColorNews162)" d="M1 4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8.95q-.243.05-.5.05h-9A2.5 2.5 0 0 1 1 10.5z"/><path fill="url(#fluentColorNews163)" d="M3.5 7a.5.5 0 0 0-.5.5v2a.5.5 0 0 0 .5.5h2a.5.5 0 0 0 .5-.5v-2a.5.5 0 0 0-.5-.5z"/><path fill="url(#fluentColorNews164)" d="M7.5 7a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1zm0 2a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1z"/><path fill="url(#fluentColorNews165)" d="M3.5 5a.5.5 0 0 0 0 1h7a.5.5 0 0 0 0-1z"/><defs><linearGradient id="fluentColorNews160" x1="4.429" x2="13.346" y1=".308" y2="12.311" gradientUnits="userSpaceOnUse"><stop stop-color="#3bd5ff"/><stop offset="1" stop-color="#367af2"/></linearGradient><linearGradient id="fluentColorNews161" x1="7.857" x2="7.857" y1="10.885" y2="13" gradientUnits="userSpaceOnUse"><stop offset=".181" stop-color="#2764e7" stop-opacity="0"/><stop offset="1" stop-color="#2764e7"/></linearGradient><linearGradient id="fluentColorNews162" x1="7.429" x2="11.535" y1="5.385" y2="16.126" gradientUnits="userSpaceOnUse"><stop stop-color="#dcf8ff" stop-opacity="0"/><stop offset="1" stop-color="#ff6ce8" stop-opacity="0.7"/></linearGradient><linearGradient id="fluentColorNews163" x1="3.286" x2="4.787" y1="6.853" y2="9.857" gradientUnits="userSpaceOnUse"><stop stop-color="#defcff"/><stop offset="1" stop-color="#9ff0f9"/></linearGradient><linearGradient id="fluentColorNews164" x1="7.35" x2="7.728" y1="7.053" y2="10.301" gradientUnits="userSpaceOnUse"><stop stop-color="#fdfdfd"/><stop offset="1" stop-color="#cceaff"/></linearGradient><linearGradient id="fluentColorNews165" x1="3.7" x2="3.721" y1="5.018" y2="6.115" gradientUnits="userSpaceOnUse"><stop stop-color="#fdfdfd"/><stop offset="1" stop-color="#cceaff"/></linearGradient><radialGradient id="fluentColorNews166" cx="0" cy="0" r="1" gradientTransform="matrix(-4.03753 4.94997 -7.38959 -6.02744 16.514 5.35)" gradientUnits="userSpaceOnUse"><stop stop-color="#068beb"/><stop offset=".617" stop-color="#0056cf"/><stop offset=".974" stop-color="#0027a7"/></radialGradient></defs></g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><g fill="none"><path fill="url(#fluentColorDocumentFolder160)" d="M5 5.5A1.5 1.5 0 0 1 6.5 4h5A1.5 1.5 0 0 1 13 5.5v5a1.5 1.5 0 0 1-1.5 1.5h-5A1.5 1.5 0 0 1 5 10.5z"/><path fill="url(#fluentColorDocumentFolder161)" d="M5 5.5A1.5 1.5 0 0 1 6.5 4h5A1.5 1.5 0 0 1 13 5.5v5a1.5 1.5 0 0 1-1.5 1.5h-5A1.5 1.5 0 0 1 5 10.5z"/><path fill="url(#fluentColorDocumentFolder163)" d="M3 3.5A1.5 1.5 0 0 1 4.5 2h5A1.5 1.5 0 0 1 11 3.5v7A1.5 1.5 0 0 1 9.5 12h-5A1.5 1.5 0 0 1 3 10.5z"/><path fill="url(#fluentColorDocumentFolder162)" d="M3.5 5A1.5 1.5 0 0 0 2 6.5V12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-.5a1.5 1.5 0 0 0-1.5-1.5h-1.586a.5.5 0 0 1-.353-.146L6.146 5.439A1.5 1.5 0 0 0 5.086 5z"/><defs><linearGradient id="fluentColorDocumentFolder160" x1="14.2" x2="15.247" y1="13.539" y2="5.069" gradientUnits="userSpaceOnUse"><stop stop-color="#bb45ea"/><stop offset="1" stop-color="#9c6cfe"/></linearGradient><linearGradient id="fluentColorDocumentFolder161" x1="13" x2="11" y1="6.769" y2="6.769" gradientUnits="userSpaceOnUse"><stop offset=".338" stop-color="#5750e2" stop-opacity="0"/><stop offset="1" stop-color="#5750e2"/></linearGradient><linearGradient id="fluentColorDocumentFolder162" x1="4.571" x2="4.571" y1="5" y2="17.273" gradientUnits="userSpaceOnUse"><stop offset=".241" stop-color="#ffd638"/><stop offset=".637" stop-color="#fab500"/><stop offset=".985" stop-color="#ca6407"/></linearGradient><radialGradient id="fluentColorDocumentFolder163" cx="0" cy="0" r="1" gradientTransform="matrix(5.2 -7.66667 11.90405 8.07405 5.4 10)" gradientUnits="userSpaceOnUse"><stop offset=".228" stop-color="#2764e7"/><stop offset=".685" stop-color="#5cd1ff"/><stop offset="1" stop-color="#6ce0ff"/></radialGradient></defs></g></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><g fill="none"><path fill="url(#fluentColorPoll160)" d="M6 3a2 2 0 1 1 4 0v10a2 2 0 1 1-4 0z"/><path fill="url(#fluentColorPoll161)" d="M13 5a2 2 0 0 0-2 2v6a2 2 0 1 0 4 0V7a2 2 0 0 0-2-2"/><path fill="url(#fluentColorPoll162)" d="M3 7a2 2 0 0 0-2 2v4a2 2 0 1 0 4 0V9a2 2 0 0 0-2-2"/><defs><linearGradient id="fluentColorPoll160" x1="9.667" x2="7.529" y1="12.433" y2=".854" gradientUnits="userSpaceOnUse"><stop stop-color="#6d37cd"/><stop offset="1" stop-color="#ea71ef"/></linearGradient><linearGradient id="fluentColorPoll161" x1="14.667" x2="13.558" y1="13.167" y2="4.76" gradientUnits="userSpaceOnUse"><stop stop-color="#e23cb4"/><stop offset="1" stop-color="#ea71ef"/></linearGradient><linearGradient id="fluentColorPoll162" x1="1.5" x2="9.148" y1="7.333" y2="11.857" gradientUnits="userSpaceOnUse"><stop stop-color="#36dff1"/><stop offset="1" stop-color="#0078d4"/></linearGradient></defs></g></svg>

After

Width:  |  Height:  |  Size: 988 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><g fill="none"><path fill="url(#fluentColorPeople160)" d="M10.038 12.264c.384.145.863.236 1.462.236c.598 0 1.077-.09 1.46-.235c1.538-.582 1.538-2.04 1.538-2.04c0-.677-.549-1.225-1.225-1.225H9.725C9.048 9 8.5 9.548 8.5 10.225c0 0 0 1.457 1.538 2.04"/><path fill="url(#fluentColorPeople165)" fill-opacity="0.5" d="M10.038 12.264c.384.145.863.236 1.462.236c.598 0 1.077-.09 1.46-.235c1.538-.582 1.538-2.04 1.538-2.04c0-.677-.549-1.225-1.225-1.225H9.725C9.048 9 8.5 9.548 8.5 10.225c0 0 0 1.457 1.538 2.04"/><path fill="url(#fluentColorPeople161)" d="M9.5 10.5A1.5 1.5 0 0 0 8 9H3a1.5 1.5 0 0 0-1.5 1.5v.075s0 2.925 4 2.925c3.812 0 3.991-2.656 4-2.906z"/><path fill="url(#fluentColorPeople162)" d="M9.5 10.5A1.5 1.5 0 0 0 8 9H3a1.5 1.5 0 0 0-1.5 1.5v.075s0 2.925 4 2.925c3.812 0 3.991-2.656 4-2.906z"/><path fill="url(#fluentColorPeople163)" d="M11.5 8a2 2 0 1 0 0-4a2 2 0 0 0 0 4"/><path fill="url(#fluentColorPeople164)" d="M8 5.5a2.5 2.5 0 1 1-5 0a2.5 2.5 0 0 1 5 0"/><defs><linearGradient id="fluentColorPeople160" x1="9.926" x2="11.205" y1="9.465" y2="12.963" gradientUnits="userSpaceOnUse"><stop offset=".125" stop-color="#9c6cfe"/><stop offset="1" stop-color="#7a41dc"/></linearGradient><linearGradient id="fluentColorPeople161" x1="3.402" x2="5" y1="9.598" y2="14.134" gradientUnits="userSpaceOnUse"><stop offset=".125" stop-color="#bd96ff"/><stop offset="1" stop-color="#9c6cfe"/></linearGradient><linearGradient id="fluentColorPeople162" x1="5.5" x2="7.75" y1="8.464" y2="15.939" gradientUnits="userSpaceOnUse"><stop stop-color="#885edb" stop-opacity="0"/><stop offset="1" stop-color="#e362f8"/></linearGradient><linearGradient id="fluentColorPeople163" x1="10.451" x2="12.49" y1="4.532" y2="7.787" gradientUnits="userSpaceOnUse"><stop offset=".125" stop-color="#9c6cfe"/><stop offset="1" stop-color="#7a41dc"/></linearGradient><linearGradient id="fluentColorPeople164" x1="4.189" x2="6.737" y1="3.665" y2="7.734" gradientUnits="userSpaceOnUse"><stop offset=".125" stop-color="#bd96ff"/><stop offset="1" stop-color="#9c6cfe"/></linearGradient><radialGradient id="fluentColorPeople165" cx="0" cy="0" r="1" gradientTransform="matrix(3.9472 -.51042 .5289 4.09009 7.74 10.75)" gradientUnits="userSpaceOnUse"><stop offset=".392" stop-color="#3b148a"/><stop offset="1" stop-color="#3b148a" stop-opacity="0"/></radialGradient></defs></g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -0,0 +1,78 @@
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import {
BarChart,
LineChart,
PieChart,
RadarChart,
GaugeChart
} from 'echarts/charts'
import {
GridComponent,
TooltipComponent,
LegendComponent,
DataZoomComponent,
GraphicComponent
} from 'echarts/components'
import MaWangEditor from './ma-wangEditor/index.vue'
import MaColorPicker from './ma-colorPicker/index.vue'
import MaCityLinkage from './ma-cityLinkage/index.vue'
import SaChart from './sa-chart/index.vue'
import SaCheckbox from './sa-checkbox/index.vue'
import SaRadio from './sa-radio/index.vue'
import SaSelect from './sa-select/index.vue'
import SaSwitch from './sa-switch/index.vue'
import SaTable from './sa-table/index.vue'
import SaTreeSlider from './sa-treeSlider/index.vue'
import SaResource from './sa-resource/index.vue'
import SaResourceButton from './sa-resource/button.vue'
import SaDict from './sa-dict/index.vue'
import SaUser from './sa-user/index.vue'
import SaUploadImage from './sa-upload-image/index.vue'
import SaUploadFile from './sa-upload-file/index.vue'
import SaUploadChunk from './sa-upload-chunk/index.vue'
import SaIcon from './sa-icon/index.vue'
import SaIconPicker from './sa-icon-picker/index.vue'
import SaPickImage from './sa-pick-image/index.vue'
use([
CanvasRenderer,
BarChart,
LineChart,
PieChart,
RadarChart,
GaugeChart,
GridComponent,
TooltipComponent,
LegendComponent,
DataZoomComponent,
GraphicComponent
])
export default {
install(Vue) {
Vue.component('MaWangEditor', MaWangEditor)
Vue.component('MaColorPicker', MaColorPicker)
Vue.component('MaCityLinkage', MaCityLinkage)
Vue.component('SaChart', SaChart)
Vue.component('SaCheckbox', SaCheckbox)
Vue.component('SaRadio', SaRadio)
Vue.component('SaSelect', SaSelect)
Vue.component('SaSwitch', SaSwitch)
Vue.component('SaTable', SaTable)
Vue.component('SaTreeSlider', SaTreeSlider)
Vue.component('SaResource', SaResource)
Vue.component('SaResourceButton', SaResourceButton)
Vue.component('SaDict', SaDict)
Vue.component('SaUser', SaUser)
Vue.component('SaUploadImage', SaUploadImage)
Vue.component('SaUploadFile', SaUploadFile)
Vue.component('SaUploadChunk', SaUploadChunk)
Vue.component('SaIcon', SaIcon)
Vue.component('SaIconPicker', SaIconPicker)
Vue.component('SaPickImage', SaPickImage)
}
}

View File

@@ -0,0 +1,179 @@
<template>
<a-cascader
v-if="props.type === 'cascader'"
v-model="val"
:field-names="
props.mode == 'name'
? { value: 'name', label: 'name' }
: { value: 'code', label: 'name' }
"
:options="jsonData"
allow-search
check-strictly
expand-trigger="hover"
path-mode
placeholder="请选择省市区"
/>
<a-space v-else>
<a-select
v-model="selectData.province"
:field-names="
props.mode == 'name'
? { value: 'name', label: 'name' }
: { value: 'code', label: 'name' }
"
:options="province"
:style="{ width: '220px' }"
allow-clear
allow-search
placeholder="请选择省/直辖市/自治区"
@change="provinceChange"
@clear="
() => {
selectData.city = [];
selectData.area = [];
selectData.province = [];
selectData.city = [];
selectData.area = [];
province.value = [];
}
"
/>
<a-select
v-model="selectData.city"
:field-names="
props.mode == 'name'
? { value: 'name', label: 'name' }
: { value: 'code', label: 'name' }
"
:options="city"
:style="{ width: '220px' }"
allow-clear
allow-search
placeholder="请选择地级市/市辖区"
@change="cityChange"
@clear="
() => {
selectData.city = [];
selectData.area = [];
selectData.city = [];
selectData.area = [];
}
"
/>
<a-select
v-model="selectData.area"
:field-names="
props.mode == 'name'
? { value: 'name', label: 'name' }
: { value: 'code', label: 'name' }
"
:options="area"
:style="{ width: '220px' }"
allow-clear
allow-search
placeholder="请选择区县"
@clear="
() => {
selectData.area = [];
selectData.area = [];
}
"
/>
</a-space>
</template>
<script setup>
import jsonData from "./lib/city.json";
import { ref, watch } from "vue";
import { isObject } from "lodash";
const val = ref();
const selectData = ref({ province: [], city: [], area: [] });
const province = ref([]);
const city = ref([]);
const area = ref([]);
const emit = defineEmits(["update:modelValue"]);
const props = defineProps({
modelValue: [Number, String, Object],
type: { type: String, default: "select" },
mode: { type: String, default: "name" },
});
if (props.type === "select") {
province.value = jsonData.map((item) => {
return { code: item.code, name: item.name };
});
}
const provinceChange = (val, clear = true) => {
if (clear) {
selectData.value.city = [];
selectData.value.area = [];
area.value = [];
city.value = [];
}
jsonData.map((item) => {
if (props.mode == "name" && val == item.name) {
city.value = item.children;
}
if (props.mode == "code" && val == item.code) {
city.value = item.children;
}
});
};
const cityChange = (val, clear = true) => {
if (clear) {
selectData.value.area = [];
area.value = [];
}
city.value.map((item) => {
if (props.mode == "name" && val == item.name) {
area.value = item.children;
}
if (props.mode == "code" && val == item.code) {
area.value = item.children;
}
});
};
const setSelectData = () => {
if (props.type === "select") {
if (val.value && isObject(val.value)) {
selectData.value.province = val.value.province ? val.value.province : "";
selectData.value.city = val.value.city ? val.value.city : "";
selectData.value.area = val.value.area ? val.value.area : "";
selectData.value.province && provinceChange(selectData.value.province, false);
selectData.value.city &&
selectData.value.province &&
cityChange(selectData.value.city, false);
}
}
};
val.value = props.modelValue;
watch(
() => props.modelValue,
(vl) => {
val.value = vl;
setSelectData();
},
{ deep: true }
);
watch(
() => val.value,
(vl) => emit("update:modelValue", vl)
);
watch(
() => selectData.value,
(vl) => emit("update:modelValue", vl),
{ deep: true }
);
setSelectData();
</script>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,108 @@
<template>
<div class="editor" ref="dom" :style="'width: 100%; height: ' + props.height + 'px'"></div>
</template>
<script setup>
import { onMounted, ref, watch, toRaw } from 'vue'
import { useAppStore } from '@/store'
import { formatJson } from '@/utils/common'
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'
import 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution'
import 'monaco-editor/esm/vs/basic-languages/php/php.contribution'
import 'monaco-editor/esm/vs/basic-languages/mysql/mysql.contribution'
import 'monaco-editor/esm/vs/basic-languages/html/html.contribution'
import 'monaco-editor/esm/vs/basic-languages/css/css.contribution'
import 'monaco-editor/esm/vs/editor/contrib/find/browser/findController'
const appStore = useAppStore()
const props = defineProps({
modelValue: {
type: [String, Object, Array],
default: () => ''
},
defaultModelValue: {
type: String,
default: '',
},
valueType: {
type: String,
default: 'value'
},
miniMap: {
type: Boolean,
default: false
},
isBind: {
type: Boolean,
default: false
},
height: {
type: Number,
default: 400
},
language: {
type: String,
default: 'javascript'
},
readonly: {
type: Boolean,
default: false
}
})
const options = {
tabSize: 4,
automaticLayout: true,
scrollBeyondLastLine: false,
language: props.language,
theme: appStore.mode === 'light' ? 'vs' : 'vs-dark',
autoIndent: true,
minimap: { enabled: props.miniMap },
readOnly: props.readonly,
folding: true,
acceptSuggestionOnCommitCharacter: true,
acceptSuggestionOnEnter: true,
contextmenu: true
}
const emit = defineEmits(['update:modelValue'])
const dom = ref()
let instance
const initEditorValue = () => {
if (props.valueType === 'value' && typeof props.modelValue === 'string') {
instance.setValue(props.modelValue)
} else if (props.valueType === 'value' && props.modelValue?._onWillDispose === undefined) {
instance.setValue(formatJson(props.modelValue))
} else if (props.modelValue){
instance.setModel(toRaw(props.modelValue))
} else {
instance.setModel(monaco.editor.createModel(props.defaultModelValue, props.language))
}
}
watch( () => props.modelValue, () => initEditorValue() )
onMounted(() => {
instance = monaco.editor.create(dom.value, options)
initEditorValue()
instance.onDidBlurEditorText(() => {
emit('update:modelValue', toRaw(props.valueType === 'value' ? instance.getValue() : instance.getModel()))
})
})
const getInstance = () => instance
defineExpose({ getInstance, initEditorValue })
</script>
<style scoped lang="less">
.editor {
border: 1px solid var(--color-border-2);
border-radius: 3px;
background: var(--color-bg-2);
}
</style>

View File

@@ -0,0 +1,77 @@
<template>
<a-input-group class="w-full">
<a-trigger position="bottom" trigger="click" auto-fit-position :unmount-on-close="false">
<a-button type="primary">选择颜色</a-button>
<template #content>
<ColorPicker
theme="dark"
:color="val"
:sucker-hide="true"
:colors-default="defaultColorList"
@changeColor="selectColor"
style="width: 218px" />
</template>
</a-trigger>
<a-input v-model="val" :style="`color: ${val}`" :placeholder="props.placeholder"> </a-input>
<a-tooltip content="复制">
<a-button @click="copyColor"
><template #icon><icon-copy class="cursor-pointer" /></template
></a-button>
</a-tooltip>
</a-input-group>
</template>
<script setup>
import { reactive, computed } from 'vue'
import { ColorPicker } from 'vue-color-kit'
import 'vue-color-kit/dist/vue-color-kit.css'
import useClipboard from 'vue-clipboard3'
import { Message } from '@arco-design/web-vue'
const props = defineProps({
modelValue: String,
placeholder: { type: String, default: '请选择颜色' },
})
const emit = defineEmits(['update:modelValue'])
const val = computed({
get() {
return props.modelValue
},
set(newVal) {
emit('update:modelValue', newVal)
},
})
const selectColor = (color) => {
val.value = color.hex
}
const copyColor = async () => {
try {
await useClipboard().toClipboard(val.value)
Message.success('复制成功')
} catch (e) {
Message.error('复制失败')
}
}
const defaultColorList = reactive([
'#165DFF',
'#F53F3F',
'#F77234',
'#F7BA1E',
'#00B42A',
'#14C9C9',
'#3491FA',
'#722ED1',
'#F5319D',
'#D91AD9',
'#34C759',
'#43a047',
'#7cb342',
'#c0ca33',
'#86909c',
'#6d4c41',
])
</script>

View File

@@ -0,0 +1,110 @@
<script setup>
import { ref, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const codeText = ref('')
const verfiyCanvas = ref(null)
const props = defineProps({
height: { type: Number, default: 36 },
width: { type: Number, default: 120 },
pool: { type: String, default: 'abcdefghjkmnpqrstuvwxyz23456789' },
size: { type: Number, default: 4 },
showError: { type: Boolean, default: true },
})
const checkResult = (verifyCode) => {
if (! verifyCode || verifyCode.length === 0) {
props.showError && Message.error(t('sys.verifyCode.notice'))
return false
}
if (verifyCode.toLowerCase() !== codeText.value.toLowerCase()) {
props.showError && Message.error(t('sys.verifyCode.error'))
generateCode()
return false
} else {
return true
}
}
const randomNum = (min, max) => {
return parseInt(Math.random() * (max - min) + min)
}
const randomColor = (min, max) => {
const r = randomNum(min, max)
const g = randomNum(min, max)
const b = randomNum(min, max)
return `rgb(${r},${g},${b})`
}
const generateCode = () => {
codeText.value = ''
const ctx = verfiyCanvas.value.getContext('2d')
ctx.fillStyle = randomColor(230, 255)
ctx.fillRect(0, 0, props.width, props.height)
for (let i = 0; i < props.size; i++) {
let currentText = '' + props.pool[randomNum(0, props.pool.length)]
codeText.value += currentText
ctx.font = '36px Simhei'
ctx.textAlign="center"
ctx.fillStyle = randomColor(80, 150)
ctx.fillText(currentText, (i + 1) * randomNum(20, 25), props.height / 2 + 13)
}
for (let i = 0; i < 5; i++) {
ctx.beginPath()
ctx.moveTo(randomNum(0, props.width), randomNum(0, props.height))
ctx.lineTo(randomNum(0, props.width), randomNum(0, props.height))
ctx.strokeStyle = randomColor(180, 230)
ctx.closePath()
ctx.stroke()
}
for (let i = 0; i < 40; i++) {
ctx.beginPath()
ctx.arc(randomNum(0, props.width), randomNum(0, props.height), 1, 0, 2 * Math.PI)
ctx.closePath()
ctx.fillStyle = randomColor(150, 200)
ctx.fill()
}
ctx.restore()
ctx.save()
return codeText
}
onMounted(() => {
generateCode()
})
const refresh = () => {
generateCode()
}
defineExpose({ checkResult, refresh })
</script>
<template>
<a-tooltip :content="t('sys.verifyCode.switch')">
<canvas
ref="verfiyCanvas"
class="canvas"
:width="props.width"
:height="props.height" @click="refresh"
/>
</a-tooltip>
</template>
<style scoped lang="less">
:deep(.arco-input-append){
padding: 0 !important;
}
.canvas {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,194 @@
<template>
<!-- 组件外部的 form-item -->
<div style="z-index: 100; border: 1px solid #ccc; width: 100%">
<Toolbar style="border-bottom: 1px solid #ccc" :editor="editorRef" :defaultConfig="toolbarConfig" :mode="mode" />
<Editor
:style="{ height: props.height + 'px', overflowY: 'hidden' }"
v-model="content"
:defaultConfig="editorConfig"
:mode="props.mode"
@onCreated="handleCreated" />
<a-modal style="z-index: 1000" v-model:visible="resourceVisible" :render-to-body="false" :width="1080" :footer="false" draggable>
<template #title>资源选择器</template>
<sa-resource v-model="list" multiple ref="resource" returnType="url" />
</a-modal>
</div>
</template>
<script setup>
import '@wangeditor/editor/dist/css/style.css'
import { onBeforeUnmount, ref, shallowRef, watch, computed } from 'vue'
import { Boot } from '@wangeditor/editor'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { useAppStore } from '@/store'
import commonApi from '@/api/common'
import file2md5 from 'file2md5'
import tool from '@/utils/tool'
const resourceVisible = ref(false)
const appStore = useAppStore()
const props = defineProps({
modelValue: { type: String },
component: Object,
height: { type: Number, default: 300 },
mode: { type: String, default: 'default' },
customField: { type: String, default: undefined },
})
const emit = defineEmits(['update:modelValue', 'change'])
let registerWangEditorButtonFlag = appStore.appCurrentSetting.registerWangEditorButtonFlag
const list = ref([])
const resource = ref()
let content = computed({
get() {
return props.modelValue
},
set(value) {
emit('update:modelValue', value)
},
})
watch(
() => content.value,
(vl) => emit('change', vl)
)
watch(
() => list.value,
(imgs) => {
let tmp = ''
imgs.map((img) => {
if (
img.indexOf('.jpg') > -1 ||
img.indexOf('.png') > -1 ||
img.indexOf('.bmp') > -1 ||
img.indexOf('.jpeg') > -1 ||
img.indexOf('.svg') > -1 ||
img.indexOf('.gif') > -1
) {
const node = { type: 'image', src: img, href: '', alt: '', style: {}, children: [{ text: '' }] }
editorRef.value.insertNode(node)
}
})
resource.value.clearSelecteds()
resourceVisible.value = false
}
)
const editorRef = shallowRef()
const toolbarConfig = {}
toolbarConfig.excludeKeys = ['group-video', 'insertImage']
class MyButtonMenu {
constructor() {
this.title = '资源选择器'
this.tag = 'button'
}
// 获取菜单执行时的 value ,用不到则返回空 字符串或 false
getValue(editor) {
return ''
}
// // 菜单是否需要激活(如选中加粗文本,“加粗”菜单会激活),用不到则返回 false
isActive(editor) {
return false
}
// 菜单是否需要禁用(如选中 H1 ,“引用”菜单被禁用),用不到则返回 false
isDisabled(editor) {
return false
}
// 点击菜单时触发的函数
exec(editor, value) {
editor.emit('click_menu')
}
}
const menu1Conf = {
key: 'menu1', // 定义 menu key :要保证唯一、不重复(重要)
factory() {
return new MyButtonMenu()
},
}
if (registerWangEditorButtonFlag === undefined || registerWangEditorButtonFlag === false) {
Boot.registerMenu(menu1Conf)
appStore.setRegisterWangEditorButtonFlag(true)
}
toolbarConfig.insertKeys = {
index: 1, // 插入的位置,基于当前的 toolbarKeys
keys: ['menu1'],
}
const editorConfig = {
placeholder: '请输入内容...',
MENU_CONF: {},
hoverbarKeys: {
// 在编辑器中,选中链接文本时,要弹出的菜单
link: {
menuKeys: [
// 默认的配置可以通过 `editor.getConfig().hoverbarKeys.image` 获取
'imageWidth30',
'imageWidth50',
'imageWidth100',
'|', // 分割符
'imageFloatNone', // 增加 '图片浮动' 菜单
'imageFloatLeft',
'imageFloatRight',
'|', // 分割符
'editImage',
'viewImageLink',
'deleteImage',
],
},
},
}
editorConfig.MENU_CONF['uploadImage'] = {
async customUpload(file, insertFn) {
uploadRequest(file, 'image', 'uploadImage').then((res) => {
insertFn(tool.attachUrl(res.url))
})
},
}
const uploadRequest = async (file, type, method, requestData = {}) => {
const hash = await file2md5(file)
const dataForm = new FormData()
dataForm.append(type, file)
dataForm.append('isChunk', false)
dataForm.append('hash', hash)
for (let name in requestData) {
dataForm.append(name, requestData[name])
}
const response = await commonApi[method](dataForm)
return response.data
}
const handleCreated = (editor) => {
editorRef.value = editor
editorRef.value.on('click_menu', () => {
resourceVisible.value = true
})
}
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null) return
editor.destroy()
})
</script>
<style scoped></style>

View File

@@ -0,0 +1,38 @@
<template>
<v-charts v-if="renderChart" :option="options" :autoresize="autoresize" :style="{ width, height }" />
</template>
<script setup>
import { ref, nextTick } from 'vue'
import VCharts from 'vue-echarts'
import 'echarts/lib/component/title'
const props = defineProps({
options: {
type: Object,
default() {
return {}
},
},
autoresize: {
type: Boolean,
default: true,
},
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: '100%',
},
})
const renderChart = ref(false)
nextTick(() => {
renderChart.value = true
})
</script>
<style scoped lang="less"></style>

View File

@@ -0,0 +1,41 @@
<template>
<a-checkbox-group v-model="value" :direction="props.direction" :disabled="props.disabled" @change="handleChangeEvent($event)">
<template v-for="(item, index) in dictList[props.dict] ?? []">
<a-checkbox :value="item.value">{{ item.label }}</a-checkbox>
</template>
</a-checkbox-group>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useDictStore } from '@/store'
const dictList = useDictStore().data
const emit = defineEmits(['update:modelValue', 'change'])
const value = ref()
const props = defineProps({
modelValue: { type: Array, default: () => [] },
dict: { type: String, default: '' },
disabled: { type: Boolean, default: false },
direction: { type: String, default: 'horizontal' },
})
watch(
() => props.modelValue,
(vl) => {
value.value = vl
},
{ immediate: true }
)
watch(
() => value.value,
(v) => {
emit('update:modelValue', value.value)
}
)
const handleChangeEvent = async (value) => {
emit('change', value)
}
</script>

View File

@@ -0,0 +1,62 @@
<template>
<div>
<!-- 渲染 span -->
<span v-if="props.render === 'span'">
<template v-if="Array.isArray(value)">
{{
value.map((v) => tool.getLabel(v, props.options.length > 0 ? props.options : dictList[props.dict])).join(', ')
}}
</template>
<template v-else>
{{ tool.getLabel(value, props.options.length > 0 ? props.options : dictList[props.dict]) }}
</template>
</span>
<!-- 渲染 tag -->
<template v-if="props.render === 'tag'">
<template v-if="Array.isArray(value)">
<a-tag
v-for="(v, index) in value"
:key="index"
class="mr-2"
:color="
tool.getColor(v, props.options.length > 0 ? props.options : dictList[props.dict], props.colors || [])
">
{{ tool.getLabel(v, props.options.length > 0 ? props.options : dictList[props.dict]) }}
</a-tag>
</template>
<a-tag
v-else-if="value !== ''"
:color="
tool.getColor(value, props.options.length > 0 ? props.options : dictList[props.dict], props.colors || [])
">
{{ tool.getLabel(value, props.options.length > 0 ? props.options : dictList[props.dict]) }}
</a-tag>
<span v-else></span>
</template>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import tool from '@/utils/tool.js'
import { useDictStore } from '@/store'
const dictList = useDictStore().data
const value = ref()
const props = defineProps({
value: { type: [String, Number, Array] },
render: { type: String, default: 'tag' },
dict: { type: String, default: '' },
options: { type: Array, default: [] },
colors: { type: Array, default: [] },
})
watch(
() => props.value,
(vl) => {
value.value = vl
},
{ immediate: true }
)
</script>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,121 @@
<template>
<div class="w-full">
<a-input-group class="w-full">
<a-input placeholder="请点击右侧按钮选择图标" v-if="props.preview" allow-clear v-model="currentIcon" />
<div class="icon-container" v-if="props.preview">
<sa-icon :icon="currentIcon" v-if="currentIcon" />
</div>
<a-button type="primary" @click="() => (visible = true)">选择图标</a-button>
</a-input-group>
<a-modal v-model:visible="visible" width="800px" draggable :footer="false">
<template #title>选择图标</template>
<a-tabs class="tabs">
<a-tab-pane key="arco" title="Arco Design">
<ul class="arco">
<li v-for="icon in arcodesignIcons" :key="icon" @click="selectIcon(icon, 'arco')">
<component :is="icon" />
</li>
</ul>
</a-tab-pane>
<a-tab-pane key="bi" title="Bootstrap Icons">
<ul class="arco">
<li v-for="icon in biData" :key="icon" @click="selectIcon(icon, 'iconify')">
<Icon :icon="icon" />
</li>
</ul>
</a-tab-pane>
</a-tabs>
</a-modal>
</div>
</template>
<script setup>
import { reactive, ref, computed } from 'vue'
import * as arcoIcons from '@arco-design/web-vue/es/icon'
import { Icon } from '@iconify/vue';
import biData from "./iconify/bi.json";
const arcodesignIcons = reactive([])
const visible = ref(false)
const props = defineProps({
modelValue: { type: String },
preview: { type: Boolean, default: true },
})
const emit = defineEmits(['update:modelValue'])
const currentIcon = computed({
get() {
return props.modelValue
},
set(value) {
// html标签名不能以数字开头
if ((/^[^\d].*/.test(value) && value) || !value) {
emit('update:modelValue', value)
}
},
})
for (let icon in arcoIcons) {
arcodesignIcons.push(icon)
}
arcodesignIcons.pop()
const selectIcon = (icon, className) => {
currentIcon.value = icon
visible.value = false
}
const handlerChange = (value) => {
selectIcon(value, '')
}
</script>
<style scoped lang="less">
.icon-container {
width: 50px;
height: 32px;
background-color: var(--color-fill-1);
display: flex;
justify-content: center;
align-items: center;
}
.icon {
width: 1em;
fill: var(--color-text-1);
}
.tabs {
ul {
display: flex;
flex-wrap: wrap;
padding-left: 7px;
}
li {
border: 2px solid var(--color-fill-4);
margin-bottom: 10px;
margin-right: 6px;
padding: 5px;
cursor: pointer;
}
li:hover,
li.active {
border: 2px solid rgb(var(--primary-6));
}
& li svg {
width: 2.4em;
height: 2.4em;
}
}
:deep(.arco-select-option-content) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,29 @@
<template>
<template v-if="value.indexOf(':') === -1">
<component :is="value" :size="props.size"></component>
</template>
<template v-else>
<Icon :icon="value" class="iconify-icon" :style="{ fontSize: props.size + 'px' }" />
</template>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Icon } from '@iconify/vue'
const value = ref('')
const props = defineProps({
icon: { type: String },
size: { type: Number, default: 24 },
})
watch(
() => props.icon,
(vl) => {
if (vl) {
value.value = vl
}
},
{ immediate: true }
)
</script>

View File

@@ -0,0 +1,414 @@
<template>
<div class="upload-image flex">
<!-- 单图 -->
<a-space wrap>
<div
class="image-list"
:style="{ width: props.small ? '60px' : '100px', height: props.small ? '60px' : '100px' }"
v-if="!props.multiple && inputValue">
<a-button class="delete" @click="removeSignImage()">
<template #icon>
<icon-delete />
</template>
</a-button>
<a-image :width="props.small ? 60 : 100" :height="props.small ? 60 : 100" :src="inputValue" />
</div>
<!-- 多图显示 -->
<template v-else-if="props.multiple">
<div
class="image-list"
:style="{ width: props.small ? '60px' : '100px', height: props.small ? '60px' : '100px' }"
v-for="(image, idx) in imageList"
:key="idx">
<a-button class="delete" @click="removeImage(idx)">
<template #icon>
<icon-delete />
</template>
</a-button>
<a-image :width="props.small ? 60 : 100" :height="props.small ? 45 : 100" :src="image" />
</div>
</template>
<div>
<div
class="upload-skin cursor-pointer"
:style="{ width: props.small ? '60px' : '100px', height: props.small ? '60px' : '100px' }"
v-if="props.multiple && imageList.length < props.limit"
@click="openResourceSelector">
<div class="icon text-3xl">
<icon-image />
</div>
<div v-if="!props.small" class="title">选择图片</div>
</div>
<div
class="upload-skin cursor-pointer"
:style="{ width: props.small ? '60px' : '100px', height: props.small ? '60px' : '100px' }"
v-if="!inputValue && !props.multiple"
@click="openResourceSelector">
<div class="icon text-3xl">
<icon-image />
</div>
<div v-if="!props.small" class="title">选择图片</div>
</div>
</div>
</a-space>
<a-modal v-model:visible="visible" width="1100px" :footer="false" draggable>
<template #title>资源选择器</template>
<div class="w-full h-144 flex flex-col">
<div class="lg:flex lg:justify-between">
<div class="flex">
<sa-upload-file
:modelValue="fileValue"
@update:modelValue="handleUpdate"
:size="20 * 1024 * 1024"
multiple
:show-list="false" />
<a-button class="ml-3" @click="openNetworkModal = true"> <icon-image /> 保存网络图片 </a-button>
<a-radio-group type="button" v-model="defaultKey" @change="handlerClick" class="ml-4">
<a-radio v-for="(item, index) in sliderData" :key="index" :value="item.value">{{ item.label }}</a-radio>
</a-radio-group>
</div>
<a-input
v-model="filename"
class="input-search lg:mt-0 mt-2"
placeholder="文件名搜索"
allow-clear
@press-enter="searchFile" />
</div>
<a-spin :loading="resourceLoading" tip="资源加载中" class="h-full">
<div class="resource-list mt-4" ref="rl" v-if="attachmentList && attachmentList.length > 0">
<div
class="item rounded-sm"
v-for="(item, index) in attachmentList"
:key="item.hash"
@click="selectFile(item, index)">
<img :src="item.url" v-if="item.mime_type.indexOf('image') !== -1" />
<div v-else class="text-3xl w-full h-full flex items-center justify-center">
{{ `.${item.suffix}` }}
</div>
<a-tooltip position="bottom">
<div class="file-name">
{{ item.origin_name }}
</div>
<template #content>
<div>存储名称{{ item.object_name }}</div>
<div>存储目录{{ item.storage_path }}</div>
<div>上传时间{{ item.create_time }}</div>
<div>文件大小{{ item.size_info }}</div>
<div>存储模式{{ tool.getLabel(item.storage_mode, dictList['upload_mode']) }}</div>
</template>
</a-tooltip>
</div>
</div>
<a-empty v-else class="mt-10" />
</a-spin>
<div class="lg:flex lg:justify-between">
<a-pagination
:total="pageInfo.total"
v-model:current="pageInfo.currentPage"
v-model:page-size="pageSize"
@change="changePage" />
<a-button type="primary" @click="selectComplete" class="mt-3 lg:mt-0">确定</a-button>
</div>
<a-modal v-model:visible="openNetworkModal" ok-text="保存" :on-before-ok="saveNetworkImg" draggable>
<template #title>保存网络图片</template>
<a-input v-model="networkImg" class="mb-3" placeholder="请粘贴网络图片地址" allow-clear />
<a-image :src="networkImg" width="100%" style="min-height: 150px" />
</a-modal>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ref, onMounted, watch, computed } from 'vue'
import commonApi from '@/api/common'
import tool from '@/utils/tool'
import { Message } from '@arco-design/web-vue'
import { useDictStore } from '@/store'
const dictList = useDictStore().data
const props = defineProps({
modelValue: {
type: [String, Array],
default: () => {},
},
multiple: { type: Boolean, default: false },
limit: { type: Number, default: 3 },
returnType: { type: String, default: 'url' },
small: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'change'])
const sliderData = ref([])
const defaultKey = ref('all')
const resourceLoading = ref(false)
const pageSize = ref(21)
const filename = ref()
const selecteds = ref()
const rl = ref()
const attachmentList = ref([])
const openNetworkModal = ref(false)
const networkImg = ref()
const pageInfo = ref({
total: 1,
currentPage: 1,
})
const visible = ref(false)
const inputValue = ref(null)
const imageList = ref([])
const removeSignImage = () => {
inputValue.value = null
emit('update:modelValue', null)
}
const removeImage = (idx) => {
imageList.value.splice(idx, 1)
emit('update:modelValue', imageList.value || [])
}
// 打开资源选择器
const openResourceSelector = async () => {
const elements = document.querySelectorAll('.item.active')
elements.forEach((element) => {
element.className = 'item rounded-sm'
})
selecteds.value = null
inputValue.value = null
visible.value = true
}
// 搜索分类
const handlerClick = async (val) => {
defaultKey.value = val
const type = val === 'all' ? undefined : val
await getAttachmentList({ mime_type: type })
}
// 搜索文件名称
const searchFile = async () => {
await getAttachmentList({ origin_name: filename.value })
}
// 选择文件
const selectFile = (item, index) => {
const children = rl.value.children
const className = 'item rounded-sm'
if (children[index].className.indexOf('active') !== -1) {
children[index].className = className
if (props.multiple) {
if (selecteds.value == null) {
selecteds.value = []
}
selecteds.value.map((file, idx) => {
selecteds.value.splice(idx, 1)
})
} else {
selecteds.value = ''
}
} else {
if (props.multiple) {
children[index].className = className + ' active'
if (selecteds.value == null) {
selecteds.value = []
}
selecteds.value.push(item[props.returnType])
} else {
if (document.querySelectorAll('.item.active').length < 1) {
children[index].className = className + ' active'
selecteds.value = item[props.returnType]
} else {
const elements = document.querySelectorAll('.item.active')
elements.forEach((element) => {
element.className = className
})
children[index].className = className + ' active'
selecteds.value = item[props.returnType]
}
}
}
}
// 文件选择确定
const selectComplete = () => {
if (props.multiple) {
const value = Object.assign([], selecteds.value)
imageList.value.push(...value)
if (imageList.value.length > props.limit) {
imageList.value.splice(5)
}
emit('update:modelValue', imageList.value)
} else {
inputValue.value = selecteds.value
emit('update:modelValue', inputValue.value)
}
visible.value = false
}
// 页码变化
const changePage = async (page) => {
await getAttachmentList({ page })
}
// 获取文件列表
const getAttachmentList = async (params = {}) => {
const requestParams = Object.assign(params, { limit: pageSize.value })
resourceLoading.value = true
attachmentList.value = []
const response = await commonApi.getResourceList(requestParams)
pageInfo.value = {
total: response?.data?.total ?? 0,
currentPage: response?.data?.current_page ?? 21,
}
attachmentList.value = response?.data?.data
resourceLoading.value = false
}
// 保存网络图片
const saveNetworkImg = async (done) => {
if (!networkImg.value) {
Message.error('输入地址不能为空')
done(false)
return
}
const response = await commonApi.saveNetWorkImage({ url: networkImg.value })
if (response.code === 200) {
Message.success(response.message)
await getAttachmentList({ page: pageInfo.value.currentPage })
networkImg.value = undefined
done(true)
} else {
Message.error(response.message)
done(false)
}
}
const fileValue = ref()
const handleUpdate = async () => {
getAttachmentList({ page: pageInfo.value.currentPage })
}
onMounted(async () => {
const treeData = dictList['attachment_type']
sliderData.value = [{ label: '所有', value: 'all' }, ...treeData]
await getAttachmentList({ page: 1 })
})
watch(
() => props.modelValue,
(val) => {
if (props.multiple) {
imageList.value = val || []
} else {
inputValue.value = val
}
},
{
deep: true,
immediate: true,
}
)
</script>
<style lang="less" scoped>
.upload-skin {
background-color: var(--color-fill-2);
border: 1px dashed var(--color-fill-4);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.icon,
.title {
color: var(--color-text-3);
}
}
.image-list {
cursor: pointer;
position: relative;
background-color: var(--color-fill-2);
.delete {
position: absolute;
z-index: 99;
right: 3px;
top: 3px;
display: none;
}
.progress {
position: absolute;
left: 50%;
top: 50%;
transform: translateX(-50%) translateY(-50%);
}
}
.image-list:hover {
.delete {
display: block;
}
}
.resource-container {
min-height: 560px;
}
.input-search {
width: 250px;
}
.resource-list {
display: flex;
width: 100%;
flex-wrap: wrap;
flex-direction: row;
align-content: center;
.item {
width: 138px;
height: 138px;
border: 2px solid var(--color-fill-1);
margin-right: 10px;
margin-bottom: 20px;
background-color: var(--color-fill-1);
cursor: pointer;
position: relative;
.file-name {
position: absolute;
bottom: 0px;
height: 23px;
width: 100%;
background: rgba(100, 100, 100, 0.3);
line-height: 23px;
font-size: 12px;
overflow: hidden;
padding: 0 10px;
text-align: center;
text-overflow: ellipsis;
color: #fff;
}
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.item:hover {
border: 2px solid rgb(var(--primary-6));
}
.item.active {
border: 2px solid rgb(var(--primary-6));
}
.item.active::after {
content: '';
position: absolute;
width: 134px;
height: 134px;
z-index: 2;
top: 0;
background: rgba(var(--primary-5), 0.2);
}
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<a-radio-group
v-model="value"
:direction="props.direction"
:type="props.type"
:disabled="props.disabled"
@change="handleChangeEvent($event)">
<a-radio v-if="props.allowNull" :value="props.nullValue">{{ props.nullLabel }}</a-radio>
<template v-for="(item, index) in dictList[props.dict] ?? []">
<a-radio :value="item.value">{{ item.label }}</a-radio>
</template>
</a-radio-group>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useDictStore } from '@/store'
const dictList = useDictStore().data
const emit = defineEmits(['update:modelValue', 'change'])
const value = ref()
const props = defineProps({
modelValue: { type: [String, Number] },
type: { type: String, default: 'radio' },
dict: { type: String, default: '' },
disabled: { type: Boolean, default: false },
direction: { type: String, default: 'horizontal' },
allowNull: { type: Boolean, default: false },
nullValue: { type: [String, Number], default: '' },
nullLabel: { type: String, default: '全部' },
})
watch(
() => props.modelValue,
(vl) => {
if (props.dict !== '') {
value.value = vl + ''
} else {
value.value = vl
}
},
{ immediate: true }
)
watch(
() => value.value,
(v) => {
emit('update:modelValue', value.value)
}
)
const handleChangeEvent = async (value) => {
emit('update:modelValue', value)
emit('change', value)
}
</script>

View File

@@ -0,0 +1,82 @@
<template>
<div class="inline-block">
<a-input-group class="w-full">
<a-trigger position="bottom" auto-fit-position :unmount-on-close="false">
<a-input v-model="inputValue" placeholder="请点击左侧按钮选择资源" readonly v-if="!props.multiple" />
<a-button v-else>预览已选</a-button>
<template #content>
<div class="trigger-content">
<a-empty v-if="list && list.length == 0" />
<a-image :src="inputValue" v-else-if="list && !isArray(list)" />
<div v-else>
<a-image-preview-group infinite>
<a-space>
<a-image :src="item" v-for="(item, index) in list" width="100%" :key="index" />
</a-space>
</a-image-preview-group>
</div>
</div>
</template>
</a-trigger>
<a-button type="primary" @click="visible = true"><icon-experiment /> 资源选择器</a-button>
</a-input-group>
<a-modal v-model:visible="visible" :width="props.width" :footer="false" draggable>
<template #title>资源选择器</template>
<sa-resource v-model="list" :multiple="props.multiple" :only-data="props.onlyData" />
</a-modal>
</div>
</template>
<script setup>
import { onMounted, ref, watch } from 'vue'
import { isArray } from 'lodash'
const list = ref()
const visible = ref(false)
const inputValue = ref('')
const emit = defineEmits(['update:modelValue'])
const props = defineProps({
modelValue: { type: [String, Array] },
multiple: { type: Boolean, default: true },
onlyData: { type: Boolean, default: true },
width: { type: Number, default: 1080 },
})
watch(
() => props.modelValue,
(vl) => {
list.value = vl
},
{ immediate: true }
)
watch(
() => list.value,
(vl) => {
emit('update:modelValue', list.value)
if (props.multiple) {
inputValue.value = isArray(list) ? list.value.join(',') : []
} else {
inputValue.value = list.value
}
visible.value = false
},
{ immediate: true, deep: true }
)
</script>
<style scoped>
.trigger-content {
margin-top: 1px;
background: var(--color-fill-1);
border: 1px solid var(--color-fill-3);
width: 340px;
border-radius: var(--border-radius-medium);
}
:deep(.arco-space) {
display: block;
margin-bottom: 5px;
}
</style>

View File

@@ -0,0 +1,253 @@
<template>
<div class="w-full resource-container h-full lg:flex lg:justify-between rounded-sm">
<a-modal v-model:visible="openNetworkModal" ok-text="保存" :on-before-ok="saveNetworkImg" draggable>
<template #title>保存网络图片</template>
<a-input v-model="networkImg" class="mb-3" placeholder="请粘贴网络图片地址" allow-clear />
<a-image :src="networkImg" width="100%" style="min-height: 150px" />
</a-modal>
<div class="w-full lg:mt-2 flex flex-col">
<div class="lg:flex lg:justify-between">
<div class="flex">
<sa-upload-file v-model="uploadFile" multiple :show-list="false" />
<a-button class="ml-3" @click="openNetworkModal = true"> <icon-image /> 保存网络图片 </a-button>
<a-radio-group type="button" v-model="defaultKey" @change="handlerClick" class="ml-4">
<a-radio v-for="(item, index) in sliderData" :key="index" :value="item.value">{{ item.label }}</a-radio>
</a-radio-group>
</div>
<a-input v-model="filename" class="input-search lg:mt-0 mt-2" placeholder="文件名搜索" @press-enter="searchFile" />
</div>
<a-spin :loading="resourceLoading" tip="资源加载中" class="h-full">
<div class="resource-list mt-2" ref="rl" v-if="attachmentList && attachmentList.length > 0">
<div class="item rounded-sm" v-for="(item, index) in attachmentList" :key="item.hash" @click="selectFile(item, index)">
<img :src="!/^(http|https)/g.test(item.url) ? $tool.attachUrl(item.url) : item.url" v-if="item.mime_type.indexOf('image') !== -1" />
<div v-else class="text-3xl w-full h-full flex items-center justify-center">
{{ `.${item.suffix}` }}
</div>
<a-tooltip position="bottom">
<div class="file-name">
{{ item.origin_name }}
</div>
<template #content>
<div>存储名称{{ item.object_name }}</div>
<div>存储目录{{ item.storage_path }}</div>
<div>上传时间{{ item.create_time }}</div>
<div>文件大小{{ item.size_info }}</div>
<div>存储模式{{ tool.getLabel(item.storage_mode, dictList['upload_mode']) }}</div>
</template>
</a-tooltip>
</div>
</div>
<a-empty v-else class="mt-10" />
</a-spin>
<div class="lg:flex lg:justify-between">
<a-pagination :total="pageInfo.total" v-model:current="pageInfo.currentPage" v-model:page-size="pageSize" @change="changePage" />
<a-button type="primary" @click="selectComplete" class="mt-3 lg:mt-0">确定</a-button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import commonApi from '@/api/common'
import tool from '@/utils/tool'
import { Message } from '@arco-design/web-vue'
import { useDictStore } from '@/store'
const dictList = useDictStore().data
const sliderData = ref([])
const defaultKey = ref('all')
const uploadFile = ref()
const attachmentList = ref([])
const openNetworkModal = ref(false)
const networkImg = ref()
const pageInfo = ref({
total: 1,
currentPage: 1,
})
const resourceLoading = ref(true)
const pageSize = ref(21)
const filename = ref()
const selecteds = ref()
const rl = ref()
const emit = defineEmits(['update:modelValue'])
const props = defineProps({
modelValue: { type: [String, Array] },
multiple: { type: Boolean, default: true },
onlyData: { type: Boolean, default: true },
returnType: { type: String, default: 'url' },
})
onMounted(async () => {
const treeData = dictList['attachment_type']
sliderData.value = [{ label: '所有', value: 'all' }, ...treeData]
await getAttachmentList({ page: 1 })
if (props.multiple) {
selecteds.value = []
}
})
const getAttachmentList = async (params = {}) => {
const requestParams = Object.assign(params, { limit: pageSize.value })
resourceLoading.value = true
const response = await commonApi.getResourceList(requestParams)
pageInfo.value = {
total: response?.data?.total ?? 0,
currentPage: response?.data?.current_page ?? 21,
}
attachmentList.value = response?.data?.data
resourceLoading.value = false
}
const handlerClick = async (val) => {
defaultKey.value = val
const type = val === 'all' ? undefined : val
await getAttachmentList({ mime_type: type })
}
const searchFile = async () => {
await getAttachmentList({ origin_name: filename.value })
}
const selectFile = (item, index) => {
const children = rl.value.children
const className = 'item rounded-sm'
if (!/^(http|https)/g.test(item.url)) {
item.url = tool.attachUrl(item.url)
}
if (children[index].className.indexOf('active') !== -1) {
children[index].className = className
if (props.multiple) {
selecteds.value.map((file, idx) => {
selecteds.value.splice(idx, 1)
})
} else {
selecteds.value = ''
}
} else {
if (props.multiple) {
children[index].className = className + ' active'
selecteds.value.push(props.onlyData ? item[props.returnType] : item)
} else {
if (document.querySelectorAll('.item.active').length < 1) {
children[index].className = className + ' active'
selecteds.value = props.onlyData ? item[props.returnType] : item
}
}
}
}
const clearSelecteds = () => {
if (rl.value && rl.value.children) {
const children = rl.value.children
for (let i = 0; i < children.length; i++) {
children[i].className = 'item rounded-sm'
}
}
if (props.multiple) {
selecteds.value = []
} else {
selecteds.value = undefined
}
}
const selectComplete = () => {
const files = props.multiple ? Object.assign([], selecteds.value) : selecteds.value
emit('update:modelValue', files)
}
const changePage = async (page) => {
await getAttachmentList({ page })
}
const saveNetworkImg = async (done) => {
if (!networkImg.value) {
Message.error('输入地址不能为空')
done(false)
return
}
const response = await commonApi.saveNetWorkImage({ url: networkImg.value })
if (response.code === 200) {
Message.success(response.message)
await getAttachmentList()
networkImg.value = undefined
done(true)
} else {
Message.error(response.message)
done(false)
}
}
watch(
() => uploadFile,
async () => await getAttachmentList(),
{ deep: true }
)
defineExpose({ clearSelecteds })
</script>
<style scoped lang="less">
.resource-container {
min-height: 560px;
}
.input-search {
width: 250px;
}
.resource-list {
display: flex;
width: 100%;
flex-wrap: wrap;
flex-direction: row;
align-content: center;
.item {
width: 138px;
height: 138px;
border: 2px solid var(--color-fill-1);
margin-right: 10px;
margin-bottom: 20px;
background-color: var(--color-fill-1);
cursor: pointer;
position: relative;
.file-name {
position: absolute;
bottom: 0px;
height: 23px;
width: 100%;
background: rgba(100, 100, 100, 0.3);
line-height: 23px;
font-size: 12px;
overflow: hidden;
padding: 0 10px;
text-align: center;
text-overflow: ellipsis;
color: #fff;
}
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.item:hover {
border: 2px solid rgb(var(--primary-6));
}
.item.active {
border: 2px solid rgb(var(--primary-6));
}
.item.active::after {
content: '';
position: absolute;
width: 134px;
height: 134px;
z-index: 2;
top: 0;
background: rgba(var(--primary-5), 0.2);
}
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<a-select
v-model="value"
:size="props.size"
:options="dictList[props.dict] ?? []"
:placeholder="props.placeholder"
:style="props.style"
:disabled="props.disabled"
:allow-clear="props.allowClear"
@change="handleChangeEvent($event)">
</a-select>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useDictStore } from '@/store'
const dictList = useDictStore().data
const emit = defineEmits(['update:modelValue', 'change'])
const value = ref()
const props = defineProps({
modelValue: { type: [String, Number] },
fieldNames: { type: Object, default: { value: 'value', label: 'label' } },
size: { type: String, default: 'medium' },
style: { type: Object, default: {} },
dict: { type: String, default: '' },
placeholder: { type: String, default: '请选择' },
disabled: { type: Boolean, default: false },
allowClear: { type: Boolean, default: true },
})
watch(
() => props.modelValue,
(vl) => {
if (props.dict !== '') {
value.value = vl + ''
} else {
value.value = vl
}
},
{ immediate: true }
)
watch(
() => value.value,
(v) => {
emit('update:modelValue', value.value)
}
)
const handleChangeEvent = async (val) => {
emit('update:modelValue', val)
emit('change', val)
}
</script>

View File

@@ -0,0 +1,60 @@
<template>
<a-switch
v-model="value"
:size="props.size"
:disabled="props.disabled"
:loading="props.loading"
:type="props.type"
:checked-value="props.checkedValue"
:unchecked-value="props.uncheckedValue"
:checked-color="props.checkedColor"
:unchecked-color="props.uncheckedColor"
@change="handleChangeEvent($event)">
<template #checked> {{ props.checkedText }} </template>
<template #unchecked> {{ props.uncheckedText }} </template>
</a-switch>
</template>
<script setup>
import { ref, watch } from 'vue'
const emit = defineEmits(['update:modelValue', 'change'])
const value = ref()
const props = defineProps({
modelValue: { type: [String, Number, Boolean] },
size: { type: String, default: 'medium' },
type: { type: String, default: 'round' },
valType: { type: String, default: 'string' },
disabled: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
checkedValue: { type: [String, Number, Boolean], default: '1' },
uncheckedValue: { type: [String, Number, Boolean], default: '2' },
checkedColor: { type: String, default: '' },
uncheckedColor: { type: String, default: '' },
checkedText: { type: String, default: '启用' },
uncheckedText: { type: String, default: '禁用' },
})
watch(
() => props.modelValue,
(vl) => {
if (props.valType === 'string') {
value.value = vl + ''
} else {
value.value = vl
}
},
{ immediate: true }
)
watch(
() => value.value,
(v) => {
emit('update:modelValue', value.value)
}
)
const handleChangeEvent = async (value) => {
emit('change', value)
}
</script>

View File

@@ -0,0 +1,132 @@
export default {
// 当前crud组件的 id全局唯一不指定则随机生成一个
id: undefined,
// 主键名称
pk: 'id',
// 请求api方法
api: () => {},
// 设置分页组件每页记录数
pageSizeOption: [10, 20, 30, 50, 100],
// 设置选择列
rowSelection: undefined,
// 是否显示边框
bordered: { wrapper: true, cell: false },
// 每页记录数
pageSize: 10,
// 默认展开所有行
expandAllRows: false,
// 是否显示总结行
showSummary: false,
// 斑马线
stripe: true,
// 表格大小
size: 'large',
// 是否显示展开/折叠按钮
isExpand: false,
// 是否显示工具栏
showTools: true,
// 页面布局方式,支持 normal标准和 fixed固定两种
pageLayout: 'fixed',
height: 0,
// 简洁模式
pageSimple: false,
// 显示排序
showSort: true,
// 显示搜索
showSearch: true,
// 搜索提交按钮文案
searchText: '搜索',
// 搜索重置按钮文案
resetText: '重置',
// 强制搜索一行显示
singleLine: false,
view: {
// 新增api
func: undefined,
// 显示新增按钮的权限
auth: [],
// 按钮文案
text: '查看',
// 是否显示
show: false
},
add: {
// 新增api
func: undefined,
// 显示新增按钮的权限
auth: [],
// 按钮文案
text: '新增',
// 是否显示
show: false
},
edit: {
// 编辑api
func: undefined,
// 显示编辑按钮的权限
auth: [],
// 按钮文案
text: '编辑',
// 是否显示
show: false
},
delete: {
// 删除api
func: undefined,
// 显示删除按钮的权限
auth: [],
// 按钮文案
text: '删除',
// 删除确认弹窗文案
confirmText: '确定要删除该数据吗?',
// 是否显示
show: false,
// 是否显示批量处理按钮
batch: true
},
import: {
// 导入url
url: undefined,
// 导入参数
params: {},
// 下载模板地址
templateUrl: undefined,
// 显示导入按钮的权限
auth: [],
// 按钮文案
text: '导入',
// 是否显示
show: false
},
export: {
// 导出url
url: undefined,
// 显示导出按钮的权限
auth: [],
// 按钮文案
text: '导出',
// 是否显示
show: false
},
// 列对齐方式
columnAlign: 'left',
// 是否显示索引列
showIndex: false,
// 索引列名称
indexLabel: '序号',
// 索引列宽度
indexColumnWidth: 70,
// 索引列固定方向false 为不固定
indexColumnFixed: 'left',
// 是否显示操作列
operationColumn: true,
// 操作列宽度
operationColumnWidth: 190,
// 操作列名称
operationColumnText: '操作'
}

View File

@@ -0,0 +1,72 @@
<template>
<a-modal
v-model:visible="visible"
:width="tool.getDevice() === 'mobile' ? '100%' : '600px'"
:footer="false"
@cancel="close"
draggable>
<template #title>导入</template>
<a-upload draggable :custom-request="upload" :show-file-list="false" accept=".xlsx,.xls">
<template #upload-button>
<div
style="background-color: var(--color-fill-2); border: 1px dashed var(--color-fill-4)"
class="rounded text-center p-7">
<div>
<icon-upload class="text-5xl text-gray-400" />
<div class="text-red-600 font-bold">导入Excel</div>
将文件拖到此处<span style="color: #3370ff">点击上传</span>只能上传 xls/xlsx 文件
</div>
</div>
</template>
</a-upload>
<div class="mt-5 italic text-right"><a-link @click="sendDownload">下载Excel模板</a-link></div>
</a-modal>
</template>
<script setup>
import { ref, inject } from 'vue'
import commonApi from '@/api/common'
import tool from '@/utils/tool'
import { Message } from '@arco-design/web-vue'
const visible = ref(false)
const options = inject('options')
const emit = defineEmits(['success'])
const open = () => (visible.value = true)
const close = () => (visible.value = false)
const upload = (fileOption) => {
Message.info('文件上传导入中...')
const dataForm = new FormData()
dataForm.append('file', fileOption.fileItem.file)
if (options.import.params) {
Object.keys(options.import.params).forEach((key) => {
dataForm.append(key, options.import.params[key])
})
}
commonApi.importExcel(options.import.url, dataForm).then(async (res) => {
res.code === 200 && Message.success(res.message || '导入成功')
emit('success')
close()
})
}
const sendDownload = () => {
Message.info('请求服务器下载文件中...')
const url = options.import.templateUrl
if (/^(http|https)/g.test(url)) {
window.open(url)
} else {
commonApi.download(url).then((res) => {
tool.download(res)
Message.success('请求成功,文件开始下载')
})
}
}
defineExpose({ open, close })
</script>

View File

@@ -0,0 +1,778 @@
<template>
<a-layout-content class="flex flex-col lg:h-full relative w-full">
<a-card :bordered="false">
<div ref="crudHeaderRef" v-show="showSearch">
<a-row v-if="tool.getDevice() === 'mobile'">
<a-col :xs="24" :sm="8">
<a-form :model="searchForm" ref="searchFormRef" :auto-label-width="true">
<a-row :gutter="10">
<slot name="tableSearch"></slot>
</a-row>
</a-form>
</a-col>
<a-col :xs="24" :sm="8" :style="{ textAlign: 'right', marginBottom: '15px' }">
<a-space direction="horizontal" :size="20">
<a-button type="primary" @click="search">
<template #icon>
<icon-search />
</template>
{{ options.searchText || '搜索' }}
</a-button>
<a-button @click="resetSearch">
<template #icon>
<icon-refresh />
</template>
{{ options.resetText || '重置' }}
</a-button>
</a-space>
</a-col>
</a-row>
<a-row v-else>
<a-col :flex="1">
<a-form :model="searchForm" ref="searchFormRef" :auto-label-width="true">
<a-row :gutter="10">
<slot name="tableSearch"></slot>
</a-row>
</a-form>
</a-col>
<a-divider v-if="!singleLine" style="height: 84px" direction="vertical" />
<a-col :flex="singleLine ? '185px' : '80px'" :style="{ textAlign: 'right' }">
<a-space :direction="!singleLine ? 'vertical' : 'horizontal'" :size="singleLine ? 10 : 20">
<a-button type="primary" @click="search">
<template #icon>
<icon-search />
</template>
{{ options.searchText || '搜索' }}
</a-button>
<a-button @click="resetSearch">
<template #icon>
<icon-refresh />
</template>
{{ options.resetText || '重置' }}
</a-button>
</a-space>
</a-col>
</a-row>
<a-divider style="margin-top: 0; margin-bottom: 15px" />
</div>
<div class="_crud-content">
<a-row style="margin-bottom: 10px" v-if="!options.pageSimple">
<a-col :xs="24" :sm="18">
<a-space :wrap="true">
<slot name="tableBeforeButtons"></slot>
<a-button type="primary" v-if="options.add.show" v-auth="options.add.auth || []" @click="addAction">
<template #icon> <icon-plus /> </template> {{ options.add.text || '新增' }}
</a-button>
<a-popconfirm
content="确定要删除数据吗?"
position="bottom"
@ok="deletesMultipleAction"
v-if="options.delete.batch && options.rowSelection">
<a-button type="primary" status="danger" v-auth="options.delete.auth || []">
<template #icon> <icon-delete /> </template> {{ options.delete.text || '删除' }}
</a-button>
</a-popconfirm>
<a-button v-if="options.import.show" v-auth="options.import.auth || []" @click="importAction">
<template #icon> <icon-upload /> </template> {{ options.import.text || '导入' }}
</a-button>
<a-button
v-if="options.export.show"
:loading="isExport"
v-auth="options.export.auth || []"
@click="exportAction">
<template #icon> <icon-download /> </template> {{ options.export.text || '导出' }}
</a-button>
<a-button type="secondary" @click="handlerExpand" v-if="options.isExpand">
<template #icon>
<icon-expand v-if="!expandState" />
<icon-shrink v-else />
</template>
{{ expandState ? ' 折叠' : ' 展开' }}
</a-button>
<slot name="tableAfterButtons"></slot>
</a-space>
</a-col>
<a-col
:xs="24"
:sm="6"
:style="{ textAlign: 'right', marginTop: tool.getDevice() === 'mobile' ? '15px' : '0' }">
<a-space v-if="options.showTools">
<slot name="tools"></slot>
<a-tooltip content="刷新表格" @click="refresh">
<a-button shape="circle"><icon-refresh /></a-button>
</a-tooltip>
<a-tooltip content="显隐搜索">
<a-button shape="circle" @click="searchChange"><icon-search /> </a-button>
</a-tooltip>
<a-tooltip content="打印表格"
><a-button shape="circle" @click="printTable"><icon-printer /></a-button
></a-tooltip>
<a-tooltip content="字段排序" v-if="options.showSort">
<a-popover trigger="click" position="br">
<a-button shape="circle"><icon-sort /></a-button>
<template #content>
<div id="tableSetting">
<div
v-for="(item, index) in columns.filter((item) => item.dataIndex !== '__operation')"
:key="item.dataIndex"
class="setting">
<div style="margin-right: 4px">
<icon-sort-ascending />
</div>
<div>
<a-checkbox v-model="item.checked" @change="handleChange($event, item, index)"> </a-checkbox>
</div>
<div class="title">
{{ item.title === '#' ? '序列号' : item.title }}
</div>
</div>
</div>
</template>
</a-popover>
</a-tooltip>
</a-space>
</a-col>
</a-row>
<div ref="crudContentRef">
<slot name="crudContent" v-bind="tableData">
<a-table
v-bind="$attrs"
ref="tableRef"
:key="options.pk"
:rowSelection="options.rowSelection ?? undefined"
:row-key="options.rowSelection?.key ?? options.pk"
:pagination="false"
:columns="columns"
:loading="loading"
:size="options.size"
:stripe="options.stripe"
:data="tableData.data"
:scroll="{ x: '100%', y: '100%' }"
:bordered="options.bordered"
:default-expand-all-rows="options.expandAllRows"
:summary="options.showSummary && __summary"
@selection-change="setSelecteds"
@sorter-change="handlerSort">
<template #columns>
<template v-for="(row, index) in columns" :key="index">
<template v-if="row.children">
<a-table-column :title="row.title">
<template v-for="(rowChild, indexChild) in row.children">
<a-table-column
:title="rowChild.title"
:data-index="rowChild.dataIndex"
:width="rowChild.width"
:min-width="rowChild.minWidth"
:ellipsis="rowChild.ellipsis ?? true"
:filterable="rowChild.filterable"
:cell-class="rowChild.cellClass"
:header-cell-class="rowChild.headerCellClass"
:body-cell-class="rowChild.bodyCellClass"
:summary-cell-class="rowChild.summaryCellClass"
:cell-style="rowChild.cellStyle"
:header-cell-style="rowChild.headerCellStyle"
:body-cell-style="rowChild.bodyCellStyle"
:summary-cell-style="rowChild.summaryCellStyle"
:tooltip="rowChild.dataIndex === '__operation' ? false : rowChild.tooltip ?? true"
:align="rowChild.align || options.columnAlign"
:fixed="rowChild.fixed"
:sortable="rowChild.sortable">
<template #cell="{ record, column, rowIndex }">
<template v-if="rowChild.dataIndex === '__index'">
<span>{{ getIndex(rowIndex) }}</span>
</template>
<template v-else-if="rowChild.dataIndex === '__operation'">
<a-scrollbar type="track" style="overflow: auto">
<a-space size="mini">
<slot name="operationBeforeExtend" v-bind="{ record, column, rowIndex }"></slot>
<slot name="operationCell" v-bind="{ record, column, rowIndex }">
<a-link
v-if="options.edit.show"
v-auth="options.edit.auth || []"
type="primary"
@click="editAction(record)">
<icon-edit />{{ options.edit.text || '编辑' }}
</a-link>
<a-popconfirm
v-if="options.delete.show"
:content="options.delete.confirmText || '确定要删除该数据吗?'"
position="bottom"
@ok="deleteAction(record)">
<a-link type="primary" v-auth="options.delete.auth || []">
<icon-delete /> {{ options.delete.text || '删除' }}
</a-link>
</a-popconfirm>
</slot>
<slot name="operationAfterExtend" v-bind="{ record, column, rowIndex }"></slot>
</a-space>
</a-scrollbar>
</template>
<slot
v-else-if="rowChild.type === 'dict'"
:name="rowChild.dataIndex"
v-bind="{ record, column, rowIndex }">
<sa-dict
:value="record[rowChild.dataIndex]"
:render="rowChild.render || 'tag'"
:colors="rowChild.colors || []"
:dict="rowChild.dict || []"
:options="rowChild.options ?? []">
</sa-dict>
</slot>
<template v-else-if="rowChild.type === 'image'">
<a-avatar
v-if="record[rowChild.dataIndex]"
@click="imageSee(rowChild, record, rowChild.dataIndex)"
:size="row.size || 64"
shape="square">
<img
:src="imageView(record[rowChild.dataIndex])"
style="object-fit: contain; cursor: pointer" />
</a-avatar>
</template>
<slot v-else :name="rowChild.dataIndex" v-bind="{ record, column, rowIndex }">
<span>{{ filterColumn(rowChild.dataIndex, record) }}</span>
</slot>
</template>
</a-table-column>
</template>
</a-table-column>
</template>
<a-table-column
v-else
:title="row.title"
:data-index="row.dataIndex"
:width="row.width"
:min-width="row.minWidth"
:ellipsis="row.ellipsis ?? true"
:filterable="row.filterable"
:cell-class="row.cellClass"
:header-cell-class="row.headerCellClass"
:body-cell-class="row.bodyCellClass"
:summary-cell-class="row.summaryCellClass"
:cell-style="row.cellStyle"
:header-cell-style="row.headerCellStyle"
:body-cell-style="row.bodyCellStyle"
:summary-cell-style="row.summaryCellStyle"
:tooltip="row.dataIndex === '__operation' ? false : row.tooltip ?? true"
:align="row.align || options.columnAlign"
:fixed="row.fixed"
:sortable="row.sortable">
<template #cell="{ record, column, rowIndex }">
<template v-if="row.dataIndex === '__index'">
<span>{{ getIndex(rowIndex) }}</span>
</template>
<template v-else-if="row.dataIndex === '__operation'">
<a-scrollbar type="track" style="overflow: auto">
<a-space size="mini">
<slot name="operationBeforeExtend" v-bind="{ record, column, rowIndex }"></slot>
<slot name="operationCell" v-bind="{ record, column, rowIndex }">
<a-link
v-if="options.view.show"
v-auth="options.view.auth || []"
type="primary"
@click="viewAction(record)">
<icon-eye />{{ options.view.text || '查看' }}
</a-link>
<a-link
v-if="options.edit.show"
v-auth="options.edit.auth || []"
type="primary"
@click="editAction(record)">
<icon-edit />{{ options.edit.text || '编辑' }}
</a-link>
<a-popconfirm
v-if="options.delete.show"
:content="options.delete.confirmText || '确定要删除该数据吗?'"
position="bottom"
@ok="deleteAction(record)">
<a-link type="primary" v-auth="options.delete.auth || []">
<icon-delete /> {{ options.delete.text || '删除' }}
</a-link>
</a-popconfirm>
</slot>
<slot name="operationAfterExtend" v-bind="{ record, column, rowIndex }"></slot>
</a-space>
</a-scrollbar>
</template>
<slot v-else-if="row.type === 'dict'" :name="row.dataIndex" v-bind="{ record, column, rowIndex }">
<sa-dict
:value="record[row.dataIndex]"
:render="row.render || 'tag'"
:colors="row.colors || []"
:dict="row.dict || []"
:options="row.options ?? []">
</sa-dict>
</slot>
<template v-else-if="row.type === 'image'">
<a-avatar
v-if="record[row.dataIndex]"
@click="imageSee(row, record, row.dataIndex)"
:size="row.size || 64"
shape="square">
<img :src="imageView(record[row.dataIndex])" style="object-fit: contain; cursor: pointer" />
</a-avatar>
</template>
<slot v-else :name="row.dataIndex" v-bind="{ record, column, rowIndex }">
<span>{{ filterColumn(row.dataIndex, record) }}</span>
</slot>
</template>
</a-table-column>
</template>
</template>
<template #summary-cell="{ column, record, rowIndex }" v-if="options.showSummary">
<slot name="summaryCell" v-bind="{ record, column, rowIndex }">{{ record[column.dataIndex] }}</slot>
</template>
</a-table>
</slot>
</div>
</div>
<div class="mt-2 text-right" v-if="tableData.total > 0">
<a-pagination
:total="tableData.total"
show-total
show-jumper
show-page-size
:page-size-options="options.pageSizeOption"
@page-size-change="pageSizeChangeHandler"
@change="pageChangeHandler"
v-model:current="requestParams['page']"
:page-size="requestParams['limit']"
style="display: inline-flex" />
</div>
</a-card>
<sa-import ref="crudImportRef" @success="refresh" />
<a-image-preview-group
:srcList="imgUrl"
v-model:visible="imgVisible"
v-if="typeof imgUrl === 'object' && imgUrl !== null" />
<a-image-preview :src="imgUrl ? imgUrl : NotImage" v-model:visible="imgVisible" v-else />
</a-layout-content>
</template>
<script setup>
import { ref, reactive, watch, provide, nextTick, onMounted, onUnmounted } from 'vue'
import { isArray, isFunction, isObject, isUndefined, cloneDeep, get } from 'lodash'
import defaultOptions from './defaultOptions'
import tool from '@/utils/tool'
import Print from '@/utils/print'
import { request } from '@/utils/request'
import { Message } from '@arco-design/web-vue'
import { useDictStore } from '@/store'
import SaImport from './import.vue'
import NotImage from '@/assets/not-image.png'
const props = defineProps({
// 表格数据
data: { type: [Function, Array], default: () => null },
// 表格设置
options: { type: Object, default: {} },
// 字段
columns: { type: Array, default: [] },
// 搜索表单
searchForm: { type: Object, default: () => {} },
})
const emit = defineEmits(['resetSearch'])
const searchFormRef = ref()
const crudHeaderRef = ref()
const crudContentRef = ref()
const headerHeight = ref(0)
const crudImportRef = ref()
const loading = ref(false)
const showSearch = ref(true)
const singleLine = ref(true)
const currentApi = ref()
const expandState = ref(false)
const selecteds = ref([])
const tableRef = ref()
const isSort = ref(false)
const isExport = ref(false)
const imgVisible = ref(false)
const imgUrl = ref('../../assets/not-image.png')
const options = ref(Object.assign(JSON.parse(JSON.stringify(defaultOptions)), props.options))
const requestParams = ref({
page: 1,
limit: options.value.pageSize,
})
const searchForm = ref(props.searchForm)
const columns = ref(props.columns)
const tableData = reactive({
total: 0,
data: [],
})
provide('options', options.value)
const filterColumn = (index, record) => {
return index.indexOf('.') > -1 ? get(record, index) : record[index]
}
const clearSelected = () => {
tableRef.value?.selectAll(false)
}
const setSelecteds = (key) => {
selecteds.value = key
}
const getTableData = () => {
return tableData.data
}
const getTableTotal = () => {
return tableData.total
}
const __summary = ({ data }) => {
if (options.value.showSummary && isArray(options.value.summary)) {
const summary = options.value.summary
let summaryData = {}
let summaryPrefixText = {}
let summarySuffixText = {}
let length = data.length || 0
summary.map((item) => {
if (item.action && item.action === 'text') {
summaryData[item.dataIndex] = item.text
} else {
summaryData[item.dataIndex] = 0
summaryPrefixText[item.dataIndex] = item?.prefixText ?? ''
summarySuffixText[item.dataIndex] = item?.suffixText ?? ''
data.map((record) => {
if (record[item.dataIndex]) {
if (item.action && item.action === 'sum') {
summaryData[item.dataIndex] += parseFloat(record[item.dataIndex])
}
if (item.action && item.action === 'avg') {
summaryData[item.dataIndex] += parseFloat(record[item.dataIndex]) / length
}
}
})
}
})
for (let i in summaryData) {
if (/^\d+(\.\d+)?$/.test(summaryData[i])) {
summaryData[i] = summaryPrefixText[i] + tool.groupSeparator(summaryData[i].toFixed(2)) + summarySuffixText[i]
}
}
return [summaryData]
}
}
const getIndex = (rowIndex) => {
const index = rowIndex + 1
if (requestParams.value['page'] === 1) {
return index
} else {
return (requestParams.value['page'] - 1) * requestParams.value['limit'] + index
}
}
// 页码变化
const pageChangeHandler = async (currentPage) => {
requestParams.value['page'] = currentPage
await refresh()
}
// 每页数量变化
const pageSizeChangeHandler = async (pageSize) => {
requestParams.value['page'] = 1
requestParams.value['limit'] = pageSize
await refresh()
}
// 搜索
const search = async () => {
await refresh()
}
// 重置
const resetSearch = async () => {
requestParams.value['page'] = 1
searchFormRef.value?.resetFields()
emit('resetSearch')
await refresh()
}
// 折叠/展开
const handlerExpand = () => {
expandState.value = !expandState.value
expandState.value ? tableRef.value.expandAll(true) : tableRef.value.expandAll(false)
}
// 排序
const handlerSort = async (name, type) => {
if (type) {
requestParams.value.orderBy = name
requestParams.value.orderType = type === 'ascend' ? 'asc' : 'desc'
isSort.value = true
} else {
requestParams.value.orderBy = undefined
requestParams.value.orderType = undefined
isSort.value = false
}
await refresh()
}
// 切换显示搜素框
const searchChange = async () => {
showSearch.value = !showSearch.value
await nextTick(() => {
headerHeight.value = crudHeaderRef.value.offsetHeight
options.value.pageLayout === 'fixed' && settingFixedPage()
})
}
// 打印表格
const printTable = () => {
new Print(crudContentRef.value)
}
// 排序
const handleChange = (checked, column, index) => {
if (column.dataIndex == '__operation') {
return
}
if (checked) {
column.sortable = {
sortDirections: ['ascend', 'descend'],
}
} else {
column.sortable = undefined
}
}
// 初始化
const init = async () => {
// 设置 组件id
if (isUndefined(options.value.id)) {
options.value.id = 'SaCrud_' + Math.floor(Math.random() * 100000 + Math.random() * 20000 + Math.random() * 5000)
}
// 设置序列号
if (options.value.showIndex && columns.value.length > 0 && columns.value[0].dataIndex !== '__index') {
columns.value.unshift({
title: options.value.indexLabel,
dataIndex: '__index',
width: options.value.indexColumnWidth,
fixed: options.value.indexColumnFixed,
})
}
// 收集数据
if (
columns.value.length > 0 &&
columns.value[columns.value.length - 1].dataIndex !== '__operation' &&
options.value.operationColumn
) {
columns.value?.push({
title: options.value.operationColumnText || '操作',
dataIndex: '__operation',
slotName: '__operation',
align: 'center',
fixed: 'right',
width: options.value.operationColumnWidth ?? 150,
})
}
columns.value.forEach((element) => {
if (element.sortable) {
element.checked = true
}
})
if (isSort.value) {
const fromSearch = cloneDeep(searchForm.value)
if (!isUndefined(fromSearch.orderBy)) {
delete fromSearch.orderBy
delete fromSearch.orderType
}
requestParams.value = Object.assign(requestParams.value, fromSearch)
} else {
requestParams.value = Object.assign(requestParams.value, searchForm.value)
}
if (options.value.singleLine) {
singleLine.value = options.value.singleLine
} else {
singleLine.value = Object.getOwnPropertyNames(props.searchForm).length > 3 ? false : true
}
currentApi.value = options.value.api
}
const refresh = async () => {
await requestData()
//tableRef.value?.selectAll(false)
}
const requestData = async () => {
loading.value = true
init()
if (isFunction(currentApi.value)) {
const response = await currentApi.value(requestParams.value)
if (response.data && response.data.data) {
tableData.total = response.data.total
tableData.data = response.data.data
} else {
tableData.total = 0
tableData.data = response.data
}
} else {
console.error(`sa-table errorcrud.api is not Function.`)
}
loading.value = false
}
// 添加操作
const addAction = () => {
if (options.value.add.func && isFunction(options.value.add.func)) {
options.value.add.func()
} else {
console.error(`sa-table errorcrud.add.func is not Function.`)
}
}
// 编辑操作
const editAction = (record) => {
if (options.value.edit.func && isFunction(options.value.edit.func)) {
options.value.edit.func(record)
} else {
console.error(`sa-table errorcrud.edit.func is not Function.`)
}
}
// 查看操作
const viewAction = (record) => {
if (options.value.view.func && isFunction(options.value.view.func)) {
options.value.view.func(record)
} else {
console.error(`sa-table errorcrud.view.func is not Function.`)
}
}
// 删除操作
const deleteAction = async (record) => {
const params = { ids: [record[options.value.pk]] }
if (options.value.delete.func && isFunction(options.value.delete.func)) {
options.value.delete.func(params)
} else {
console.error(`sa-table errorcrud.delete.func is not Function.`)
}
}
// 批量删除
const deletesMultipleAction = async () => {
const params = { ids: selecteds.value }
if (selecteds.value && selecteds.value.length > 0) {
// 删除
if (options.value.delete.func && isFunction(options.value.delete.func)) {
options.value.delete.func(params)
tableRef.value?.selectAll(false)
} else {
console.error(`sa-table errorcrud.delete.func is not Function.`)
}
} else {
Message.error('至少选择一条数据')
}
}
// 导入
const importAction = () => crudImportRef.value.open()
// 导出
const exportAction = () => {
Message.info('请求服务器下载文件中...')
const data = requestParams.value
const download = (url) => request({ url, data, method: 'post', timeout: 60 * 1000, responseType: 'blob' })
isExport.value = true
download(options.value.export.url)
.then((res) => {
if (res && res.status == 200) {
tool.download(res)
Message.success('请求成功,文件开始下载')
} else {
Message.error('请前往服务端安装Excel导出库')
}
})
.catch(() => {
Message.error('请求服务器错误,下载失败')
})
.finally(() => {
isExport.value = false
})
}
const imageSee = async (row, record, dataIndex) => {
imgUrl.value = record[dataIndex]
imgVisible.value = true
}
const imageView = (url) => {
if (typeof url === 'string' && url !== null) {
return url
} else {
if (url !== null) {
return url[0]
} else {
return NotImage
}
}
}
const resizeHandler = () => {
headerHeight.value = crudHeaderRef.value.offsetHeight
settingFixedPage()
}
const settingFixedPage = () => {
const workAreaHeight = options.value.height ? options.value.height : document.querySelector('.work-area').offsetHeight
let tableHeight = workAreaHeight - headerHeight.value - 160 + (!showSearch.value ? 15 : 0)
crudContentRef.value.style.height = tableHeight + 'px'
}
onMounted(async () => {
showSearch.value = options.value.showSearch ?? true
if (options.value.pageLayout === 'fixed') {
await nextTick(() => {
window.addEventListener('resize', resizeHandler, false)
headerHeight.value = crudHeaderRef.value.offsetHeight
settingFixedPage()
})
}
})
onUnmounted(() => {
if (options.value.pageLayout === 'fixed') {
window.removeEventListener('resize', resizeHandler, false)
}
})
defineExpose({
requestData,
refresh,
setSelecteds,
clearSelected,
tableRef,
getTableData,
getTableTotal,
})
</script>
<style lang="less" scoped>
.setting {
display: flex;
align-items: center;
width: 150px;
.title {
margin-left: 12px;
cursor: pointer;
}
}
</style>

View File

@@ -0,0 +1,120 @@
<template>
<div class="flex flex-col w-full" :class="props.border ? 'slider-border p-2' : ''">
<a-input-group class="mb-2 w-full" size="mini">
<a-input :placeholder="props?.searchPlaceholder" allow-clear @input="changeKeyword" @clear="resetData" />
<a-button
@click="
() => {
isExpand ? saTree.expandAll(false) : saTree.expandAll(true)
isExpand = !isExpand
}
"
>{{ isExpand ? '折叠' : '展开' }}</a-button
>
<slot name="treeAfterButtons"></slot>
</a-input-group>
<a-tree
blockNode
ref="saTree"
:data="treeData"
class="h-full w-full"
@select="handlerSelect"
:field-names="props.fieldNames"
v-model:selected-keys="modelValue"
v-bind="$attrs">
<template #icon v-if="props.icon"><component :is="props.icon" /></template>
<template v-for="(_, name) in $slots" v-slot:[name]="data">
<slot :name="name" v-bind="data"></slot>
</template>
</a-tree>
</div>
</template>
<script setup>
import { ref, watch, computed, onMounted } from 'vue'
const treeData = ref([])
const saTree = ref()
const isExpand = ref(false)
const emit = defineEmits(['update:modelValue', 'click'])
const props = defineProps({
modelValue: { type: Array },
data: { type: Array },
border: { type: Boolean, default: true },
searchPlaceholder: { type: String },
fieldNames: {
type: Object,
default: () => {
return { title: 'label', key: 'value' }
},
},
icon: { type: String, default: undefined },
})
const modelValue = computed({
get() {
return props.modelValue
},
set(newVal) {
emit('update:modelValue', newVal)
},
})
watch(
() => props.data,
(val) => {
treeData.value = val
},
{ immediate: true, deep: true }
)
const handlerSelect = (item, data) => {
modelValue.value = [item]
emit('click', ...[item, data])
}
const resetData = () => (treeData.value = props.data)
const changeKeyword = (keyword) => {
if (!keyword || keyword === '') {
treeData.value = Object.assign(props.data, [])
return false
}
treeData.value = searchNode(keyword)
}
const searchNode = (keyword) => {
const loop = (data) => {
let tree = []
data.map((item) => {
if (item[props.fieldNames['title']].indexOf(keyword) !== -1) {
tree.push(item)
} else if (item.children && item.children.length > 0) {
const temp = loop(item.children)
tree.push(...temp)
}
return tree
})
return tree
}
return loop(treeData.value)
}
defineExpose({ saTree })
</script>
<style scoped lang="less">
:deep(.arco-tree-node:hover) {
background-color: var(--color-fill-2);
border-radius: 3px;
}
:deep(.arco-tree-node-switcher) {
margin-left: 2px;
}
.slider-border {
border: 1px solid #ebebeb;
}
</style>

View File

@@ -0,0 +1,298 @@
<template>
<div>
<div class="upload-file w-full">
<a-upload
:custom-request="uploadFileHandler"
:show-file-list="false"
:multiple="props.multiple"
:accept="props.accept"
:disabled="isDisabled"
:tip="props.tip"
:draggable="props.draggable">
<template #upload-button v-if="props.draggable">
<slot name="customer">
<div style="background-color: var(--color-fill-2); border: 1px dashed var(--color-fill-4)" class="rounded text-center p-7 w-full">
<div>
<icon-upload class="text-5xl text-gray-400" />
<div class="text-red-600 font-bold">
{{ props.title }}
</div>
将文件拖到此处<span style="color: #3370ff">点击上传</span>
</div>
</div>
</slot>
</template>
</a-upload>
</div>
<!-- 单文件 -->
<div class="file-list mt-2" v-if="!props.multiple && props.showList && currentItem?.percent">
<a-progress
v-if="currentItem.percent < 100"
:percent="currentItem.percent"
animation
class="progress">
<template v-slot:text="scope" >
{{(scope.percent * 100).toFixed(2)}}%
</template>
</a-progress>
<a-tooltip content="点击文件名预览/下载" position="tr">
<a
:href="currentItem.url"
v-if="currentItem?.url && currentItem.percent === 100 && currentItem?.status === 'complete'"
class="file-name"
target="_blank"
>{{ currentItem.name }}</a
>
</a-tooltip>
<a-button type="text" size="small" @click="removeSignFile()" v-if="currentItem.percent === 100">
<template #icon>
<icon-delete />
</template>
</a-button>
</div>
<!-- 多文件 -->
<div v-if="props.showList" class="file-list mt-2" v-for="(file, idx) in showFileList" :key="idx">
<a-progress
v-if="file.percent < 100"
:percent="file.percent"
animation
class="progress">
<template v-slot:text="scope" >
{{(scope.percent * 100).toFixed(2)}}%
</template>
</a-progress>
<a-tooltip content="点击文件名预览/下载" position="tr">
<a
:href="file.url"
v-if="file?.url && file.percent === 100 && file?.status === 'complete'"
class="file-name"
target="_blank"
>{{ file.name }}</a>
</a-tooltip>
<a-button type="text" size="small" v-if="file.percent === 100" @click="removeFile(idx)">
<template #icon>
<icon-delete />
</template>
</a-button>
</div>
</div>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import { isArray } from 'lodash'
import file2md5 from 'file2md5'
import commonApi from '@/api/common'
import { Message } from '@arco-design/web-vue'
const props = defineProps({
modelValue: {
type: [String, Number, Array],
default: () => {},
},
showList: { type: Boolean, default: true },
draggable: { type: Boolean, default: false },
multiple: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
title: { type: String, default: '本地上传' },
icon: { type: String, default: 'icon-plus' },
size: { type: Number, default: 100 * 1024 * 1024 },
chunkSize: { type: Number, default: 1 * 1024 * 1024 },
limit: { type: Number, default: 0 },
mode: { type: String, default: 'system' },
tip: { type: String, default: undefined },
accept: { type: String, default: '*' },
})
const emit = defineEmits(['update:modelValue'])
const showFileList = ref([])
const signFile = ref()
const currentItem = ref({})
const uploading = ref(false)
const isDisabled = computed(() => {
if (props.disabled) {
return true
} else {
if (!props.multiple) {
if (currentItem.value && currentItem.value.percent) {
return true
}
}
return false
}
})
const uploadFileHandler = async (options) => {
if (uploading.value) {
Message.warning('正在上传中,请稍后上传')
return
}
let idx
if (!props.multiple) {
currentItem.value = options.fileItem
} else {
showFileList.value.push(options.fileItem)
idx = showFileList.value.length - 1
}
let isCheck = true
const file = options.fileItem.file
if(!file.type) {
Message.error('获取文件类型失败,无法上传')
return
}
if (file.size > props.size) {
Message.warning(file.name + '超出文件大小限制')
currentItem.value = {}
isCheck = false
}
if (props.multiple && props.limit > 0) {
if (showFileList.value.length > props.limit) {
Message.warning('最多上传' + props.limit + '个文件')
currentItem.value = {}
showFileList.value.pop()
isCheck = false
}
}
uploading.value = true
if (isCheck) {
const hash = await file2md5(file)
const chunks = Math.ceil(file.size / props.chunkSize)
for(let currentChunk = 0; currentChunk < chunks; currentChunk++) {
const start = currentChunk * props.chunkSize
const end = (start + props.chunkSize >= file.size)
? file.size
: start + props.chunkSize
const dataForm = new FormData()
dataForm.append('package', file.slice(start, end))
dataForm.append('hash', hash)
dataForm.append('total', chunks)
dataForm.append('name', file.name)
dataForm.append('type', file.type)
dataForm.append('size', file.size)
dataForm.append('index', currentChunk + 1)
dataForm.append('ext', /[^.]+$/g.exec(file.name)[0])
const res = await commonApi.chunkUpload(dataForm)
if(res.data && res.data.hash) {
if(props.multiple) {
showFileList.value[idx].percent = 100
showFileList.value[idx].status = 'complete'
showFileList.value[idx].url = res.data.url
let files = []
files = showFileList.value.map(item => {
return item.url
})
emit('update:modelValue', files)
} else {
signFile.value = res.data['url']
emit('update:modelValue', signFile.value)
currentItem.value.url = res.data.url
currentItem.value.percent = 99
setTimeout(() => {
currentItem.value.status = 'complete'
currentItem.value.percent = 100
}, 1000)
}
break
}
if(res.data && res.data.status && res.data.status === 'resume') {
currentChunk = res.data.chunk - 2
const percent = (Math.floor((1 / chunks) * 10000) / 10000) * (res.data.chunk - 1);
if (props.multiple) {
showFileList.value[idx].percent = percent
} else {
currentItem.value.percent = percent
}
continue
}
if(res.data && res.data.status && res.data.status === 'success') {
const percent = Math.floor((1 / chunks) * 10000) / 10000;
if (props.multiple) {
showFileList.value[idx].percent += percent
} else {
currentItem.value.status = 'uploading'
currentItem.value.percent += percent
}
}
}
}
uploading.value = false
}
const removeSignFile = () => {
currentItem.value = {}
signFile.value = undefined
emit('update:modelValue', null)
}
const removeFile = (idx) => {
showFileList.value.splice(idx, 1)
let files = []
files = showFileList.value.map((item) => {
return item['url']
})
emit('update:modelValue', files)
}
const initData = async () => {
if (props.multiple) {
if (isArray(props.modelValue) && props.modelValue.length > 0) {
showFileList.value = props.modelValue.map((url) => {
return { url, name: url.substring(url.lastIndexOf('/') + 1), percent: 100, status: 'complete' }
})
} else {
showFileList.value = []
}
} else if (props.modelValue) {
signFile.value = props.modelValue
currentItem.value.url = props.modelValue
currentItem.value.name = props.modelValue.substring(props.modelValue.lastIndexOf('/') + 1)
currentItem.value.percent = 100
currentItem.value.status = 'complete'
} else {
removeSignFile()
}
}
watch(
() => props.modelValue,
(val) => {
initData()
},
{
deep: true,
immediate: true,
}
)
</script>
<style lang="less" scoped>
.file-list {
background-color: var(--color-primary-light-1);
border-radius: 4px;
height: 36px;
padding: 0 5px;
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
.progress {
width: 200px;
display: block;
margin: 0 5px;
}
.file-name {
margin: 0 5px;
overflow: hidden;
color: #165dff;
}
}
</style>

View File

@@ -0,0 +1,206 @@
<template>
<div>
<div class="upload-file w-full">
<a-upload
:custom-request="uploadFileHandler"
:show-file-list="false"
:multiple="props.multiple"
:accept="props.accept"
:disabled="isDisabled"
:tip="props.tip"
:draggable="props.draggable">
<template #upload-button v-if="props.draggable">
<slot name="customer">
<div
style="background-color: var(--color-fill-2); border: 1px dashed var(--color-fill-4)"
class="rounded text-center p-7 w-full">
<div>
<icon-upload class="text-5xl text-gray-400" />
<div class="text-red-600 font-bold">
{{ props.title }}
</div>
将文件拖到此处<span style="color: #3370ff">点击上传</span>
</div>
</div>
</slot>
</template>
</a-upload>
</div>
<!-- 单文件 -->
<div class="file-list mt-2" v-if="!props.multiple && currentItem?.url && props.showList">
<a-tooltip content="点击文件名预览/下载" position="tr">
<a :href="currentItem.url" v-if="currentItem?.url" class="file-name" target="_blank">{{ currentItem.name }}</a>
</a-tooltip>
<a-button type="text" size="small" @click="removeSignFile()">
<template #icon>
<icon-delete />
</template>
</a-button>
</div>
<!-- 多文件 -->
<div v-if="props.showList" class="file-list mt-2" v-for="(file, idx) in showFileList" :key="idx">
<a-tooltip content="点击文件名预览/下载" position="tr">
<a :href="file.url" v-if="file?.url" class="file-name" target="_blank">{{ file.name }}</a>
</a-tooltip>
<a-button type="text" size="small" @click="removeFile(idx)">
<template #icon>
<icon-delete />
</template>
</a-button>
</div>
</div>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import { isArray } from 'lodash'
import file2md5 from 'file2md5'
import commonApi from '@/api/common'
import { Message } from '@arco-design/web-vue'
const props = defineProps({
modelValue: {
type: [String, Number, Array],
default: () => {},
},
showList: { type: Boolean, default: true },
draggable: { type: Boolean, default: false },
multiple: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
title: { type: String, default: '本地上传' },
icon: { type: String, default: 'icon-plus' },
size: { type: Number, default: 4 * 1024 * 1024 },
limit: { type: Number, default: 0 },
mode: { type: String, default: 'system' },
tip: { type: String, default: undefined },
accept: { type: String, default: '*' },
})
const emit = defineEmits(['update:modelValue'])
const showFileList = ref([])
const signFile = ref()
const currentItem = ref({})
const isDisabled = computed(() => {
if (props.disabled) {
return true
} else {
if (!props.multiple) {
if (currentItem.value && currentItem.value.url) {
return true
}
}
return false
}
})
const uploadFileHandler = async (options) => {
if (!options.fileItem) return
if (!props.multiple) {
currentItem.value = options.fileItem
}
let isCheck = true
const file = options.fileItem.file
if (file.size > props.size) {
Message.warning(file.name + '超出文件大小限制')
currentItem.value = {}
isCheck = false
}
if (props.multiple && props.limit > 0) {
if (showFileList.value.length >= props.limit) {
Message.warning('最多上传' + props.limit + '个文件')
currentItem.value = {}
isCheck = false
}
}
if (isCheck) {
const hash = await file2md5(file)
const dataForm = new FormData()
dataForm.append('file', file)
dataForm.append('hash', hash)
if (props.mode === 'local') {
dataForm.append('mode', 'local')
}
const resp = await commonApi.uploadFile(dataForm)
const result = resp.data
if (result) {
if (!props.multiple) {
signFile.value = result['url']
emit('update:modelValue', signFile.value)
} else {
showFileList.value.push(result)
let files = []
files = showFileList.value.map((item) => {
return item['url']
})
emit('update:modelValue', files)
}
}
}
}
const removeSignFile = () => {
currentItem.value = {}
signFile.value = undefined
emit('update:modelValue', null)
}
const removeFile = (idx) => {
showFileList.value.splice(idx, 1)
let files = []
files = showFileList.value.map((item) => {
return item['url']
})
emit('update:modelValue', files)
}
const initData = async () => {
if (props.multiple) {
if (isArray(props.modelValue) && props.modelValue.length > 0) {
showFileList.value = props.modelValue.map((url) => {
return { url, name: url.substring(url.lastIndexOf('/') + 1) }
})
} else {
showFileList.value = []
}
} else if (typeof props.modelValue === 'string') {
signFile.value = props.modelValue
currentItem.value.url = props.modelValue
currentItem.value.name = props.modelValue.substring(props.modelValue.lastIndexOf('/') + 1)
}
}
watch(
() => props.modelValue,
(val) => {
initData()
},
{
deep: true,
immediate: true,
}
)
</script>
<style lang="less" scoped>
.file-list {
background-color: var(--color-primary-light-1);
border-radius: 4px;
height: 36px;
padding: 0 5px;
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
.file-name {
max-width: 90%;
margin: 0 5px;
overflow: hidden;
color: #165dff;
}
}
</style>

View File

@@ -0,0 +1,234 @@
<template>
<div class="upload-image flex" :class="props.rounded ? 'rounded-full' : ''">
<!-- 单图 -->
<a-space wrap>
<div :class="'image-list ' + (props.rounded ? 'rounded-full' : '')" v-if="!props.multiple && currentItem?.url">
<a-button class="delete" @click="removeSignImage()">
<template #icon>
<icon-delete />
</template>
</a-button>
<a-image width="130" height="130" :class="props.rounded ? 'rounded-full' : ''" :src="currentItem.url" />
</div>
<!-- 多图显示 -->
<template v-else-if="props.multiple">
<div
:class="'image-list ' + (props.rounded ? 'rounded-full' : '')"
v-for="(image, idx) in showImgList"
:key="idx">
<a-button class="delete" @click="removeImage(idx)">
<template #icon>
<icon-delete />
</template>
</a-button>
<a-image width="130" height="130" :class="props.rounded ? 'rounded-full' : ''" :src="image.url" />
</div>
</template>
<a-upload
:custom-request="uploadImageHandler"
:show-file-list="false"
:accept="props.accept ?? '.jpg,.jpeg,.gif,.png,.svg,.bpm'"
:disabled="props.disabled"
:tip="props.tip">
<template #upload-button>
<slot name="customer">
<div
:class="'upload-skin ' + (props.rounded ? 'rounded-full' : 'rounded-sm')"
v-if="props.multiple && showImgList.length < props.limit">
<div class="icon text-3xl">
<component :is="props.icon" />
</div>
<div class="title">
{{ props.title }}
</div>
</div>
<div
:class="'upload-skin ' + (props.rounded ? 'rounded-full' : 'rounded-sm')"
v-if="!props.modelValue && !props.multiple">
<div class="icon text-3xl">
<component :is="props.icon" />
</div>
<div class="title">
{{ props.title }}
</div>
</div>
</slot>
</template>
</a-upload>
</a-space>
</div>
</template>
<script setup>
import { nextTick, ref, watch } from 'vue'
import { isArray } from 'lodash'
import file2md5 from 'file2md5'
import commonApi from '@/api/common'
import { Message } from '@arco-design/web-vue'
const props = defineProps({
modelValue: {
type: [String, Array],
default: () => {},
},
rounded: { type: Boolean, default: false },
multiple: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
title: { type: String, default: '本地上传' },
icon: { type: String, default: 'icon-plus' },
size: { type: Number, default: 4 * 1024 * 1024 },
limit: { type: Number, default: 0 },
mode: { type: String, default: 'system' },
tip: { type: String, default: undefined },
accept: { type: String, default: '.jpg,.jpeg,.gif,.png,.svg,.bpm' },
})
const emit = defineEmits(['update:modelValue'])
const showImgList = ref([])
const signImage = ref()
const currentItem = ref({})
const uploadImageHandler = async (options) => {
if (!options.fileItem) return
if (!props.multiple) {
currentItem.value = options.fileItem
}
let isCheck = true
const file = options.fileItem.file
if (file.size > props.size) {
Message.warning(file.name + '超出文件大小限制')
currentItem.value = {}
isCheck = false
}
if (props.multiple && props.limit > 0) {
if (showImgList.value.length >= props.limit) {
Message.warning('最多上传' + props.limit + '张图片')
currentItem.value = {}
isCheck = false
}
}
if (isCheck) {
const hash = await file2md5(file)
const dataForm = new FormData()
dataForm.append('image', file)
dataForm.append('isChunk', false)
dataForm.append('hash', hash)
if (props.mode === 'local') {
dataForm.append('mode', 'local')
}
const resp = await commonApi.uploadImage(dataForm)
const result = resp.data
if (result) {
if (!props.multiple) {
signImage.value = result['url']
emit('update:modelValue', signImage.value)
} else {
showImgList.value.push(result)
let files = []
files = showImgList.value.map((item) => {
return item['url']
})
emit('update:modelValue', files)
}
}
}
}
const removeSignImage = () => {
currentItem.value = {}
signImage.value = undefined
emit('update:modelValue', null)
}
const removeImage = (idx) => {
showImgList.value.splice(idx, 1)
let files = []
files = showImgList.value.map((item) => {
return item['url']
})
emit('update:modelValue', files)
}
const initData = async () => {
if (props.multiple) {
if (isArray(props.modelValue) && props.modelValue.length > 0) {
showImgList.value = props.modelValue.map((url) => {
return { url }
})
} else {
showImgList.value = []
}
} else if (typeof props.modelValue === 'string') {
signImage.value = props.modelValue
currentItem.value.url = props.modelValue
currentItem.value.percent = 100
currentItem.value.status = 'complete'
}
}
watch(
() => props.modelValue,
(val) => {
initData()
},
{
deep: true,
immediate: true,
}
)
</script>
<style lang="less" scoped>
.upload-skin {
background-color: var(--color-fill-2);
border: 1px dashed var(--color-fill-4);
width: 130px;
height: 130px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.icon,
.title {
color: var(--color-text-3);
}
}
.image-list {
cursor: pointer;
position: relative;
background-color: var(--color-fill-2);
width: 130px;
height: 130px;
.delete {
position: absolute;
z-index: 99;
right: 3px;
top: 3px;
display: none;
}
.progress {
position: absolute;
left: 50%;
top: 50%;
transform: translateX(-50%) translateY(-50%);
}
}
.image-list:hover {
.delete {
display: block;
}
}
.upload-skin:hover {
border: 1px dashed rgb(var(--primary-6));
}
// .arco-upload-hide {
// display: block !important;
// }
</style>

View File

@@ -0,0 +1,159 @@
<template>
<div class="ma-content-block">
<a-space class="flex">
<a-button type="primary" @click="open">
<template #icon><icon-select-all /></template>{{ props.text }}
</a-button>
<a-tag size="large" color="blue" v-if="props.isEcho"
>已选择 {{ isArray(selecteds) ? selecteds.length : 0 }} </a-tag
>
<a-input-tag
v-model="userList"
v-if="props.isEcho"
:style="{ width: '320px' }"
:placeholder="'请点击前面按钮' + props.text"
:max-tag-count="3"
disabled />
</a-space>
<a-modal v-model:visible="visible" width="100%" draggable :on-before-ok="close" unmountOnClose>
<template #title>{{ props.text }}</template>
<sa-table
ref="crudRef"
:options="options"
:columns="columns"
:searchForm="searchForm"
v-model:selected-keys="selecteds"
@selection-change="selectHandler">
<!-- 搜索区 tableSearch -->
<template #tableSearch>
<a-col :sm="8" :xs="24">
<a-form-item field="username" label="账户">
<a-input v-model="searchForm.username" placeholder="请输入账户" />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="phone" label="手机">
<a-input v-model="searchForm.phone" placeholder="请输入手机" />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="dept_id" label="部门">
<a-tree-select
v-model="searchForm.dept_id"
:data="deptData"
:field-names="{ key: 'value', title: 'label' }"
allow-clear
placeholder="请选择所属部门">
</a-tree-select>
</a-form-item>
</a-col>
</template>
</sa-table>
</a-modal>
</div>
</template>
<script setup>
import { onMounted, ref, watch, nextTick } from 'vue'
import commonApi from '@/api/common'
import { Message } from '@arco-design/web-vue'
import { isArray, isEmpty } from 'lodash'
const props = defineProps({
modelValue: { type: Array },
isEcho: { type: Boolean, default: false },
multiple: { type: Boolean, default: true },
onlyId: { type: Boolean, default: true },
text: { type: String, default: '选择用户' },
})
const emit = defineEmits(['update:modelValue', 'success'])
const visible = ref(false)
const crudRef = ref()
const selecteds = ref([])
const userList = ref([])
const deptData = ref([])
const open = async () => {
visible.value = true
initPage()
await nextTick()
crudRef.value?.refresh()
setTimeout(() => {
selecteds.value = props.modelValue
}, 500)
}
const initPage = async () => {
const deptResp = await commonApi.commonGet('/core/dept/index?tree=true')
deptData.value = deptResp.data
}
onMounted(() => {
if (props.isEcho && props.onlyId) selecteds.value = props.modelValue
})
watch(
() => props.modelValue,
(val) => {
if (props.isEcho && props.onlyId) selecteds.value = val
if (val.length == 0) userList.value = []
}
)
const selectHandler = (rows) => {
selecteds.value = rows
}
const close = async (done) => {
if (isArray(selecteds.value) && selecteds.value.length > 0) {
const response = await commonApi.getUserInfoByIds({ ids: selecteds.value })
if (!isEmpty(response) && isArray(response.data)) {
userList.value = response.data.map((item) => {
return `${item.username}(${item.id})`
})
if (props.onlyId) {
emit('update:modelValue', selecteds.value)
} else {
emit('update:modelValue', response.data)
}
emit('success', true)
Message.success('选择成功')
}
} else {
emit('update:modelValue', [])
userList.value = []
}
done(true)
}
const searchForm = ref({
username: '',
phone: '',
dept_id: '',
})
const options = ref({
api: commonApi.getUserList,
pageSimple: true,
operationColumn: false,
rowSelection: props.multiple ? { type: 'checkbox', showCheckedAll: true } : { type: 'radio' },
})
const columns = ref([
{ title: '账户', dataIndex: 'username', width: 120 },
{ title: '昵称', dataIndex: 'nickname', width: 120 },
{ title: '手机', dataIndex: 'phone', width: 120 },
{ title: '邮箱', dataIndex: 'email', width: 180 },
])
</script>
<style scoped>
:deep(.arco-tabs-nav-type-capsule .arco-tabs-nav-tab) {
justify-content: flex-start;
}
</style>

View File

@@ -0,0 +1,23 @@
import mine from '@/assets/skins-thumb/mine/thumb.jpg'
import classics from '@/assets/skins-thumb/classics/thumb.jpg'
import businessGray from '@/assets/skins-thumb/businessGray/thumb.jpg'
import city from '@/assets/skins-thumb/city/thumb.jpg'
export default [
{
name: 'mine',
thumb: mine
},
{
name: 'classics',
thumb: classics
},
{
name: 'businessGray',
thumb: businessGray
},
{
name: 'city',
thumb: city
}
]

View File

@@ -0,0 +1,8 @@
import { useUserStore } from '@/store'
const auth = name => {
const userStore = useUserStore()
return (userStore.codes && userStore.codes.includes(name)) || (userStore.codes && userStore.codes.includes('*'))
}
export default auth

View File

@@ -0,0 +1,29 @@
import auth from './auth'
const checkAuth = (el, binding) => {
const { value } = binding
if (Array.isArray(value)) {
if (value.length > 0) {
let isHas = false
value.map(item => {
isHas = auth(item)
})
if (!isHas && el.parentNode) {
el.parentNode.removeChild(el)
}
}
} else {
throw new Error(`need permission! Like v-auth="['admin','user']"`)
}
}
export default {
mounted(el, binding) {
checkAuth(el, binding)
},
updated(el, binding) {
checkAuth(el, binding)
},
};

View File

@@ -0,0 +1,46 @@
import useClipboard from 'vue-clipboard3'
import { Message } from '@arco-design/web-vue'
const handlerMap = new WeakMap()
const copy = (el, binding) => {
const { value } = binding
const oldHandler = handlerMap.get(el)
if (oldHandler) {
el.removeEventListener('click', oldHandler)
}
const newHandler = async () => {
if (value && value !== '') {
try {
await useClipboard().toClipboard(value)
Message.success('已成功复制到剪切板')
} catch(e) {
Message.error('复制失败')
}
} else {
throw new Error(`need for copy content! Like v-copy="Hello World"`)
}
}
el.addEventListener('click', newHandler)
handlerMap.set(el, newHandler)
}
export default {
mounted(el, binding) {
copy(el, binding)
},
updated(el, binding) {
copy(el, binding)
},
unmounted(el) {
const handler = handlerMap.get(el)
if (handler) {
el.removeEventListener('click', handler)
handlerMap.delete(el)
}
}
}

View File

@@ -0,0 +1,12 @@
import auth from './auth/index'
import role from './role/index'
import copy from './copy/index'
export default {
install (Vue) {
Vue.directive('auth', auth)
Vue.directive('role', role)
Vue.directive('copy', copy)
}
}

View File

@@ -0,0 +1,32 @@
import role from './role'
const checkRole = (el, binding) => {
const { value } = binding
if (Array.isArray(value)) {
if (value.length > 0) {
let isHas = false
value.map((item) => {
if (!isHas) {
isHas = role(item)
}
})
if (!isHas && el.parentNode) {
// el.parentNode.remove()
el.remove()
}
}
} else {
throw new Error(`need role! Like v-role="['seo', 'cfo']"`)
}
}
export default {
mounted(el, binding) {
checkRole(el, binding)
},
updated(el, binding) {
checkRole(el, binding)
}
}

View File

@@ -0,0 +1,8 @@
import { useUserStore } from '@/store'
const role = name => {
const userStore = useUserStore()
return (userStore.roles && userStore.roles.includes(name)) || (userStore.roles && userStore.roles.includes('superAdmin'))
}
export default role

View File

@@ -0,0 +1,3 @@
export default {
}

View File

@@ -0,0 +1,8 @@
export default {
loadingText: 'Loading...',
searchFileNotice: 'Search file by name',
searchResource: 'Search resource type',
saveNetworkImage: 'Save network image',
networkImageNotice: 'Please paste the web picture address',
ok: 'OK',
}

View File

@@ -0,0 +1,53 @@
export default {
// 特殊页
'openForm': 'CRUD',
// 首页菜单
'home': 'Home',
'dashboard': 'Dashboard',
'userCenter': 'User Center',
'message': 'Message Center',
'setting:config': 'System Setting',
'demo': 'Component Demo',
// 权限
'permission': 'Permission',
'system:user': 'User Manage',
'system:role': 'Role Manage',
'system:dept': 'Department Manage',
'system:menu': 'Menu Manage',
'system:post': 'Post Manage',
'dataCenter': 'Data Center',
'system:dict': 'Dictionary',
'system:attachment': 'Attached',
'system:dataMaintain': 'Table Maintenance',
'system:notice': 'Notice',
'apps': 'App Center',
'system:appGroup': 'App Group',
'system:app': 'App Manage',
'apis': 'Api Center',
'system:apiGroup': 'Api Group',
'system:api': 'Api Manage',
// 监控
'monitor': 'Monitor',
'system:monitor:server': 'Server Monitor',
'system:onlineUser': 'Online User',
'system:cache': 'Cache Monitor',
'system:monitor:rely': 'Reliance Monitor',
'logs': 'Logs Monitor',
'system:queueLog': 'Queue Logs',
'system:loginLog': 'Login Logs',
'system:operLog': 'Operation Logs',
'system:apiLog': 'Apis Logs',
// 工具
'devTools': 'Tools',
'setting:module': 'Module Manage',
'setting:code': 'Code Generator',
'setting:code:update': 'Edit the build information',
'setting:crontab': 'Crontab',
'setting:table': 'Table Designer',
'systemInterface': 'System Apis',
}

View File

@@ -0,0 +1,14 @@
export default {
mine: 'Mine',
classics: 'classics',
businessGray: 'Business gray',
city: 'City',
mineDesc: 'Predominantly pure white, Mine defaults to skin',
classicsDesc: 'Classic dark sidebar skin',
businessGrayDesc: 'Gray versatility and atmosphere, creating business and stability',
cityDesc: 'May there be a warmth in every angle of the city',
activated: 'Activated',
use: 'Use'
}

View File

@@ -0,0 +1,92 @@
export default {
pageSetting: 'Page Setting',
chinese: '简体中文',
english: 'English',
search: 'Search',
store: 'App Store',
fullScreen: 'Full Screen',
closeFullScreen: 'Close Full Screen',
changeSkin: 'Change Skin',
skin: 'Skin',
layouts: 'Layout',
language: 'Language',
dark: 'Dark Mode',
tag: 'Open Tags',
water: 'Watermark',
waterContent: 'Watermark content',
menuFold: 'Menu Fold',
menuWidth: 'Mene Width',
skinHelp: 'Set up background skins',
layoutsHelp: 'Set the background display',
languageHelp: 'Set the page language and the request background language',
darkHelp: 'Sets the page display mode',
tagHelp: 'Whether to enable multi-tab mode',
waterHelp: 'Whether to display the watermark',
menuFoldHelp: 'Whether the left menu of the system is collapsed',
menuWidthHelp: 'Sets the display width of the left menu',
saveToBackend: 'Save to backend',
backendSettingTitle: 'Backend setting',
systemPrimaryColor: 'System Primary Color',
personalizedConfig: 'Personalized configuration',
layout: {
classic: 'Classic',
columns: 'Columns',
banner: 'Banner',
mixed: 'Mixed',
},
userCenter: 'User Center',
clearCache: 'Clear Cache',
logout: 'Logout System',
logoutAlert: 'Exit prompt',
logoutMessage: 'Are you sure you want to sign out?',
operationMessage: {
message: 'Message',
notification: 'Notification',
todo: 'Todo',
},
goHome: 'Go Home',
notFoundPage: 'Exit tip Ah oh, the page visited was hijacked by the Martians...',
login: {
slogan: 'High-quality middle and back office management system out of the box',
title: 'Login System',
username: 'Username',
usernameNotice: 'Please enter the username',
password: 'Passoword',
passwordNotice: 'Please enter the password',
verifyCode: 'Please enter the verification code',
verifyCodeNotice: 'Please enter the correct verification code',
loginBtn: 'Login in',
otherLoginType: 'Other ways to sign in'
},
verifyCode: {
switch: 'Click Toggle verification code',
error: 'The verification code is incorrect',
notice: 'Please enter the verification code'
},
i18n: 'open multi-language',
i18nHelp: 'Whether to enable the multi-language feature',
ws: 'open websocket',
wsHelp: 'Whether to enable the websocket feature',
round: 'opend round',
roundHelp: 'Whether to enable the round feature',
animation: 'Animation',
animationHelp: 'Page transition animation effect',
animate: {
fade: 'The page fades out',
sliderLeft: 'The page fades to the left',
sliderRight:'The page fades to the right',
sliderDown:'The page fades to the down',
sliderUp:'The page fades to the up',
},
tags: {
refresh: 'Refresh',
fullscreen: 'Full screen',
closeRightTag: 'Close right tag',
closeLeftTag: 'Close left tag',
closeTag: 'Close current tag',
closeOtherTag: 'Close other tag',
},
noticeTitle: 'System Prompted',
save: 'Save',
cancel: 'Cancel',
}

View File

@@ -0,0 +1,8 @@
export default {
fileHashFail: 'Get file hash failed, please try again!',
sizeLimit: 'The file size exceeds the upload limit',
uploadFailed: 'File upload failed',
buttonText: 'Local upload',
clickUpload: 'Click upload',
uploadDesc: 'Drag the file here, or ',
}

View File

View File

@@ -0,0 +1,50 @@
import { createI18n } from 'vue-i18n'
import tool from '@/utils/tool'
const setting = tool.local.get('setting')
const getLanguage = () => {
const loadFile = () => {
if (setting.language === 'zh_CN') {
return import.meta.glob('./zh_CN/**/*.js', { eager:true })
} else if (setting.language === 'en') {
return import.meta.glob('./en/**/*.js', { eager:true })
}
}
const generateLanguage = (fileNames, fileContent, generateLanguages = {}) => {
const fileName = fileNames.shift()
if (fileNames.length > 0) {
if (typeof generateLanguages[fileName] == 'undefined') {
generateLanguages[fileName] = {}
}
generateLanguages[fileName] = generateLanguage(fileNames, fileContent, generateLanguages[fileName])
}else{
generateLanguages[fileName] = fileContent
}
return generateLanguages
}
const files = loadFile()
let messages = { [setting.language]: {} }
for (let path in files) {
const names = path.match(/([A-Za-z0-9_]+)/g)
//去除语言文件夹和文件后缀名
names.shift()
names.pop()
if (files[path].default) {
messages[setting.language] = generateLanguage(names, files[path].default, messages[setting.language])
}
}
return messages
}
const i18n = createI18n({
locale: setting.language,
legacy: false,
globalInjection: true,
fallbackLocale: 'zh_CN',
messages: getLanguage()
})
export default i18n

View File

@@ -0,0 +1,3 @@
export default {
}

View File

@@ -0,0 +1,8 @@
export default {
loadingText: '数据加载中...',
searchFileNotice: '文件名搜索',
searchResource: '搜索资源类型',
saveNetworkImage: '保存网络图片',
networkImageNotice: '请粘贴网络图片地址',
ok: '确定'
}

View File

@@ -0,0 +1,53 @@
export default {
// 特殊页
'openForm': '公共表单',
// 首页菜单
'home': '首页',
'dashboard': '仪表盘',
'userCenter': '个人中心',
'message': '消息中心',
'setting:config': '系统配置',
'demo': '组件演示',
// 权限
'permission': '权限',
'system:user': '用户管理',
'system:role': '角色管理',
'system:dept': '部门管理',
'system:menu': '菜单管理',
'system:post': '岗位管理',
'dataCenter': '数据',
'system:dict': '数据字典',
'system:attachment': '附件管理',
'system:dataMaintain': '数据表维护',
'system:notice': '系统公告',
'apps': '应用中心',
'system:appGroup': '应用分组',
'system:app': '应用管理',
'apis': '应用接口',
'system:apiGroup': '接口分组',
'system:api': '接口管理',
// 监控
'monitor': '监控',
'system:monitor:server': '服务监控',
'system:onlineUser': '在线用户',
'system:cache': '缓存监控',
'system:monitor:rely': '依赖监控',
'logs': '日志监控',
'system:queueLog': '队列日志',
'system:loginLog': '登录日志',
'system:operLog': '操作日志',
'system:apiLog': '接口日志',
// 工具
'devTools': '工具',
'setting:module': '模块管理',
'setting:code': '代码生成器',
'setting:code:update': '编辑生成信息',
'setting:crontab': '定时任务',
'setting:table': '数据表设计器',
'systemInterface': '系统接口',
}

View File

@@ -0,0 +1,14 @@
export default {
mine: 'Mine',
classics: '经典',
businessGray: '商务灰',
city: '城市',
mineDesc: '以纯净的白色为主Mine默认皮肤',
classicsDesc: '经典的深色侧边栏皮肤',
businessGrayDesc: '灰色的百搭与大气,营造商务与稳重',
cityDesc: '愿城市每一个角度,都有一份温馨',
activated: '已激活',
use: '使用'
}

View File

@@ -0,0 +1,92 @@
export default {
pageSetting: '页面设置',
chinese: '简体中文',
english: 'English',
search: '搜索',
store: '应用市场',
fullScreen: '全屏',
closeFullScreen: '关闭全屏',
changeSkin: '换肤',
skin: '当前皮肤',
layouts: '布局',
language: '语言',
dark: '黑夜模式',
tag: '多标签',
water: '水印',
waterContent: '水印内容',
menuFold: '菜单折叠',
menuWidth: '菜单宽度',
skinHelp: '设置后台皮肤',
layoutsHelp: '设置后台显示方式',
languageHelp: '设置页面语言和请求后台语言',
darkHelp: '设置页面显示模式',
tagHelp: '是否启用多标签方式',
waterHelp: '是否显示水印',
menuFoldHelp: '系统左侧菜单是否折叠起来',
menuWidthHelp: '设置左侧菜单的显示宽度',
saveToBackend: '保存到后台',
backendSettingTitle: '后台设置',
systemPrimaryColor: '系统主色调',
personalizedConfig: '个性化配置 ',
layout: {
classic: '经典',
columns: '分栏',
banner: '通栏',
mixed: '混合',
},
userCenter: '个人中心',
clearCache: '清除缓存',
logout: '退出系统',
logoutAlert: '退出提示',
logoutMessage: '确定要退出登录吗?',
operationMessage: {
message: '消息',
notification: '通知',
todo: '待办',
},
goHome: '回到首页',
notFoundPage: '啊哦,访问的页面被火星人劫走了...',
login: {
slogan: '开箱即用的高质量中后台管理系统',
title: '登录',
username: '账户',
usernameNotice: '请输入账户',
password: '密码',
passwordNotice: '请输入密码',
verifyCode: '请输入验证码',
verifyCodeNotice: '请输入正确的验证码',
loginBtn: '登录',
otherLoginType: '其他登录方式'
},
verifyCode: {
switch: '点击切换验证码',
error: '验证码错误',
notice: '请输入验证码'
},
i18n: '开启多语言',
i18nHelp: '是否开启多语言功能',
ws: '开启Ws',
wsHelp: '是否开启Websocket连接',
round: '圆角',
roundHelp: '是否开启圆角',
animation: '切换动画',
animationHelp: '工作区页面切换的进场和出场动画效果',
animate: {
fade: '页面渐隐渐出',
sliderLeft: '页面向左渐出',
sliderRight:'页面向右渐出',
sliderDown:'页面向下渐出',
sliderUp:'页面向上渐出',
},
tags: {
refresh: '刷新',
fullscreen: '全屏',
closeRightTag: '关闭右侧标签',
closeLeftTag: '关闭左侧标签',
closeTag: '关闭当前标签',
closeOtherTag: '关闭其他标签',
},
noticeTitle: '系统提示',
save: '保存',
cancel: '取消',
}

View File

@@ -0,0 +1,8 @@
export default {
fileHashFail: '获取文件Hash失败请重试',
sizeLimit: '文件大小超过了限制',
uploadFailed: '文件上传失败',
buttonText: '本地上传',
clickUpload: '点击上传',
uploadDesc: '将文件拖到此处,或',
}

View File

@@ -0,0 +1,4 @@
export default {
name: '菜单管理',
'system:cache': '系统缓存'
}

View File

@@ -0,0 +1,19 @@
<template>
<div class="page max-7xl mx-auto text-center">
<div class="bg mx-auto">
<img src="@/assets/404.svg" />
<div class="mt-2">{{ $t('sys.notFoundPage') }}</div>
</div>
<div class="mt-5"><a-button type="primary" @click="$router.push({ name: 'dashboard' })">{{ $t('sys.goHome') }}</a-button></div>
</div>
</template>
<style scoped lang="less">
.page {
position: absolute; top: 50%; left: 50%; margin-top: -200px; margin-left: -195px;
}
.bg, .bg img{
width: 390px;
}
</style>

View File

@@ -0,0 +1,68 @@
<template>
<a-layout class="layout flex flex-col h-full">
<a-layout-header class="ma-ui-header flex justify-between h-50 layout-banner-header operation-area">
<div class="flex justify-between md:justify-center logo">
<a-avatar class="mt-1 ml-2 md:ml-0" :size="40">
<img src="../../../assets/logo.png" class="bg-white" />
</a-avatar>
<span class="ml-2 text-xl mt-2.5 hidden md:block">{{ $title }}</span>
</div>
<div class="flex justify-between w-full layout-banner">
<children-banner v-model="userStore.routers" />
<ma-operation />
</div>
</a-layout-header>
<ma-tags class="hidden lg:flex ma-ui-tags" />
<ma-worker-area />
</a-layout>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { useAppStore, useUserStore } from '@/store'
import { useRoute } from 'vue-router'
import MaOperation from '../ma-operation.vue'
import MaWorkerArea from '../ma-workerArea.vue'
import MaTags from '../ma-tags.vue'
import ChildrenBanner from '../components/children-banner.vue'
const route = useRoute()
const MaMenuRef = ref(null)
const userStore = useUserStore()
const appStore = useAppStore()
const actives = ref([])
onMounted(() => {
actives.value = [route.name]
})
watch(
() => route,
(v) => {
actives.value = [v.name]
},
{ deep: true }
)
</script>
<style scoped lang="less">
.tags {
margin-top: -1px;
}
:deep(.arco-menu-collapse-button) {
right: 10px;
}
:deep(.layout-banner .arco-menu-horizontal .arco-menu-inner) {
align-items: none;
padding: 8px 10px;
overflow-y: hidden;
}
:deep(.sys-menus .arco-menu-icon svg) {
display: inline;
margin-bottom: -1px;
}
:deep(.sys-menus .arco-menu-icon .iconify-icon) {
margin-bottom: 2px;
}
</style>

View File

@@ -0,0 +1,21 @@
<template>
<a-layout class="layout flex justify-between h-full">
<ma-classic-slider class="ma-ui-slider" />
<a-layout-content class="flex flex-col">
<ma-classic-header class="ma-ui-header" />
<ma-worker-area />
</a-layout-content>
</a-layout>
</template>
<script setup>
import { ref } from 'vue'
import MaClassicSlider from './ma-classic-slider.vue'
import MaClassicHeader from './ma-classic-header.vue'
import MaWorkerArea from '../ma-workerArea.vue'
</script>

View File

@@ -0,0 +1,18 @@
<template>
<a-layout-header class="layout-classic-header flex flex-col operation-area">
<div class="flex justify-between layout-classic-header-container">
<a-avatar class="mt-1 ml-2 inline lg:hidden" style="width: 45px" :size="40">
<img src="../../../assets/logo.png" class="bg-white" />
</a-avatar>
<ma-breadcrumb />
<ma-operation />
</div>
<ma-tags class="hidden lg:flex" />
</a-layout-header>
</template>
<script setup>
import MaBreadcrumb from '../ma-breadcrumb.vue'
import MaOperation from '../ma-operation.vue'
import MaTags from '../ma-tags.vue'
</script>

View File

@@ -0,0 +1,36 @@
<template>
<a-layout-sider
class="layout-classic-sider h-full flex flex-col hidden lg:block"
:style="`width: ${appStore.menuCollapse ? '48px' : appStore.menuWidth + 'px'};`">
<div class="flex justify-center logo">
<a-avatar class="mt-1" :size="40">
<img src="../../../assets/logo.png" class="bg-white" />
</a-avatar>
<span class="ml-2 text-xl mt-2.5" v-if="!appStore.menuCollapse">{{ $title }}</span>
</div>
<ma-menu ref="MaMenuRef" height="calc(100% - 51px)" :class="`${appStore.menuCollapse ? 'ml-1.5' : ''};`" />
</a-layout-sider>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useAppStore, useUserStore } from '@/store'
import MaMenu from '../ma-menu.vue'
const MaMenuRef = ref(null)
const userStore = useUserStore()
const appStore = useAppStore()
onMounted(() => {
setTimeout((_) => {
MaMenuRef.value.menus = userStore.routers
}, 50)
})
</script>
<style>
.logo {
height: 51px;
border-bottom: 1px solid var(--color-border-1);
}
</style>

View File

@@ -0,0 +1,39 @@
<template>
<a-layout-content class="layout flex justify-between">
<div id="layout-columns-left-panel" class="ma-ui-menu layout-columns-left-panel hidden lg:flex justify-between">
<ma-columns-menu />
</div>
<div class="layout-columns-right-panel flex flex-col" :style="`width: calc(100% - ${containerWidth}px)`" >
<ma-columns-header class="ma-ui-header" />
<ma-worker-area />
</div>
</a-layout-content>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import ResizeObserver from 'resize-observer-polyfill'
import MaColumnsHeader from './ma-columns-header.vue'
import MaColumnsMenu from './ma-columns-menu.vue'
import MaWorkerArea from '../ma-workerArea.vue'
const containerWidth = ref(0)
onMounted(() => {
const dom = document.getElementById('layout-columns-left-panel')
const robserver = new ResizeObserver( entries => {
for (const entry of entries) {
// 可以通过 判断 entry.target得知当前改变的 Element分别进行处理。
switch(entry.target){
case dom :
containerWidth.value = entry.contentRect.width
break
}
}
})
robserver.observe(dom)
})
</script>

View File

@@ -0,0 +1,18 @@
<template>
<a-layout-header class="layout-header flex flex-col operation-area">
<div class="flex justify-between" style="height: 50px">
<a-avatar class="mt-1 ml-2 inline lg:hidden" style="width: 45px" :size="40">
<img src="../../../assets/logo.png" class="bg-white" />
</a-avatar>
<ma-breadcrumb />
<ma-operation />
</div>
<ma-tags class="hidden lg:flex" />
</a-layout-header>
</template>
<script setup>
import MaBreadcrumb from '../ma-breadcrumb.vue'
import MaOperation from '../ma-operation.vue'
import MaTags from '../ma-tags.vue'
</script>

View File

@@ -0,0 +1,120 @@
<template>
<div class="sider customer-scrollbar flex flex-col items-center bg-gray-800 dark:border-blackgray-5">
<a-avatar class="mt-2" :size="40">
<img src="../../../assets/logo.png" class="bg-white" />
</a-avatar>
<ul class="mt-1 parent-menu-container">
<template v-for="(bigMenu, index) in userStore.routers" :key="index">
<li :class="`${classStyle}`" @click="loadMenu(bigMenu, index)">
<sa-icon v-if="bigMenu.meta.icon" :icon="bigMenu.meta.icon" class="mt-1" />
<span class="mt-0.5" :style="appStore.language === 'en' ? 'font-size: 10px' : ''">{{
appStore.i18n
? $t(`menus.${bigMenu.name}`).indexOf('.') > 0
? bigMenu.meta.title
: $t(`menus.${bigMenu.name}`)
: bigMenu.meta.title
}}</span>
</li>
</template>
</ul>
</div>
<div class="layout-menu shadow flex flex-col" v-show="showMenu">
<div class="menu-title flex items-center" v-show="!appStore.menuCollapse">{{ title }}</div>
<a-layout-sider
:style="`width: ${appStore.menuCollapse ? '50px' : appStore.menuWidth + 'px'};
height: ${appStore.menuCollapse ? '100%' : 'calc(100% - 51px)'};`">
<ma-menu ref="MaMenuRef" :class="appStore.menuCollapse ? 'ml-0.5' : ''" />
</a-layout-sider>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import MaMenu from '../ma-menu.vue'
import { useAppStore, useUserStore } from '@/store'
const route = useRoute()
const router = useRouter()
const MaMenuRef = ref(null)
const appStore = useAppStore()
const userStore = useUserStore()
const showMenu = ref(false)
const title = ref('')
const classStyle = ref(
'flex flex-col parent-menu items-center rounded mt-1 text-gray-200 hover:bg-gray-700 dark:hover:text-gray-50 dark:hover:bg-blackgray-1'
)
onMounted(() => {
initMenu()
})
watch(
() => route,
(v) => {
initMenu()
},
{ deep: true }
)
const initMenu = () => {
let current
if (route.matched[1]?.meta?.breadcrumb) {
current = route.matched[1].meta.breadcrumb[0].name
} else {
current = 'home'
}
if (userStore.routers && userStore.routers.length > 0) {
userStore.routers.map((item, index) => {
if (item.name == current) loadMenu(item, index)
})
}
}
const loadMenu = (bigMenu, index) => {
if (bigMenu.meta.type === 'L') {
window.open(bigMenu.path)
return
}
if (bigMenu.children && bigMenu.children.length > 0) {
MaMenuRef.value.loadChildMenu(bigMenu)
showMenu.value = true
} else {
showMenu.value = false
router.push(bigMenu.path)
}
title.value = MaMenuRef.value?.title
document.querySelectorAll('.parent-menu').forEach((item, id) => {
index !== id ? item.classList.remove('active') : item.classList.add('active')
})
}
</script>
<style>
.parent-menu-container {
width: 62px;
}
.parent-menu {
width: 62px;
padding: 5px;
height: 57px;
cursor: pointer;
font-size: 13px;
fill: #fff;
transition: all 0.2s;
}
.parent-menu.active {
background: rgb(var(--primary-6));
color: #fff;
}
:deep(.arco-menu-vertical .arco-menu-inner) {
padding: 4px;
}
:deep(.arco-menu-vertical .arco-menu-item) {
padding: 0px 9px;
line-height: 36px;
}
</style>

View File

@@ -0,0 +1,64 @@
<template>
<a-layout-content class="sys-menus">
<a-menu ref="MaMenuRef" mode="horizontal" class="layout-banner-menu hidden lg:flex" :popup-max-height="360"
:selected-keys="actives">
<template v-for="menu in modelValue" :key="menu.id">
<template v-if="!menu.meta.hidden">
<template v-if="! menu.children || menu.children.length === 0">
<!-- 没有子菜单的进入 -->
<a-menu-item :key="menu.name" @click="routerPush(menu)">
<template #icon v-if="menu.meta.icon">
<sa-icon :icon="menu.meta.icon" :size="18" />
</template>
{{ appStore.i18n ? ( $t(`menus.${menu.name}`).indexOf('.') > 0 ? menu.meta.title : $t(`menus.${menu.name}`) ) : menu.meta.title }}
</a-menu-item>
</template>
<!-- 有子菜单的进入 -->
<template v-else>
<SubMenu :menu-info="menu" />
</template>
</template>
</template>
</a-menu>
</a-layout-content>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { useTagStore, useAppStore } from '@/store'
import { useRouter, useRoute } from 'vue-router'
import SubMenu from './sub-menu.vue'
defineProps({ modelValue: Array })
const router = useRouter()
const emits = defineEmits(['go'])
const appStore = useAppStore()
const tagStore = useTagStore()
const route = useRoute()
const actives = ref([])
onMounted(() => {
actives.value = [route.name]
})
watch(() => route, v => {
actives.value = [v.name]
}, { deep: true })
const routerPush = (menu) => {
if (menu.meta && menu.meta.type === 'L') {
window.open(menu.path)
} else {
router.push(menu.path)
tagStore.addTag({ name: menu.name, title: menu.meta.title, path: menu.path })
}
}
</script>
<style>
.sys-menus .icon {
width: 1em;
height: 1em;
}
.arco-menu-selected .icon {
fill: rgb(var(--primary-6));
}
</style>

View File

@@ -0,0 +1,59 @@
<template>
<a-layout-content class="sys-menus">
<template v-for="menu in modelValue" :key="menu.id">
<template v-if="!menu.meta.hidden">
<a-menu-item
v-if="! menu.children || menu.children.length === 0"
:key="menu.name"
@click="routerPush(menu)"
>
<template #icon v-if="menu.meta.icon">
<sa-icon :icon="menu.meta.icon" :size="18" />
</template>
{{ appStore.i18n ? ( $t(`menus.${menu.name}`).indexOf('.') > 0 ? menu.meta.title : $t(`menus.${menu.name}`) ) : menu.meta.title }}
</a-menu-item>
<a-sub-menu v-else :key="menu.name">
<template #icon v-if="menu.meta.icon">
<sa-icon :icon="menu.meta.icon" :size="18" />
</template>
<template #title @click="routerPush(menu.path)">
{{ appStore.i18n ? ( $t(`menus.${menu.name}`).indexOf('.') > 0 ? menu.meta.title : $t(`menus.${menu.name}`) ) : menu.meta.title }}
</template>
<template v-if="menu.children">
<children-menu v-model="menu.children" />
</template>
</a-sub-menu>
</template>
</template>
</a-layout-content>
</template>
<script setup>
import { useTagStore, useAppStore } from '@/store'
import { useRouter } from 'vue-router'
defineProps({ modelValue: Array })
const router = useRouter()
const emits = defineEmits(['go'])
const appStore = useAppStore()
const tagStore = useTagStore()
const routerPush = (menu) => {
if (menu.meta && menu.meta.type === 'L') {
window.open(menu.path)
} else {
router.push(menu.path)
tagStore.addTag({ name: menu.name, title: menu.meta.title, path: menu.path })
}
}
</script>
<style>
.sys-menus .icon {
width: 1em; height: 1em;
}
.arco-menu-selected .icon {
fill: rgb(var(--primary-6));
}
</style>

View File

@@ -0,0 +1,38 @@
<template>
<div class="w-full h-full" v-show="$route.meta.type === 'I'">
<iframe
v-for="item in iframeStore.iframes"
v-show="item.meta.url === $route.meta.url"
:src="item.meta.url"
:key="item.name"
frameborder="0"
class="w-full h-full"
/>
</div>
</template>
<script setup>
import { watch } from 'vue'
import { useIframeStore } from '@/store'
import { useRoute } from 'vue-router'
const iframeStore = useIframeStore()
const route = useRoute()
watch(
() => route,
value => {
pushRoute(value)
},
{ deep: true }
)
const pushRoute = (r) => {
if (r.meta.type === 'I') {
iframeStore.addIframe(r)
}
}
pushRoute(route)
</script>

View File

@@ -0,0 +1,91 @@
<template>
<div class="mgs-nfc rounded p-2 shadow-lg">
<a-tabs default-active-key="message" type="rounded">
<a-tab-pane key="message">
<template #title>
{{ $t('sys.operationMessage.message') }}
<a-badge
:count="5"
dot
:dotStyle="{ width: '5px', height: '5px', top: '-8px' }"
v-if="messageStore.messageList.length > 0">
</a-badge>
</template>
<a-list :max-height="230" class="h-full" v-if="messageStore.messageList.length > 0">
<a-list-item
v-for="item in messageStore.messageList"
:key="item.id"
class="cursor-pointer"
@click="viewMessage(item)">
<a-list-item-meta :title="item.title">
<template #description>
<div class="flex justify-between" style="font-size: 13px">
<span>发送人{{ item.send_user.nickname }}</span>
<span>时间{{ item.create_time.substr(0, 10) }}</span>
</div>
</template>
<template #avatar>
<a-avatar shape="square">
<img alt="avatar" :src="`${item.send_user.avatar}` || avatar" />
</a-avatar>
</template>
</a-list-item-meta>
</a-list-item>
</a-list>
<a-empty v-else class="h-full" />
</a-tab-pane>
<a-tab-pane key="todo">
<template #title>{{ $t('sys.operationMessage.todo') }}</template>
<a-empty class="h-full" />
</a-tab-pane>
</a-tabs>
</div>
<a-modal v-model:visible="detailVisible" fullscreen :footer="false">
<template #title>消息详情</template>
<a-typography :style="{ marginTop: '-30px' }">
<a-typography-title class="text-center">
{{ row?.title }}
</a-typography-title>
<a-typography-paragraph class="text-right" style="font-size: 13px; color: var(--color-text-3)">
<a-space size="large">
<span>创建时间{{ row?.create_time }}</span>
</a-space>
</a-typography-paragraph>
<a-typography-paragraph>
<div v-html="row?.content"></div>
</a-typography-paragraph>
</a-typography>
</a-modal>
</template>
<script setup>
import { ref } from 'vue'
import { useMessageStore } from '@/store'
import avatar from '@/assets/avatar.jpg'
const messageStore = useMessageStore()
const row = ref({})
const detailVisible = ref(false)
const viewMessage = async (record) => {
row.value = record
detailVisible.value = true
}
</script>
<style scoped lang="less">
.mgs-nfc {
width: 400px;
background: var(--color-bg-1);
height: 360px;
border: 1px solid var(--color-border-1);
margin-right: 10px;
margin-top: 9px;
position: relative;
}
:deep(.arco-list-item-meta-content) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,4 @@
<template>
</template>

View File

@@ -0,0 +1,52 @@
<template>
<a-modal v-model:visible="visible" width="600px" @cancel="close" :footer="false">
<template #title>{{ $t('sys.changeSkin') }}</template>
<div class="flex flex-col">
<a-card
v-for="(item, index) in skinList"
:key="item.name"
:class="index === 0 ? '' : 'mt-3'"
:body-style="{ width: '100%', display: 'flex', justifyContent: 'space-between', padding: '10px' }">
<a-row class="w-full flex items-center">
<a-col :span="3" class="flex flex-col text-center">
<div class="leading-6">{{ $t(`skin.${item.name}`) }}</div>
</a-col>
<a-col :span="6" class="flex flex-col text-center">
<a-image :src="item.thumb" class="rounded border" />
</a-col>
<a-col :span="12" class="flex items-center pl-3 pr-3">
{{ $t(`skin.${item.name}Desc`) }}
</a-col>
<a-col :span="3" class="flex items-center justify-end">
<a-button
:type="appStore.skin === item.name ? 'primary' : 'secondary'"
:disabled="appStore.skin === item.name"
@click="useSkin(item.name)">
{{ appStore.skin === item.name ? $t('skin.activated') : $t('skin.use') }}
</a-button>
</a-col>
</a-row>
</a-card>
</div>
</a-modal>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useAppStore } from '@/store'
import skins from '@/config/skins'
const visible = ref(false)
const appStore = useAppStore()
const open = () => (visible.value = true)
const close = () => (visible.value = false)
const useSkin = (name) => appStore.useSkin(name)
const skinList = reactive(skins)
defineExpose({ open })
</script>
<style scoped lang="less"></style>

View File

@@ -0,0 +1,49 @@
<template>
<a-sub-menu :key="menuInfo.name">
<template #title>
{{ appStore.i18n ? ($t(`menus.${menuInfo.name}`).indexOf('.') > 0 ? menuInfo.meta.title : $t(`menus.${menuInfo.name}`)) : menuInfo.meta.title }}
</template>
<template #icon v-if="menuInfo.meta.icon">
<sa-icon :icon="menuInfo.meta.icon" :size="18" />
</template>
<template v-for="item in menuInfo.children" :key="item.id">
<template v-if="!item.children|| item.children.length === 0">
<a-menu-item :key="item.name" @click="routerPush(item)">
<template #icon v-if="item.meta.icon">
<sa-icon :icon="item.meta.icon" :size="18" />
</template>
{{ appStore.i18n ? ($t(`menus.${item.name}`).indexOf('.') > 0 ? item.meta.title : $t(`menus.${item.name}`)) : item.meta.title }}
</a-menu-item>
</template>
<template v-else>
<SubMenu :menu-info="item" />
</template>
</template>
</a-sub-menu>
</template>
<script setup name="SubMenu">
import { useRouter, useRoute } from 'vue-router'
import { useTagStore, useAppStore } from '@/store'
defineProps({ menuInfo: Object })
const emits = defineEmits(['go'])
const router = useRouter()
const tagStore = useTagStore()
const appStore = useAppStore()
const routerPush = (menu) => {
if (menu.meta && menu.meta.type === 'L') {
window.open(menu.path)
} else {
router.push(menu.path)
tagStore.addTag({ name: menu.name, title: menu.meta.title, path: menu.path })
}
}
</script>
<style scoped>
.arco-trigger-menu-icon .icon {
width: 1em; height: 1em;
}
[mine-skin="mine"] .arco-menu-selected .icon {
fill: rgb(var(--primary-6));
}
</style>

View File

@@ -0,0 +1,34 @@
<template>
<div class="ml-2 mt-3.5 hidden lg:block">
<a-breadcrumb>
<a-breadcrumb-item class="cursor-pointer" @click="router.push('/dashboard')">
<div class="flex items-center">
<icon-dashboard style="color:rgb(var(--color-text-1))" />
<span class="ml-1">{{ $t('menus.dashboard') }}</span>
</div>
</a-breadcrumb-item>
<template v-for="(r, index) in route.matched" :key="index">
<a-breadcrumb-item
v-if="index > 0 && !['/', '/home', '/dashboard'].includes(r.path)"
>
<div class="flex items-center">
<sa-icon :icon="r.meta.icon" :size="16" style="color:rgb(var(--color-text-1));width:1rem" />
<span class="ml-1">
{{ appStore.i18n ? ( $t('menus.' + r.name).indexOf('.') > 0 ? r.meta.title : $t('menus.' + r.name) ) : r.meta.title }}
</span>
</div>
</a-breadcrumb-item>
</template>
</a-breadcrumb>
</div>
</template>
<script setup>
import { useRoute, useRouter } from 'vue-router'
import { useAppStore } from '@/store'
const appStore = useAppStore()
const route = useRoute()
const router = useRouter()
</script>

View File

@@ -0,0 +1,35 @@
<template>
<div class="block lg:hidden button-menu">
<a-trigger
:trigger="['click']"
clickToClose
position="top"
v-model:popupVisible="popupVisible"
>
<div :class="`button-trigger ${popupVisible ? 'button-trigger-active' : ''}`">
<icon-close v-if="popupVisible" />
<icon-menu v-else />
</div>
<template #content>
<a-menu mode="popButton" showCollapseButton :popup-max-height="360">
<children-menu v-model="userStore.routers" />
</a-menu>
</template>
</a-trigger>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useAppStore, useUserStore } from '@/store'
import ChildrenMenu from './components/children-menu.vue'
const userStore = useUserStore()
const popupVisible = ref(false)
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,102 @@
<template>
<a-menu
class="ma-menu"
:style="{ width: appStore.menuWidth + 'px', height: props.height }"
breakpoint="md"
v-model:open-keys="openKeys"
v-model:selected-keys="actives"
:accordion="true"
:collapsed-width="45"
show-collapse-button
:collapsed="appStore.menuCollapse"
@collapse="onCollapse"
:popup-max-height="360"
auto-scroll-into-view
>
<children-menu v-model="menus" />
</a-menu>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import ChildrenMenu from './components/children-menu.vue'
import { useAppStore, useUserStore } from '@/store'
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const appStore = useAppStore()
const userStore = useUserStore()
const menus = ref([])
const actives = ref([])
const openKeys = ref([])
const title = ref('')
onMounted(() => {
actives.value = [ route.name ]
findTopMenuName()
})
watch(() => route, v => {
actives.value = [ v.name ]
findTopMenuName()
}, { deep: true })
const loadChildMenu = (obj) => {
if (obj.children && obj.children.length > 0) {
menus.value = obj.children
if (! appStore.i18n) {
title.value = obj.meta.title
} else {
title.value = t('menus.' + obj.name).indexOf('.') > 0 ? obj.meta.title : t('menus.' + obj.name)
}
}
}
const findTopMenuName = () => {
if (route.matched[1] && route.matched[1].meta && ! route.matched[1].meta.breadcrumb) {
openKeys.value = []
route.matched.map((item, index) => {
if (route.matched[0].name === 'layout') {
openKeys.value.push('home')
}
})
} else {
openKeys.value = []
if (route.matched[1] && route.matched[1].meta) {
route.matched[1].meta.breadcrumb.map(item => {
openKeys.value.push(item.name)
})
}
}
}
const onCollapse = (val) => {
appStore.toggleMenu(val)
}
const props = defineProps({
height: { type: String, default: '100%' }
})
defineExpose({ loadChildMenu, title, actives, menus, openKeys, findTopMenuName })
</script>
<style scoped>
:deep(.arco-menu-vertical .arco-menu-inner) {
padding: 0;
}
:deep(.arco-menu-collapse-button) {
right: 8px; bottom: 8px;
}
:deep(.arco-menu-inner ::-webkit-scrollbar-thumb) {
border: 4px solid transparent;
background-clip: padding-box;
border-radius: 7px;
background-color: var(--color-text-4)
}
</style>

View File

@@ -0,0 +1,173 @@
<template>
<div class="mr-2 flex justify-end lg:justify-between w-full lg:w-auto">
<a-space class="mr-0 lg:mr-5" size="medium">
<a-tooltip :content="$t('sys.store')" v-if="isDev">
<a-button :shape="'circle'" class="hidden lg:inline" @click="handleAppStore">
<template #icon>
<icon-apps :size="16" :rotate="45" />
</template>
</a-button>
</a-tooltip>
<a-tooltip :content="$t('sys.search')">
<a-button :shape="'circle'" @click="() => (appStore.searchOpen = true)" class="hidden lg:inline">
<template #icon>
<icon-search />
</template>
</a-button>
</a-tooltip>
<!-- <a-tooltip content="锁屏">-->
<!-- <a-button :shape="'circle'" class="hidden lg:inline">-->
<!-- <template #icon>-->
<!-- <icon-lock />-->
<!-- </template>-->
<!-- </a-button>-->
<!-- </a-tooltip>-->
<a-tooltip :content="isFullScreen ? $t('sys.closeFullScreen') : $t('sys.fullScreen')">
<a-button :shape="'circle'" class="hidden lg:inline" @click="screen">
<template #icon>
<icon-fullscreen-exit v-if="isFullScreen" />
<icon-fullscreen v-else />
</template>
</a-button>
</a-tooltip>
<a-trigger trigger="click">
<a-button :shape="'circle'">
<template #icon>
<a-badge
:count="5"
dot
:dotStyle="{ width: '5px', height: '5px' }"
v-if="messageStore.messageList.length > 0">
<icon-notification />
</a-badge>
<icon-notification v-else />
</template>
</a-button>
<template #content>
<message-notification />
</template>
</a-trigger>
<a-tooltip :content="$t('sys.pageSetting')">
<a-button :shape="'circle'" @click="() => (appStore.settingOpen = true)" class="hidden lg:inline">
<template #icon>
<icon-settings />
</template>
</a-button>
</a-tooltip>
</a-space>
<a-dropdown @select="handleSelect" trigger="hover">
<a-avatar class="bg-blue-500 text-3xl avatar" style="top: -1px">
<img :src="userStore.user && userStore.user.avatar ? userStore.user.avatar : avatar" />
</a-avatar>
<template #content>
<a-doption value="userCenter"><icon-user /> {{ $t('sys.userCenter') }}</a-doption>
<a-doption value="clearCache"><icon-delete /> {{ $t('sys.clearCache') }}</a-doption>
<a-divider style="margin: 5px 0" />
<a-doption value="logout"><icon-poweroff /> {{ $t('sys.logout') }}</a-doption>
</template>
</a-dropdown>
<a-modal v-model:visible="showLogoutModal" @ok="handleLogout" @cancel="handleLogoutCancel">
<template #title>{{ $t('sys.logoutAlert') }}</template>
<div>{{ $t('sys.logoutMessage') }}</div>
</a-modal>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useAppStore, useUserStore, useMessageStore } from '@/store'
import tool from '@/utils/tool'
import MessageNotification from './components/message-notification.vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
import { Push } from '@/utils/push-vue'
import { info } from '@/utils/common'
import commonApi from '@/api/common'
import avatar from '@/assets/avatar.jpg'
const { t } = useI18n()
const messageStore = useMessageStore()
const userStore = useUserStore()
const appStore = useAppStore()
const setting = ref(null)
const router = useRouter()
const isFullScreen = ref(false)
const showLogoutModal = ref(false)
const isDev = ref(import.meta.env.DEV)
const handleSelect = async (name) => {
if (name === 'userCenter') {
router.push({ name: 'userCenter' })
}
if (name === 'clearCache') {
const res = await commonApi.clearAllCache()
tool.local.remove('dictData')
res.code === 200 && Message.success(res.message)
}
if (name === 'logout') {
showLogoutModal.value = true
document.querySelector('#app').style.filter = 'grayscale(1)'
}
}
const handleAppStore = async () => {
window.open('https://saas.saithink.top/#/appStore')
}
const handleLogout = async () => {
await userStore.logout()
document.querySelector('#app').style.filter = 'grayscale(0)'
router.push({ name: 'login' })
}
const handleLogoutCancel = () => {
document.querySelector('#app').style.filter = 'grayscale(0)'
}
const screen = () => {
tool.screen(document.documentElement)
isFullScreen.value = !isFullScreen.value
}
if (appStore.ws) {
const env = import.meta.env
const baseURL = env.VITE_APP_OPEN_PROXY === 'true' ? env.VITE_APP_PROXY_PREFIX : env.VITE_APP_BASE_URL
const wsURL = env.VITE_APP_WS_URL ? env.VITE_APP_WS_URL : ''
const appKey = env.VITE_APP_WS_APPKEY ? env.VITE_APP_WS_APPKEY : ''
// 建立连接
var connection = new Push({
url: wsURL, // websocket地址
app_key: appKey, // appkey
auth: baseURL + '/plugin/webman/push/auth',
})
// 创建监听频道
var user_channel = connection.subscribe('saiadmin')
// 当saiadmin频道有message事件的消息时
user_channel.on('message', function (message) {
// message是消息内容
info('新消息提示', '您有新的消息,请注意查收!')
messageStore.messageList = message.data
})
}
</script>
<style scoped>
:deep(.arco-avatar-text) {
top: 1px;
}
:deep(.arco-divider-horizontal) {
margin: 5px 0;
}
.avatar {
cursor: pointer;
margin-top: 6px;
}
</style>

View File

@@ -0,0 +1,478 @@
<template>
<div class="flex justify-between tags-container" ref="containerDom" v-if="appStore.tag">
<div class="menu-tags-wrapper" ref="scrollbarDom" :class="{ 'tag-pn': tagShowPrevNext }">
<div class="tags" ref="tags">
<div v-for="tag in tagStore.tags" :key="tag.path" @contextmenu.prevent="openContextMenu($event, tag)"
:class="route.fullPath == tag.path ? 'active' : ''"
@click="tagJump(tag)">
<span>
{{ tag.customTitle ? tag.customTitle : appStore.i18n ? ($t('menus.' + tag.name).indexOf('.') > 0 ? tag.title : $t('menus.' + tag.name)) : tag.title }}
</span>
<icon-close class="tag-icon" v-if="!tag.affix" @click.stop="closeTag(tag)" />
</div>
</div>
<span class="ma-tag-prev" v-if="tagShowPrevNext">
<IconLeft :size="20" class="tag-scroll-icon" @click="handleScroll(-500)" />
</span>
<span class="ma-tag-next" v-if="tagShowPrevNext">
<IconRight :size="20" class="tag-scroll-icon" @click="handleScroll(500)" />
</span>
</div>
<a-trigger class="ma-tags-more-dropdown" :popup-translate="[-65, -6]" :show-arrow="true" trigger="hover">
<span class="ma-tags-more">
<span class="ma-tags-more-icon">
<i class="ma-box ma-box-t"></i>
<i class="ma-box ma-box-b"></i>
</span>
</span>
<template #content>
<ul class="ma-tags-more-contextmenu">
<li @click="tagToolRefreshTag">
<icon-refresh />
{{ $t('sys.tags.refresh') }}
</li>
<a-divider class="dropdown-divider" />
<li @click="tagToolCloseCurrentTag">
<icon-close-circle />
{{ $t('sys.tags.closeTag') }}
</li>
<li @click="tagToolCloseOtherTag">
<icon-close-circle-fill />
{{ $t('sys.tags.closeOtherTag') }}
</li>
</ul>
</template>
</a-trigger>
<ul class="tags-contextmenu" v-if="contextMenuVisible" :style="{ left: left + 'px', top: top + 'px' }">
<li @click="contextMenuRefreshTag">
<icon-refresh />
{{ $t('sys.tags.refresh') }}
</li>
<li @click="contextMenuMaxSizeTag">
<icon-fullscreen />
{{ $t('sys.tags.fullscreen') }}
</li>
<a-divider />
<li @click="contextMenuCloseRightTag">
<icon-arrow-right />
{{ $t('sys.tags.closeRightTag') }}
</li>
<li @click="contextMenuCloseLeftTag">
<icon-arrow-left />
{{ $t('sys.tags.closeLeftTag') }}
</li>
<li @click="contextMenuCloseTag" :class="contextMenuItem.affix ? 'disabled' : ''">
<icon-close-circle />
{{ $t('sys.tags.closeTag') }}
</li>
<li @click="contextMenuCloseOtherTag">
<icon-close-circle-fill />
{{ $t('sys.tags.closeOtherTag') }}
</li>
</ul>
</div>
</template>
<script setup>
import { ref, watch, onMounted, nextTick } from 'vue'
import { useAppStore, useTagStore } from '@/store'
import { useRoute, useRouter } from 'vue-router'
import { addTag, closeTag, refreshTag } from '@/utils/common'
import Sortable from "sortablejs"
import { Message } from '@arco-design/web-vue'
import { IconFaceFrownFill } from '@arco-design/web-vue/dist/arco-vue-icon'
import tool from '@/utils/tool'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const tagStore = useTagStore()
const tags = ref(null)
const tagShowPrevNext = ref(false)
const contextMenuVisible = ref(false)
const contextMenuItem = ref(null)
const left = ref(0)
const top = ref(0)
const notAddTagList = [ 'login' ]
watch(
() => appStore.tag,
r => {
nextTick(() => {
if ( (tags.value.scrollWidth ?? false) && tags.value.offsetWidth ) {
tagShowPrevNext.value = tags.value.scrollWidth > tags.value.offsetWidth
}
})
},
{ deep: true }
)
watch(
() => tagStore.tags,
r => {
nextTick(() => {
if ( (tags.value.scrollWidth ?? false) && tags.value.offsetWidth ) {
tagShowPrevNext.value = tags.value.scrollWidth > tags.value.offsetWidth
}
})
},
{ deep: true }
)
watch(
() => route,
r => {
if (!notAddTagList.includes(r.name)) {
addTag({
name: r.name,
path: r.fullPath,
affix: r.meta.affix,
title: r.meta.title
})
}
nextTick(() => {
if ( tags.value && tags.value.scrollWidth > tags.value.clientWidth ) {
//确保当前标签在可视范围内
tags.value.querySelector('.active').scrollIntoView()
}
})
}, { deep: true }
)
watch(
contextMenuVisible,
value => {
const handler = (e) => {
const dom = document.querySelector('.tags-contextmenu')
if (dom && !dom.contains(e.target)) {
closeContextMenu()
}
}
value
? document.body.addEventListener("click", e => handler(e))
: document.body.removeEventListener("click", e => handler(e))
}
)
const tagJump = tag => {
router.push({ path: tag.path, query: tool.getRequestParams(tag.path) })
}
const openContextMenu = (e, tag) => {
contextMenuItem.value = tag
contextMenuVisible.value = true
left.value = e.clientX + 1
top.value = e.clientY + 1
nextTick(() => {
const dom = document.querySelector('.tags-contextmenu')
if (document.body.offsetWidth - e.clientX < dom.offsetWidth) {
left.value = document.body.offsetWidth - dom.offsetWidth + 1;
top.value = e.clientY + 1;
}
})
}
const closeContextMenu = () => {
contextMenuItem.value = null
contextMenuVisible.value = false
}
const contextMenuMaxSizeTag = () => {
const tag = contextMenuItem.value
contextMenuVisible.value = false
if (route.fullPath != tag.fullPath) {
router.push({ path: tag.path, query: tool.getRequestParams(tag.path) })
}
document.getElementById('app').classList.add('max-size')
}
const contextMenuRefreshTag = () => {
const tag = contextMenuItem.value
contextMenuVisible.value = false
if (route.fullPath != tag.fullPath) {
router.push({ path: tag.path, query: tool.getRequestParams(tag.path) })
}
refreshTag()
}
const tagToolRefreshTag = () => {
refreshTag()
}
const tagToolCloseCurrentTag = () => {
const list = [...tagStore.tags]
list.forEach(tag => {
if (tag.affix || route.path == tag.path) {
closeTag(tag)
}
})
}
const contextMenuCloseTag = () => {
if (!contextMenuItem.value.affix) {
closeTag(contextMenuItem.value)
contextMenuVisible.value = false
}
}
const contextMenuCloseRightTag = () => {
const currentTag = contextMenuItem.value
if (route.path != currentTag.path) {
router.push({ path: currentTag.path })
}
const list = [...tagStore.tags]
let index = null
list.forEach((tag, idx) => {
if (currentTag.path == tag.path) {
index = idx
}
})
list.forEach((tag, idx) => {
if (tag.affix || currentTag.path == tag.path) {
return true
} else {
idx > index && closeTag(tag)
}
})
contextMenuVisible.value = false
}
const contextMenuCloseLeftTag = () => {
const currentTag = contextMenuItem.value
if (route.path != currentTag.path) {
router.push({ path: currentTag.path })
}
const list = [...tagStore.tags]
let index = null
list.forEach((tag, idx) => {
if (currentTag.path == tag.path) {
index = idx
}
})
list.forEach((tag, idx) => {
if (tag.affix || currentTag.path == tag.path) {
return true
} else {
idx < index && closeTag(tag)
}
})
contextMenuVisible.value = false
}
const tagToolCloseOtherTag = () => {
const list = [...tagStore.tags]
list.forEach(tag => {
if (tag.affix || route.path == tag.path) {
return true
} else {
closeTag(tag)
}
})
contextMenuVisible.value = false
}
const contextMenuCloseOtherTag = () => {
const currentTag = contextMenuItem.value
if (route.path != currentTag.path) {
router.push({ path: currentTag.path })
}
const list = [...tagStore.tags]
list.forEach(tag => {
if (tag.affix || currentTag.path == tag.path) {
return true
} else {
closeTag(tag)
}
})
contextMenuVisible.value = false
}
const scrollHandler = event => {
const detail = event.wheelDelta || event.detail;
const moveForwardStep = 1;
const moveBackStep = -1;
let step = 0;
if (detail == 3 || (detail < 0 && detail != -3)) {
step = moveForwardStep * 50;
} else {
step = moveBackStep * 50;
}
tags.value.scrollLeft += step;
}
const handleScroll = (offset) => {
const distance = tags.value.scrollLeft
const total = distance + offset
const step = offset / 50
moveSlow(distance, total, step)
};
const moveSlow = (distance, total, step) => {
if (step > 0) {
//往左滚
if (distance < total) {
distance += step
tags.value.scrollLeft = distance
window.requestAnimationFrame(() => {
moveSlow(distance, total, step)
})
} else {
tags.value.scrollLeft = total;
}
} else {
//往右滚
if (distance > total) {
distance += step
tags.value.scrollLeft = distance
window.requestAnimationFrame(() => {
moveSlow(distance, total, step)
})
} else {
tags.value.scrollLeft = total;
}
}
}
onMounted(() => {
if (tags.value) {
Sortable.create(tags.value, { draggable: 'a', animation: 300 })
tags.value.addEventListener("mousewheel", scrollHandler, { passive: true }) ||
tags.value.addEventListener("DOMMouseScroll", scrollHandler, { passive: true })
nextTick(() => {
tagShowPrevNext.value = tags.value.scrollWidth > tags.value.offsetWidth
})
}
})
</script>
<style scoped lang="less">
.tag-pn {
padding: 0 15px;
margin: 0 5px;
}
.menu-tags-wrapper {
box-sizing: border-box;
overflow: hidden;
position: relative;
display: inline-flex;
.ma-tag-next,
.ma-tag-prev {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
height: 40px;
.tag-scroll-icon {
cursor: pointer;
color: var(--color-text-3);
;
}
.tag-scroll-icon:hover {
color: rgb(var(--primary-6));
}
}
.ma-tag-prev {
left: -4px;
}
.ma-tag-next {
right: -4px;
}
}
.ma-tags-more {
position: relative;
box-sizing: border-box;
display: flex;
text-align: left;
justify-content: center;
align-items: center;
margin-right: 15px;
margin-left: 5px;
top: -1px;
.ma-tags-more-icon {
display: inline-block;
color: var(--color-text-2);
cursor: pointer;
transition: transform .3s ease-out;
.ma-box {
position: relative;
display: block;
width: 14px;
height: 8px;
.ma-box-t:before {
transition: transform .3s ease-out .3s;
}
}
.ma-box:before {
position: absolute;
top: 2px;
left: 0;
width: 6px;
height: 6px;
content: "";
background: var(--color-text-3);
}
.ma-box:after {
position: absolute;
top: 2px;
left: 8px;
width: 6px;
height: 6px;
content: "";
background: var(--color-text-3);
}
}
}
.ma-tags-more:hover .ma-tags-more-icon .ma-box:before {
background: rgb(var(--primary-6));
transform: rotate(45deg);
}
.ma-tags-more:hover .ma-tags-more-icon .ma-box:after {
background: rgb(var(--primary-6));
}
.ma-tags-more:hover .ma-tags-more-icon {
transform: rotate(90deg);
}
.dropdown-divider {
margin: 0;
}
.ma-tags-more-contextmenu {
border: 1px solid var(--color-border-2);
padding: 5px 0;
z-index: 100;
width: 170px;
background-color: var(--color-bg-5);
border-radius: 4px;
li {
padding: 7px 15px;
color: var(--color-text-2);
font-size: 13px;
}
li:hover {
background-color: rgb(var(--primary-1));
cursor: pointer;
}
.arco-divider-horizontal {
margin: 5px 0;
}
}
</style>

View File

@@ -0,0 +1,23 @@
<template>
<a-layout-content class="work-area customer-scrollbar relative">
<div class="h-full" :class="{ 'p-3': $route.path.indexOf('maIframe') === -1 }">
<a-watermark :content="appStore.waterMark ? appStore.waterContent : ''">
<router-view v-slot="{ Component }">
<transition :name="appStore.animation" mode="out-in">
<keep-alive :include="keepStore.keepAlives">
<component :is="Component" :key="$route.fullPath" v-if="keepStore.show" />
</keep-alive>
</transition>
</router-view>
</a-watermark>
<iframe-view />
</div>
</a-layout-content>
</template>
<script setup>
import { useAppStore, useKeepAliveStore } from '@/store'
import IframeView from './components/iframe-view.vue'
const appStore = useAppStore()
const keepStore = useKeepAliveStore()
</script>

View File

@@ -0,0 +1,132 @@
<template>
<a-layout class="layout flex flex-col h-full">
<a-layout-header class="ma-ui-header flex justify-between h-50 layout-banner-header operation-area">
<div class="flex justify-between md:justify-center logo">
<a-avatar class="mt-1 ml-2 md:ml-0" :size="40">
<img src="../../../assets/logo.png" class="bg-white" />
</a-avatar>
<span class="ml-2 text-xl mt-2.5 hidden md:block">{{ $title }}</span>
</div>
<div class="flex justify-between w-full layout-banner">
<top-menu v-model="userStore.routers" :active="active" @go="loadMenu" ref="topMenuRef" />
<ma-operation />
</div>
</a-layout-header>
<div class="flex" :style="`height:calc(100% - ${appStore.tag ? '87px' : '52px'}); `">
<a-layout-sider
id="layout-mixed-left-panel"
class="layout-classic-sider h-full flex flex-col hidden lg:block"
:style="`width: ${appStore.menuCollapse ? '48px' : appStore.menuWidth + 'px'};`"
v-show="showMenu">
<ma-menu ref="MaMenuRef" height="100%" :class="`${appStore.menuCollapse ? 'ml-1.5' : ''};`" />
</a-layout-sider>
<div class="w-full" :style="`width: calc(100% - ${containerWidth}px)`">
<ma-tags class="hidden lg:flex ma-ui-tags" />
<ma-worker-area />
</div>
</div>
</a-layout>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { useAppStore, useUserStore } from '@/store'
import { useRoute, useRouter } from 'vue-router'
import ResizeObserver from 'resize-observer-polyfill'
import MaOperation from '../ma-operation.vue'
import MaWorkerArea from '../ma-workerArea.vue'
import MaTags from '../ma-tags.vue'
import MaMenu from '../ma-menu.vue'
import topMenu from './top-menu.vue'
const route = useRoute()
const router = useRouter()
const topMenuRef = ref(null)
const MaMenuRef = ref(null)
const userStore = useUserStore()
const appStore = useAppStore()
const showMenu = ref(false)
const active = ref()
onMounted(() => {
initMenu()
})
watch(
() => route,
(v) => {
initMenu()
},
{ deep: true }
)
const initMenu = () => {
if (route.matched[1]?.meta?.breadcrumb) {
active.value = route.matched[1].meta.breadcrumb[0].name
} else {
active.value = 'home'
}
if (userStore.routers && userStore.routers.length > 0) {
userStore.routers.map((item, index) => {
if (item.name == active.value) loadMenu(item)
})
}
}
const loadMenu = (bigMenu) => {
if (bigMenu.meta.type === 'L') {
window.open(bigMenu.path)
return
}
if (bigMenu.children && bigMenu.children.length > 0) {
MaMenuRef.value.loadChildMenu(bigMenu)
showMenu.value = true
} else {
showMenu.value = false
router.push(bigMenu.path)
}
topMenuRef.value.updateActive(bigMenu.name)
}
const containerWidth = ref(0)
onMounted(() => {
const dom = document.getElementById('layout-mixed-left-panel')
const robserver = new ResizeObserver((entries) => {
for (const entry of entries) {
// 可以通过 判断 entry.target得知当前改变的 Element分别进行处理。
switch (entry.target) {
case dom:
containerWidth.value = entry.contentRect.width
break
}
}
})
robserver.observe(dom)
})
</script>
<style scoped lang="less">
.tags-container {
border-top: 0;
}
:deep(.tags-container .tags) {
border-bottom: 0 !important;
}
:deep(.arco-menu-collapse-button) {
right: 10px;
}
:deep(.layout-banner .arco-menu-horizontal .arco-menu-inner) {
align-items: none;
padding: 8px 10px;
overflow-y: hidden;
}
:deep(.sys-menus .arco-menu-icon svg) {
display: inline;
margin-bottom: -1px;
}
:deep(.sys-menus .arco-menu-icon .iconify-icon) {
margin-bottom: 2px;
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<a-layout-content class="sys-menus">
<a-menu ref="MaMenuRef" mode="horizontal" class="layout-banner-menu hidden lg:flex" :popup-max-height="360" :selected-keys="actives">
<template v-for="menu in props.modelValue" :key="menu.id">
<template v-if="!menu.meta.hidden">
<a-menu-item :key="menu.name" @click="routerPush(menu)">
<template #icon v-if="menu.meta.icon">
<sa-icon :icon="menu.meta.icon" :size="18" />
</template>
{{ appStore.i18n ? ( $t(`menus.${menu.name}`).indexOf('.') > 0 ? menu.meta.title : $t(`menus.${menu.name}`) ) : menu.meta.title }}
</a-menu-item>
</template>
</template>
</a-menu>
</a-layout-content>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { useTagStore, useAppStore } from '@/store'
import { useRouter, useRoute } from 'vue-router'
const props = defineProps({ modelValue: Array, active: String })
const router = useRouter()
const emits = defineEmits(['go'])
const appStore = useAppStore()
const tagStore = useTagStore()
const route = useRoute()
const actives = ref([ props.active ?? '' ])
watch(() => props.active, value => actives.value = [value])
const routerPush = (menu) => {
actives.value = [menu.name]
emits('go', menu)
}
const updateActive = (active) => actives.value = [active]
defineExpose({ updateActive })
</script>
<style>
.sys-menus .icon {
width: 1em;
height: 1em;
}
.arco-menu-selected .icon {
fill: rgb(var(--primary-6));
}
</style>

View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

View File

@@ -0,0 +1,66 @@
<template>
<a-layout-content class="h-full main-container">
<columns-layout v-if="appStore.layout === 'columns'" />
<classic-layout v-if="appStore.layout === 'classic'" />
<banner-layout v-if="appStore.layout === 'banner'" />
<mixed-layout v-if="appStore.layout === 'mixed'" />
<setting ref="settingRef"/>
<transition name="ma-slide-down" mode="out-in">
<system-search ref="systemSearchRef" v-show="appStore.searchOpen" />
</transition>
<ma-button-menu />
<div class="max-size-exit" @click="tagExitMaxSize"><icon-close /></div>
</a-layout-content>
</template>
<script setup>
import { onMounted, ref, watch } from 'vue'
import { useAppStore, useUserStore } from '@/store'
import ColumnsLayout from './components/columns/index.vue'
import ClassicLayout from './components/classic/index.vue'
import BannerLayout from './components/banner/index.vue'
import MixedLayout from './components/mixed/index.vue'
import Setting from './setting.vue'
import SystemSearch from './search.vue'
import MaButtonMenu from './components/ma-buttonMenu.vue'
const appStore = useAppStore()
const userStore = useUserStore()
const settingRef = ref()
const systemSearchRef = ref()
watch(() => appStore.settingOpen, vl => {
if (vl === true) {
settingRef.value.open()
appStore.settingOpen = false
}
})
const tagExitMaxSize = () => {
document.getElementById('app').classList.remove('max-size')
}
onMounted(() => {
document.addEventListener('keydown', e => {
const keyCode = e.keyCode ?? e.which ?? e.charCode
const altKey = e.altKey ?? e.metaKey
if(altKey && keyCode === 83) {
appStore.searchOpen = true
return
}
if (keyCode === 27) {
appStore.searchOpen = false
return
}
})
})
</script>
<style scoped lang="less">
</style>

View File

@@ -0,0 +1,241 @@
<template>
<div class="sys-search-container">
<div class="ssc-bg">
<div class="w-6/12 mx-auto center-box">
<div class="mt-10"><img src="../assets/logo.png" width="100" class="mx-auto" /></div>
<div class="mt-10">
<a-input
size="large"
ref="searchInputRef"
placeholder="搜索页面支持名称、标识以及URL的模糊查询"
@input="searchPage">
<template #prefix><icon-search /></template>
</a-input>
</div>
<div class="mt-5">
<a-space size="large" class="flex justify-center">
<a-space><a-tag>ALT+S</a-tag><a-tag>唤醒搜索面板</a-tag></a-space>
<a-space>
<a-tag><icon-caret-up /></a-tag>
<a-tag><icon-caret-down /></a-tag>
<a-tag>切换搜索结果</a-tag>
</a-space>
<a-space><a-tag>Enter</a-tag><a-tag>进入页面</a-tag></a-space>
<a-space><a-tag>Esc</a-tag><a-tag>关闭搜索面板</a-tag></a-space>
</a-space>
</div>
<ul class="mt-10 results shadow-lg customer-scrollbar">
<template v-for="res in resultList">
<li
class="flex items-center"
v-if="res && res.path.indexOf(':') === -1 && res.components && res?.meta?.type === 'M'"
@click="gotoPage(res)">
<div class="icon-box flex justify-center items-center">
<component
v-if="res.meta.icon"
:is="res.meta.icon"
:class="res.meta.icon.indexOf('ma') > 0 ? 'icon' : ''" />
<icon-menu v-else />
</div>
<div class="ml-5 leading-6">
<div class="title">{{ res.meta.title }}</div>
<div class="path">{{ res.path }}</div>
</div>
</li>
</template>
</ul>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/store'
const appStore = useAppStore()
const router = useRouter()
const searchInputRef = ref()
const resultList = ref(router.getRoutes())
const searchPage = (value) => {
resultList.value = router.getRoutes().filter((item) => {
if (item.path && item.path.indexOf(value) > -1) {
return true
}
if (item.name && item.name.indexOf(value) > -1) {
return true
}
if (item.meta && item.meta.title && item.meta.title.indexOf(value) > -1) {
return true
}
return false
})
}
const gotoPage = (res) => {
appStore.searchOpen = false
router.push(res.path)
}
onMounted(() => {
searchInputRef.value.focus()
document.addEventListener('keydown', (e) => {
const keyCode = e.keyCode ?? e.which ?? e.charCode
const active = document.querySelector('.active-search-li')
const getActiveItemInfo = () => {
const li = document.querySelectorAll('.results li')
let activeItem = { idx: 0, path: '/' }
li.forEach((item, index) => {
if (item.className.split(' ').includes('active-search-li')) {
activeItem.path = item.querySelector('.path').innerHTML
activeItem.idx = index
return
}
})
return activeItem
}
const add = (index) => {
document.querySelectorAll('.results li')[index].classList.add('active-search-li')
}
const remove = (index) => {
document.querySelectorAll('.results li')[index].classList.remove('active-search-li')
}
if (appStore.searchOpen) {
// down
if (keyCode === 40) {
if (!active) {
add(0)
return
} else {
const li = document.querySelectorAll('.results li')
let item = getActiveItemInfo(),
nextIndex = item.idx + 1
if (nextIndex >= li.length) {
nextIndex = 0
}
remove(item.idx)
add(nextIndex)
}
}
// up
if (keyCode === 38) {
if (!active) {
add(document.querySelectorAll('.results li').length - 1)
return
} else {
const li = document.querySelectorAll('.results li')
let item = getActiveItemInfo(),
prevIndex = item.idx - 1
if (prevIndex < 0) {
prevIndex = li.length - 1
}
remove(item.idx)
add(prevIndex)
}
}
if (keyCode === 13) {
const item = getActiveItemInfo()
remove(item.idx)
item.path !== '/' && gotoPage(item)
}
}
nextTick(() => {
const dom = document.querySelector('.results')
if (dom && dom.scrollTop !== false) {
dom.scrollTop = (getActiveItemInfo()['idx'] + 1) * 80 - document.querySelectorAll('.results li').length * 10
}
})
})
})
</script>
<style scoped lang="less">
.sys-search-container {
top: 0;
left: 0;
position: absolute;
z-index: 999;
width: 100%;
height: 100%;
overflow: hidden;
& .ssc-bg {
position: absolute;
z-index: 999;
width: 100%;
height: 100%;
top: 0;
left: 0;
background-color: rgba(100, 100, 100, 0.2);
backdrop-filter: blur(12px);
}
& .center-box {
height: 90%;
}
& .results {
background-color: var(--color-bg-2);
border-radius: 6px;
height: calc(100% - 250px);
overflow-y: auto;
& li {
border-bottom: 1px solid var(--color-border-1);
cursor: pointer;
.title {
font-size: 16px;
}
.path {
color: var(--color-text-3);
}
}
li:hover,
.active-search-li {
background-color: var(--color-neutral-1);
.arco-icon,
.icon {
transition: all 0.25s;
width: 1.8em !important;
height: 1.8em !important;
}
.arco-icon {
color: rgb(var(--primary-6));
}
.icon {
fill: rgb(var(--primary-6));
}
.title {
color: rgb(var(--primary-6));
}
.path {
color: rgb(var(--primary-3));
}
}
.icon-box {
width: 80px;
height: 80px;
border-right: 1px solid var(--color-border-1);
}
}
.arco-icon,
.icon {
width: 1.5em !important;
height: 1.5em !important;
}
.arco-menu-selected .icon {
fill: rgb(var(--primary-6));
}
}
</style>

View File

@@ -0,0 +1,204 @@
<template>
<a-drawer
class="backend-setting"
v-model:visible="visible"
:on-before-ok="save"
width="450px"
:ok-text="$t('sys.saveToBackend')"
@cancel="close"
unmountOnClose>
<template #title>{{ $t('sys.backendSettingTitle') }}</template>
<a-form :model="form" :auto-label-width="true">
<a-row class="flex justify-center mb-5">
<a-divider orientation="center"
><span class="title">{{ $t('sys.systemPrimaryColor') }}</span></a-divider
>
<ColorPicker
theme="dark"
:color="appStore.color"
:sucker-hide="true"
:colors-default="defaultColorList"
@changeColor="changeColor"
style="width: 218px" />
</a-row>
<a-divider orientation="center"
><span class="title">{{ $t('sys.personalizedConfig') }}</span></a-divider
>
<a-form-item :label="$t('sys.skin')" :help="$t('sys.skinHelp')">
{{ currentSkin }}
<a-button type="primary" size="mini" class="ml-2" @click="skin.open()">
{{ $t('sys.changeSkin') }}
</a-button>
</a-form-item>
<a-form-item :label="$t('sys.layouts')" :help="$t('sys.layoutsHelp')">
<a-select v-model="form.layout" @change="handleLayout">
<a-option value="classic">{{ $t('sys.layout.classic') }}</a-option>
<a-option value="columns">{{ $t('sys.layout.columns') }}</a-option>
<a-option v-if="appStore.skin !== 'classics'" value="banner">{{ $t('sys.layout.banner') }}</a-option>
<a-option v-if="appStore.skin !== 'classics'" value="mixed">{{ $t('sys.layout.mixed') }}</a-option>
</a-select>
</a-form-item>
<a-form-item :label="$t('sys.round')" :help="$t('sys.roundHelp')">
<a-switch v-model="form.roundOpen" @change="handleRound" />
</a-form-item>
<a-form-item :label="$t('sys.ws')" :help="$t('sys.wsHelp')">
<a-switch v-model="form.ws" @change="handleWs" />
</a-form-item>
<a-form-item :label="$t('sys.i18n')" :help="$t('sys.i18nHelp')">
<a-switch v-model="form.i18n" @change="handleI18n" />
</a-form-item>
<a-form-item :label="$t('sys.language')" :help="$t('sys.languageHelp')" v-if="form.i18n">
<a-select v-model="form.language" @change="handleLanguage">
<a-option value="zh_CN">{{ $t('sys.chinese') }}</a-option>
<a-option value="en">{{ $t('sys.english') }}</a-option>
</a-select>
</a-form-item>
<a-form-item :label="$t('sys.animation')" :help="$t('sys.animationHelp')">
<a-select v-model="form.animation" @change="handleAnimation">
<a-option value="ma-fade">{{ $t('sys.animate.fade') }}</a-option>
<a-option value="ma-slide-left">{{ $t('sys.animate.sliderLeft') }}</a-option>
<a-option value="ma-slide-right">{{ $t('sys.animate.sliderRight') }}</a-option>
<a-option value="ma-slide-down">{{ $t('sys.animate.sliderDown') }}</a-option>
<a-option value="ma-slide-up">{{ $t('sys.animate.sliderUp') }}</a-option>
</a-select>
</a-form-item>
<a-form-item :label="$t('sys.dark')" :help="$t('sys.darkHelp')" v-if="currentSkin === 'Mine'">
<a-switch v-model="form.mode" @change="handleSettingMode" />
</a-form-item>
<a-form-item :label="$t('sys.water')" :help="$t('sys.waterHelp')">
<a-switch v-model="form.waterMark" @change="handleSettingWater" />
</a-form-item>
<a-form-item :label="$t('sys.waterContent')" v-if="form.waterMark">
<a-input v-model="form.waterContent" @blur="handleSettingWaterContent" />
</a-form-item>
<a-form-item :label="$t('sys.tag')" :help="$t('sys.tagHelp')">
<a-switch v-model="form.tag" @change="handleSettingTag" />
</a-form-item>
<a-form-item v-if="form.layout !== 'banner'" :label="$t('sys.menuFold')" :help="$t('sys.menuFoldHelp')">
<a-switch v-model="form.menuCollapse" @change="handleMenuCollapse" />
</a-form-item>
<a-form-item v-if="form.layout !== 'banner'" :label="$t('sys.menuWidth')" :help="$t('sys.menuWidthHelp')">
<a-input-number v-model="form.menuWidth" mode="button" @change="handleMenuWidth" />
</a-form-item>
</a-form>
</a-drawer>
<Skin ref="skin" />
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { useAppStore, useUserStore } from '@/store'
import { Message } from '@arco-design/web-vue'
import user from '@/api/system/user'
import Skin from './components/components/skin.vue'
import skins from '@/config/skins'
import { useI18n } from 'vue-i18n'
import { ColorPicker } from 'vue-color-kit'
import 'vue-color-kit/dist/vue-color-kit.css'
const userStore = useUserStore()
const appStore = useAppStore()
const { t } = useI18n()
const skin = ref(null)
const visible = ref(false)
const currentSkin = ref('')
const form = reactive({
mode: appStore.mode === 'dark',
tag: appStore.tag,
menuCollapse: appStore.menuCollapse,
menuWidth: appStore.menuWidth,
layout: appStore.layout,
language: appStore.language,
animation: appStore.animation,
i18n: appStore.i18n,
waterMark: appStore.waterMark,
waterContent: appStore.waterContent,
ws: appStore.ws,
roundOpen: appStore.roundOpen,
})
const defaultColorList = reactive([
'#165DFF',
'#7166f0',
'#e84a6c',
'#efbd48',
'#0bd092',
'#bb1b1b',
'#0d9496',
'#18181b',
'#0960be',
'#4e69fd',
'#f5319d',
'#c1420b',
'#43a047',
'#f53f3f',
'#344256',
'#3f3f46',
])
const changeColor = (color) => {
appStore.changeColor(color.hex)
}
skins.map((item) => {
if (item.name === appStore.skin) currentSkin.value = t('skin.' + item.name)
})
watch(
() => appStore.skin,
(v) => {
skins.map((item) => {
if (item.name === v) currentSkin.value = t('skin.' + item.name)
})
}
)
const open = () => (visible.value = true)
const close = () => (visible.value = false)
const handleLayout = (val) => appStore.changeLayout(val)
const handleI18n = (val) => appStore.toggleI18n(val)
const handleWs = (val) => appStore.toggleWs(val)
const handleRound = (val) => appStore.toggleRound(val)
const handleLanguage = (val) => appStore.changeLanguage(val)
const handleAnimation = (val) => appStore.changeAnimation(val)
const handleSettingMode = (val) => appStore.toggleMode(val ? 'dark' : 'light')
const handleSettingWater = (val) => appStore.toggleWater(val)
const handleSettingWaterContent = (val) => appStore.changeWaterContent(val)
const handleSettingTag = (val) => appStore.toggleTag(val)
const handleMenuCollapse = (val) => appStore.toggleMenu(val)
const handleMenuWidth = (val) => appStore.changeMenuWidth(val)
watch(
() => appStore.menuCollapse,
(val) => (form.menuCollapse = val)
)
const save = async (done) => {
const data = {
mode: appStore.mode,
tag: appStore.tag,
menuCollapse: appStore.menuCollapse,
menuWidth: appStore.menuWidth,
layout: appStore.layout,
skin: appStore.skin,
i18n: appStore.i18n,
language: appStore.language,
animation: appStore.animation,
color: appStore.color,
waterMark: appStore.waterMark,
waterContent: appStore.waterContent,
ws: appStore.ws,
roundOpen: appStore.roundOpen,
}
user.updateInfo({ id: userStore.user.id, backend_setting: data }).then((res) => {
res.code === 200 && Message.success(res.message)
})
done(true)
}
defineExpose({ open })
</script>

46
saiadmin-vue/src/main.js Normal file
View File

@@ -0,0 +1,46 @@
import { createApp } from 'vue'
import ArcoVue from '@arco-design/web-vue'
import ArcoVueIcon from '@arco-design/web-vue/es/icon'
import globalComponents from '@/components'
import App from './App.vue'
import router from './router'
import store from './store'
import i18n from '@/i18n'
import directives from './directives'
import dayjs from 'dayjs'
import zhCn from 'dayjs/locale/zh-cn'
import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.locale(zhCn)
dayjs.extend(relativeTime)
// 官方样式
import '@arco-design/web-vue/dist/arco.css'
import './style/skin.less'
import './style/index.css'
import './style/global.less'
import tool from '@/utils/tool'
import * as common from '@/utils/common'
import packageJson from '../package.json'
const app = createApp(App)
app
.use(ArcoVue, {})
.use(ArcoVueIcon)
.use(router)
.use(store)
.use(i18n)
.use(directives)
.use(globalComponents)
app.config.globalProperties.$tool = tool
app.config.globalProperties.$common = common
app.config.globalProperties.$title = import.meta.env.VITE_APP_TITLE
app.config.globalProperties.$url = import.meta.env.VITE_APP_BASE
app.mount('#app')
tool.capsule('SaiAdmin', `v${packageJson.version} release`)
console.log('SaiAdmin 官网 https://saithink.top')

View File

@@ -0,0 +1,8 @@
export default {
install: (Vue) => {
const pluginList = import.meta.glob('./*/main.js')
Object.keys(pluginList).forEach((path) => {
pluginList[path]().then(plugin => Vue.use(plugin.default || plugin))
})
}
}

View File

@@ -0,0 +1,40 @@
const homePageRoutes = [
{
name: 'dashboard',
path: '/dashboard',
meta: {
title: '仪表盘',
icon: 'icon-dashboard',
type: 'M',
affix: true
},
component: () => import('@/views/dashboard/index.vue')
},
{
name: 'userCenter',
path: '/usercenter',
meta: {
title: '个人信息',
icon: 'icon-user',
type: 'M'
},
component: () => import('@/views/dashboard/userCenter/index.vue')
},
{
name: 'appStore',
path: 'https://saas.saithink.top/#/appStore',
meta: {
title: '插件市场',
icon: 'icon-apps',
type: 'L'
}
}
]
export const homePage = {
name: 'home',
path: '/home',
meta: { title: '首页', icon: 'icon-home', hidden: false, type: 'M' }
}
export default homePageRoutes

View File

@@ -0,0 +1,57 @@
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
import { useUserStore } from '@/store'
import NProgress from 'nprogress'
import tool from '@/utils/tool'
import 'nprogress/nprogress.css'
import routes from './webRouter.js'
const title = import.meta.env.VITE_APP_TITLE
const defaultRoutePath = '/'
const whiteRoute = ['login']
const router = createRouter({
history: createWebHashHistory(),
routes
})
router.beforeEach(async (to, from, next) => {
NProgress.start()
const userStore = useUserStore()
let toTitle = to.meta.title ? to.meta.title : to.name
document.title = `${toTitle} - ${title}`
const token = tool.local.get(import.meta.env.VITE_APP_TOKEN_PREFIX)
// 登录状态下
if (token) {
if (to.name === 'login') {
next({ path: defaultRoutePath })
return
}
if (! userStore.user && userStore.user == undefined ) {
const data = await userStore.requestUserInfo()
data && next({ path: to.path, query: to.query })
} else {
next()
}
} else {
// 未登录的情况下允许访问的路由
if (! whiteRoute.includes(to.name)) {
next({ name: 'login', query: { redirect: to.fullPath } })
} else {
next()
}
}
})
router.afterEach((to, from) => {
NProgress.done()
})
router.onError(error => {
NProgress.done();
});
export default router

View File

@@ -0,0 +1,25 @@
import homePageRoutes from './homePageRoutes'
//系统路由
const routes = [
{
name: 'layout',
path: '/',
component: () => import('@/layout/index.vue'),
redirect: 'dashboard',
children: homePageRoutes
},
{
name: 'login',
path: '/login',
component: () => import('@/views/login.vue'),
meta: { title: '登录' }
},
{
path: '/:pathMatch(.*)*',
hidden: true,
meta: { title: '访问的页面不存在' },
component: () => import('@/layout/404.vue')
}
]
export default routes

View File

@@ -0,0 +1,25 @@
import { createPinia } from 'pinia'
import useUserStore from './modules/user'
import useAppStore from './modules/app'
import useTagStore from './modules/tag'
import useKeepAliveStore from './modules/keepAlive'
import useIframeStore from './modules/iframe'
import useConfigStore from './modules/config'
import useMessageStore from './modules/message'
import useDictStore from './modules/dict'
import useTerminalStore from './modules/terminal'
const pinia = createPinia()
export {
useUserStore,
useAppStore,
useTagStore,
useKeepAliveStore,
useIframeStore,
useConfigStore,
useMessageStore,
useDictStore,
useTerminalStore
}
export default pinia

View File

@@ -0,0 +1,160 @@
let defaultSetting = {
mode: 'light',
tag: true,
menuCollapse: false,
menuWidth: 230,
layout: 'classic',
skin: 'mine',
i18n: false,
language: 'zh_CN',
animation: 'ma-slide-down',
color: '#7166F0',
settingOpen: false,
searchOpen: false,
roundOpen: true,
waterMark: true,
waterContent: 'saiadmin',
ws: false,
registerWangEditorButtonFlag: false
}
import { defineStore } from 'pinia'
import tool from '@/utils/tool'
import { generate, getRgbStr } from '@arco-design/color'
if (!tool.local.get('setting')) {
tool.local.set('setting', defaultSetting)
} else {
defaultSetting = tool.local.get('setting')
}
document.body.setAttribute('arco-theme', defaultSetting.mode)
document.body.setAttribute('mine-skin', defaultSetting.skin)
const useAppStore = defineStore('app', {
state: () => ({ ...defaultSetting }),
getters: {
appCurrentSetting() {
return { ...this.$state }
}
},
actions: {
updateSettings(partial) {
this.$patch(partial)
},
toggleMode(dark) {
this.mode = dark
document.getElementsByTagName('html')[0].className = this.mode
document.body.setAttribute('arco-theme', this.mode)
defaultSetting.mode = this.mode
this.changeColor(this.color)
tool.local.set('setting', defaultSetting)
},
toggleMenu(status) {
this.menuCollapse = status
defaultSetting.menuCollapse = this.menuCollapse
tool.local.set('setting', defaultSetting)
},
toggleRound(status) {
this.roundOpen = status
if (this.roundOpen) {
document.body.style.setProperty(`--border-radius-small`, '4px')
document.body.style.setProperty(`--border-radius-medium`, '6px')
} else {
document.body.style.setProperty(`--border-radius-small`, '2px')
document.body.style.setProperty(`--border-radius-medium`, '4px')
}
defaultSetting.roundOpen = this.roundOpen
tool.local.set('setting', defaultSetting)
},
toggleWater(status) {
this.waterMark = status
defaultSetting.waterMark = this.waterMark
tool.local.set('setting', defaultSetting)
},
changeWaterContent(val) {
this.waterContent = val.target ? val.target.value : val
defaultSetting.waterContent = this.waterContent
tool.local.set('setting', defaultSetting)
},
toggleTag(status) {
this.tag = status
defaultSetting.tag = this.tag
tool.local.set('setting', defaultSetting)
},
toggleI18n(i18n) {
this.i18n = i18n
defaultSetting.i18n = this.i18n
tool.local.set('setting', defaultSetting)
},
toggleWs(val) {
this.ws = val
defaultSetting.ws = this.ws
tool.local.set('setting', defaultSetting)
},
changeMenuWidth(width) {
this.menuWidth = width
defaultSetting.menuWidth = this.menuWidth
tool.local.set('setting', defaultSetting)
},
changeLayout(layout) {
this.layout = layout
defaultSetting.layout = this.layout
tool.local.set('setting', defaultSetting)
},
changeLanguage(language) {
this.language = language
defaultSetting.language = this.language
tool.local.set('setting', defaultSetting)
window.location.reload()
},
changeColor(color) {
if (!/^#[0-9A-Za-z]{6}/.test(color)) return
this.color = color
const list = generate(this.color, {
list: true,
dark: this.mode === 'dark'
})
list.forEach((color, index) => {
const rgbStr = getRgbStr(color)
document.body.style.setProperty(`--primary-${index + 1}`, rgbStr)
document.body.style.setProperty(`--arcoblue-${index + 1}`, rgbStr)
})
defaultSetting.color = this.color
tool.local.set('setting', defaultSetting)
},
changeAnimation(name) {
this.animation = name
defaultSetting.animation = this.animation
tool.local.set('setting', defaultSetting)
},
useSkin(name) {
this.skin = name
defaultSetting.skin = this.skin
document.body.setAttribute('mine-skin', this.skin)
tool.local.set('setting', defaultSetting)
},
setRegisterWangEditorButtonFlag(value) {
this.registerWangEditorButtonFlag = value
}
}
})
export default useAppStore

View File

@@ -0,0 +1,30 @@
import { defineStore } from 'pinia'
let defaultConfig = {
site_name: 'SaiAdmin',
site_keywords: '',
site_desc: '',
site_record_number: '',
site_copyright: '',
site_storage_mode: '',
web_close: '',
}
const useConfigStore = defineStore('config', {
state: () => ({ ...defaultConfig }),
getters: {
appCurrentConfig() {
return { ...this.$state }
},
},
actions: {
updateSettings(partial) {
this.$patch(partial);
},
},
})
export default useConfigStore

View File

@@ -0,0 +1,27 @@
import { defineStore } from 'pinia'
import commonApi from '@/api/common'
// 定义字典store名称是dict
const useDictStore = defineStore('dict', {
// 字典数据是数组我们定义一个data来进行保存
state: () => ({ data: undefined }),
getters: {
// 获取store状态
getState() {
return { ...this.$state }
}
},
actions: {
//给字典数据赋值
setInfo(data) {
this.$patch(data)
},
// 初始化字典数据
async initData() {
const { data } = await commonApi.dictAll()
this.data = data
}
}
})
export default useDictStore

View File

@@ -0,0 +1,41 @@
import { defineStore } from 'pinia'
const useIframeStore = defineStore('iframe', {
state: () => ({
iframes: [],
name: null,
show: true
}),
getters: {
getState() {
return { ...this.$state }
},
},
actions: {
addIframe (component) {
if (! this.iframes.includes(component)) {
this.iframes.push(component)
}
},
removeIframe (component) {
const idx = this.iframes.indexOf(component)
if (idx !== -1) {
this.iframes.splice(idx, 1)
}
},
display () { this.show = true },
hidden () { this.show = false },
setName (name) { this.name = name },
clearIframe() { this.iframes = [] },
},
})
export default useIframeStore

View File

@@ -0,0 +1,46 @@
import { defineStore } from 'pinia'
const useKeepAliveStore = defineStore('keepAlive', {
state: () => ({
keepAlives: [],
show: true
}),
getters: {
getState() {
return { ...this.$state }
}
},
actions: {
addKeepAlive(component) {
if (component.path.indexOf('maIframe') > -1) {
return
}
if (!this.keepAlives.includes(component.name)) {
this.keepAlives.push(component.name)
}
},
removeKeepAlive(component) {
const idx = this.keepAlives.indexOf(component.name)
if (idx !== -1) {
this.keepAlives.splice(idx, 1)
}
},
display() {
this.show = true
},
hidden() {
this.show = false
},
clearKeepAlive() {
this.keepAlives = []
}
}
})
export default useKeepAliveStore

View File

@@ -0,0 +1,24 @@
import { defineStore } from 'pinia'
let defaultType = {
messageList: [],
}
const useMessageStore = defineStore('message', {
state: () => ({ ...defaultType }),
getters: {
getState() {
return { ...this.$state }
},
},
actions: {
updateMessage(partial) {
this.$patch(partial);
},
},
})
export default useMessageStore

View File

@@ -0,0 +1,71 @@
import { defineStore } from 'pinia'
import tool from '@/utils/tool'
const defaultTag = [ { name: 'dashboard', title: '仪表盘', path: '/dashboard', affix: true } ]
const useTagStore = defineStore('tag', {
state: () => ({
tags: (! tool.local.get('tags') || tool.local.get('tags').length === 0 ) ? defaultTag : tool.local.get('tags')
}),
getters: {
getState() {
return { ...this.$state }
},
},
actions: {
addTag(tag) {
const target = this.tags.find( item => item.path === tag.path )
if (! target && tag.path ) {
this.tags.push(tag)
}
this.updateTagsToLocal()
},
removeTag(tag) {
let index = 0
this.tags.map((item, idx) => {
if ( item.path === tag.path && ! item.affix ) {
if (this.tags[(idx + 1)]) {
index = idx
} else if ( idx > 0) {
index = idx - 1
}
this.tags.splice(idx, 1)
}
})
this.updateTagsToLocal()
return this.tags[index]
},
updateTag(tag) {
this.tags.map(item => {
if (item.path == tag.path) {
item = Object.assign(item, tag)
}
})
this.updateTagsToLocal()
},
updateTagTitle(path, title) {
this.tags.map(item => {
if (item.path == path) {
item.customTitle = title
}
})
this.updateTagsToLocal()
},
updateTagsToLocal() {
tool.local.set('tags', this.tags)
},
clearTags() {
this.tags = defaultTag
tool.local.set('tags', defaultTag)
},
},
})
export default useTagStore

View File

@@ -0,0 +1,216 @@
import { defineStore } from 'pinia'
import tool from '@/utils/tool'
import { Message } from '@arco-design/web-vue'
const buildTerminalUrl = (commandKey, uuid, extend) => {
const env = import.meta.env
const baseURL = env.VITE_APP_BASE_URL
const token = tool.local.get(env.VITE_APP_TOKEN_PREFIX)
const terminalUrl = '/app/saipackage/index/terminal'
return (
baseURL +
terminalUrl +
'?command=' +
commandKey +
'&uuid=' +
uuid +
'&extend=' +
extend +
'&token=' +
token
)
}
const useTerminalStore = defineStore('terminal', {
state: () => ({
show: false,
taskList: [],
npmRegistry: 'npm',
packageManager: 'yarn',
composerRegistry: 'composer'
}),
getters: {
getState() {
return { ...this.$state }
}
},
actions: {
setTaskStatus(idx, status) {
this.taskList[idx].status = status
this.setTaskShowMessage(idx, true)
},
addTaskMessage(idx, message) {
this.taskList[idx].message = this.taskList[idx].message.concat(message)
},
setTaskShowMessage(idx, val = !this.taskList[idx].showMessage) {
this.taskList[idx].showMessage = val
},
cleanTaskList() {
this.taskList = []
},
taskCompleted(idx) {
if (typeof this.taskList[idx].callback != 'function') return
const status = this.taskList[idx].status
if (status == 5 || status == 6) {
this.taskList[idx].callback(5)
} else if (status == 4) {
this.taskList[idx].callback(4)
}
},
findTaskIdxFromUuid(uuid) {
for (const key in this.taskList) {
if (this.taskList[key].uuid == uuid) {
return parseInt(key)
}
}
return false
},
findTaskIdxFromGuess(idx) {
if (!this.taskList[idx]) {
let taskKey = -1
for (const key in this.taskList) {
if (
this.taskList[key].status == 2 ||
this.taskList[key].status == 3
) {
taskKey = parseInt(key)
}
}
return taskKey === -1 ? false : taskKey
} else {
return idx
}
},
startEventSource(taskKey) {
const that = this
window.eventSource = new EventSource(
buildTerminalUrl(
that.taskList[taskKey].command,
that.taskList[taskKey].uuid,
that.taskList[taskKey].extend
)
)
window.eventSource.onmessage = function (e) {
const data = JSON.parse(e.data)
if (!data || !data.data) {
return
}
const taskIdx = that.findTaskIdxFromUuid(data.uuid)
if (taskIdx === false) {
return
}
if (data.data == 'exec-error') {
that.setTaskStatus(taskIdx, 5)
window.eventSource.close()
that.taskCompleted(taskIdx)
that.startTask()
} else if (data.data == 'exec-completed') {
window.eventSource.close()
if (that.taskList[taskIdx].status != 4) {
that.setTaskStatus(taskIdx, 5)
}
that.taskCompleted(taskIdx)
that.startTask()
} else if (data.data == 'connection-success') {
that.setTaskStatus(taskIdx, 3)
} else if (data.data == 'exec-success') {
that.setTaskStatus(taskIdx, 4)
} else {
that.addTaskMessage(taskIdx, data.data)
}
}
window.eventSource.onerror = function () {
window.eventSource.close()
const taskIdx = that.findTaskIdxFromGuess(taskKey)
if (taskIdx === false) return
that.setTaskStatus(taskIdx, 5)
that.taskCompleted(taskIdx)
}
},
addNodeTask(command, extend = '', callback = () => {}) {
command =
command +
'.' +
(this.packageManager == 'unknown' ? 'npm' : this.packageManager)
this.addTask(command, extend, callback)
},
addTask(command, extend = '', callback = () => {}) {
this.taskList = this.taskList.concat({
uuid: tool.uuid(),
createTime: tool.dateFormat(),
status: 1,
command: command,
message: [],
showMessage: false,
extend: extend,
callback: callback
})
// 检查是否有已经失败的任务
if (this.show === false) {
for (const key in this.taskList) {
if (
this.taskList[key].status == 5 ||
this.taskList[key].status == 6
) {
Message.warning({
content: '任务列表中存在失败的任务',
duration: 2000
})
break
}
}
}
this.startTask()
},
startTask() {
let taskKey = null
// 寻找可以开始执行的命令
for (const key in this.taskList) {
if (this.taskList[key].status == 1) {
taskKey = parseInt(key)
break
}
if (this.taskList[key].status == 2 || this.taskList[key].status == 3) {
break
}
if (this.taskList[key].status == 4) {
continue
}
if (this.taskList[key].status == 5 || this.taskList[key].status == 6) {
continue
}
}
if (taskKey !== null) {
this.setTaskStatus(taskKey, 2)
this.startEventSource(taskKey)
}
},
retryTask(idx) {
this.taskList[idx].message = []
this.setTaskStatus(idx, 1)
this.startTask()
},
delTask(idx) {
if (this.taskList[idx].status != 2 && this.taskList[idx].status != 3) {
this.taskList.splice(idx, 1)
}
}
},
persist: {
key: 'storeTerminal'
}
})
export default useTerminalStore

View File

@@ -0,0 +1,199 @@
import { defineStore } from 'pinia'
import loginApi from '@/api/login'
import tool from '@/utils/tool'
import router from '@/router'
import webRouter from '@/router/webRouter'
import { isUndefined } from 'lodash'
import { homePage } from '@/router/homePageRoutes'
import { useAppStore, useTagStore, useDictStore } from '@/store'
const useUserStore = defineStore('user', {
state: () => ({
codes: undefined,
roles: undefined,
routers: undefined,
user: undefined,
menus: undefined
}),
getters: {
getState() {
return { ...this.$state }
}
},
actions: {
setToken(token) {
tool.local.set(import.meta.env.VITE_APP_TOKEN_PREFIX, token)
},
getToken() {
return tool.local.get(import.meta.env.VITE_APP_TOKEN_PREFIX)
},
clearToken() {
tool.local.remove(import.meta.env.VITE_APP_TOKEN_PREFIX)
},
setInfo(data) {
this.$patch(data)
},
resetUserInfo() {
this.$reset()
},
setMenu(data) {
const routers = flatAsyncRoutes(filterAsyncRouter(data))
routers.map((item) => {
if (isUndefined(item.meta.layout)) {
router.addRoute('layout', item)
} else {
if (item.meta.layout) {
router.addRoute('layout', item)
} else {
router.addRoute(item)
}
}
})
},
requestUserInfo() {
return new Promise((resolve, reject) => {
loginApi.getInfo().then(async (response) => {
if (!response || !response.data) {
this.clearToken()
await router.push({ name: 'login' })
reject(false)
} else {
this.setInfo(response.data)
const dictStore = useDictStore()
await dictStore.initData()
homePage.children = webRouter[0].children
this.setMenu(this.routers)
this.routers = removeButtonMenu(this.routers)
this.routers.unshift(homePage)
await this.setApp()
resolve(response.data)
}
})
})
},
login(form) {
return loginApi
.login(form)
.then((r) => {
if (r.code === 200) {
this.setToken(r.data.access_token)
return true
} else {
return false
}
})
.catch((e) => {
console.error(e)
return false
})
},
async logout() {
// await loginApi.logout()
const tagStore = useTagStore()
tool.local.remove('tags')
tagStore.clearTags()
this.clearToken()
this.resetUserInfo()
},
async setApp() {
const appStore = useAppStore()
const setting =
typeof this.user.backend_setting === 'string'
? JSON.parse(this.user.backend_setting)
: this.user.backend_setting
appStore.toggleMode(setting?.mode ?? appStore.mode)
appStore.toggleMenu(setting?.menuCollapse ?? appStore.menuCollapse)
appStore.toggleTag(setting?.tag ?? appStore.tag)
appStore.toggleRound(setting?.roundOpen ?? appStore.roundOpen)
appStore.toggleWs(setting?.ws ?? appStore.ws)
appStore.changeMenuWidth(setting?.menuWidth ?? appStore.menuWidth)
appStore.changeLayout(setting?.layout ?? appStore.layout)
appStore.useSkin(setting?.skin ?? appStore.skin)
appStore.changeColor(setting?.color ?? appStore.color)
appStore.toggleWater(setting?.waterMark ?? appStore.waterMark)
appStore.changeWaterContent(
setting?.waterContent ?? appStore.waterContent
)
}
}
})
//路由扁平化
const flatAsyncRoutes = (routes, breadcrumb = []) => {
let res = []
routes.forEach((route) => {
const tmp = { ...route }
if (tmp.children) {
let childrenBreadcrumb = [...breadcrumb]
childrenBreadcrumb.push(route)
let tmpRoute = { ...route }
tmpRoute.meta.breadcrumb = childrenBreadcrumb
delete tmpRoute.children
res.push(tmpRoute)
let childrenRoutes = flatAsyncRoutes(tmp.children, childrenBreadcrumb)
childrenRoutes.map((item) => {
res.push(item)
})
} else {
let tmpBreadcrumb = [...breadcrumb]
tmpBreadcrumb.push(tmp)
tmp.meta.breadcrumb = tmpBreadcrumb
res.push(tmp)
}
})
return res
}
const views = import.meta.glob('../../views/**/**.vue')
const empty = import.meta.glob('../../layout/empty.vue')
// 菜单转换路由
const filterAsyncRouter = (routerMap) => {
const accessedRouters = []
routerMap.forEach((item) => {
if (item.meta.type !== 'B') {
if (item.meta.type === 'I') {
item.meta.url = item.path
item.path = `/maIframe/${item.name}`
}
const route = {
path: item.path,
name: item.name,
hidden: item.hidden === 1,
meta: item.meta,
children: item.children ? filterAsyncRouter(item.children) : null,
component: views[`../../views/${item.component}.vue`]
}
accessedRouters.push(route)
}
})
return accessedRouters
}
// 去除按钮菜单
const removeButtonMenu = (routers) => {
let handlerAfterRouters = []
routers.forEach((item) => {
if (item.meta.type !== 'B' && !item.meta.hidden) {
let route = item
if (item.children && item.children.length > 0) {
route.children = removeButtonMenu(item.children)
}
handlerAfterRouters.push(route)
}
})
return handlerAfterRouters
}
export default useUserStore

View File

@@ -0,0 +1,56 @@
.ma-fade-enter-active,
.ma-fade-leave-active {
transition: opacity 0.15s ease;
}
.ma-fade-enter-from,
.ma-fade-leave-to {
opacity: 0;
}
.ma-slide-right-enter-active,
.ma-slide-right-leave-active,
.ma-slide-left-enter-active,
.ma-slide-left-leave-active {
will-change: transform;
transition: all 0.2s ease;
}
// ma-slide-right
.ma-slide-right-enter-from {
opacity: 0;
transform: translateX(-10px);
}
.ma-slide-right-leave-to {
opacity: 0;
transform: translateX(10px);
}
// ma-slide-left
.ma-slide-left-enter-from {
&:extend(.ma-slide-right-leave-to);
}
.ma-slide-left-leave-to {
&:extend(.ma-slide-right-enter-from);
}
.ma-slide-down-enter-active,
.ma-slide-down-leave-active,
.ma-slide-up-enter-active,
.ma-slide-up-leave-active {
will-change: transform;
transition: all 0.2s ease;
}
// ma-slide-down
.ma-slide-down-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.ma-slide-down-leave-to {
opacity: 0;
transform: translateY(10px);
}
// ma-slide-up
.ma-slide-up-enter-from {
&:extend(.ma-slide-down-leave-to);
}
.ma-slide-up-leave-to {
&:extend(.ma-slide-down-enter-from);
}

View File

@@ -0,0 +1,14 @@
[arco-theme="dark"] {
.menu-title { color: #efefef }
.logo { span { color: #efefef } }
.sys-search-container .results li .title,
.sys-search-container .arco-icon
{
color: #efefef;
}
.sys-search-container .icon {
fill: #efefef;
}
}

View File

@@ -0,0 +1,243 @@
@import 'dark.less';
@import 'animation.less';
html, body {
height: 100%;
}
.arco-layout-sider-children {
overflow-x: hidden;
}
.arco-switch {
background-color: var(--color-fill-4);
}
.arco-switch-checked {
background-color: rgb(var(--primary-6));
}
.layout, .layout-columns-left-panel, .layout-columns-right-panel {
height: 100%;
}
.layout-columns-left-panel {
.sider { padding: 5px; height: 100%; overflow-y: auto; overflow-x: hidden }
.layout-menu {
position: relative; z-index: 3; overflow: hidden;
}
.menu-title {
height: 51px; padding-left: 10px; font-weight: bold;
background-color: var(--color-bg-2);
border-bottom:1px solid var(--color-border-1);
}
}
.layout-columns-right-panel {
width: 100%; background-color: var(--color-neutral-2);
.layout-header {
background-color: var(--color-bg-2);
width: 100%; box-shadow: 1px 1px 2px var(--color-neutral-2);
}
}
.layout-banner-header {
height: 52px; border-bottom:1px solid var(--color-border-1);
background-color: var(--color-bg-2);
.logo {
width: 220px; padding-bottom: 1px; border-bottom: 0;
}
.banner-menus li {
cursor: pointer; padding: 0px 15px; height: 40px;
margin-left: 5px; border-radius: 4px; color: var(--color-neutral-10); fill: var(--color-neutral-10);
.icon {
width: 1.1em; height: 1.1em; display: inline;
}
}
.banner-menus li:hover {
background-color: var(--color-neutral-2);
}
.banner-menus li.active {
background-color: rgb(var(--primary-4)); color: var(--color-white);
fill: var(--color-white);
}
}
.layout-banner-content {
.tags {
border-top: 0;
}
}
.layout-classic-sider { box-shadow: none; }
.layout-classic-header {
.layout-classic-header-container {
background-color: var(--color-bg-2); height: 50px;
}
}
.ma-menu .arco-menu-inner::-webkit-scrollbar,
.backend-setting .arco-drawer-body::-webkit-scrollbar,
.arco-list::-webkit-scrollbar,
.customer-scrollbar::-webkit-scrollbar
{
width: 6px; /*高宽分别对应横竖滚动条的尺寸*/
height: 7px;
}
.ma-menu .arco-menu-inner::-webkit-scrollbar-thumb,
.backend-setting .arco-drawer-body::-webkit-scrollbar-thumb,
.arco-list::-webkit-scrollbar-thumb,
.customer-scrollbar::-webkit-scrollbar-thumb
{
border-radius: 10px;
background: var(--color-text-4)
}
.ma-menu .arco-menu-inner::-webkit-scrollbar-thumb:hover,
.backend-setting .arco-drawer-body::-webkit-scrollbar-thumb:hover,
.arco-list::-webkit-scrollbar-thumb:hover,
.customer-scrollbar::-webkit-scrollbar-thumb:hover
{
border-radius: 10px;
background: var(--color-text-3)
}
.ma-menu .arco-menu-inner::-webkit-scrollbar-track,
.backend-setting .arco-drawer-body::-webkit-scrollbar-track,
.arco-list::-webkit-scrollbar-track,
.customer-scrollbar::-webkit-scrollbar-track
{
border-radius: 0;
background: var(--color-text-5)
}
.tags-container {
background-color: var(--color-bg-2);
border-top:1px solid var(--color-border-1);
.tags {
border-bottom:1px solid var(--color-border-1);
background-color: var(--color-bg-2);
height: 40px; position: relative;
display: flex; align-items: center;
overflow: hidden; width: 100%;
div {
font-size: 13px; color: var(--color-text-2);
position: relative; flex-shrink: 0;
padding: 4px 8px; border-radius: 4px;
border: 1px solid var(--color-border-2);
cursor: pointer; transition: all 0.2s; margin-left: 10px;
display: inline-flex; justify-content: center; align-items: center;
.tag-icon { margin-left: 5px; font-size:18px; padding:2px; border-radius: 10px;}
.tag-icon:hover { color: var(--color-white); background-color: rgb(var(--primary-6));}
}
div:hover {
border: 1px solid rgb(var(--primary-3)); color: rgb(var(--primary-6));
}
div.active{
background-color: rgb(var(--primary-1)); color: rgb(var(--primary-6));border: 1px solid rgb(var(--primary-3));
.tag-icon { color: var(--primary-6);}
.tag-icon:hover { color: var(--color-white); background-color: rgb(var(--primary-6));}
}
}
&.tags-tool {
width: 100px;
}
.tags-contextmenu {
position: fixed; border: 1px solid var(--color-border-2);
padding: 5px 0; z-index: 999;
width: 170px; background-color: var(--color-bg-5);
border-radius: 4px;
.arco-divider-horizontal {
margin: 5px 0;
}
li {
padding: 7px 15px; color: var(--color-text-2); font-size: 13px;
}
li:hover {
background-color: rgb(var(--primary-1));
cursor: pointer;
}
li.disabled {
color: var(--color-text-4); cursor: no-drop;
}
}
}
.work-area {
background-color: var(--color-neutral-2);
color: var(--color-text-2);
height: 100%; overflow-y: auto;
.content-block-title { color: var(--color-text-1); font-size: 1.3em; }
.ma-content-block {
background-color: var(--color-bg-2); border-radius: 2px;
}
}
.button-menu {
position: fixed; bottom: 30px; left: 20px; z-index: 100;
.button-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
color: var(--color-white);
font-size: 14px;
border-radius: 50%;
cursor: pointer;
transition: all 0.1s;
:deep(.icon) {
margin-left: 1px !important;
}
}
.button-trigger:nth-child(1) {
background-color: var(--color-primary-light-4);
}
.button-trigger:nth-child(1).button-trigger-active {
background-color: rgb(var(--primary-6));
}
}
.ma-ui.max-size {
.max-size-exit {display: block;}
.ma-ui-slider, .ma-ui-header, .ma-ui-menu, .ma-ui-tags {display: none;}
}
.ma-ui {
.max-size-exit {
position: fixed;
z-index: 9999;
top: -45px;
left: 50%;
margin-left: -30px;
cursor: pointer;
display: none;
width: 60px;
height: 60px;
text-align: center;
fill: var(--color-white);
color: var(--color-white);
font-size: 45px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.2);
transition: all 0.3s;
}
.max-size-exit:hover {
top: 0px;
}
}
.tag-primary {
color: var(--color-white);
background-color: rgb(var(--primary-6));
}

View File

@@ -0,0 +1,4 @@
/* ./src/index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,4 @@
@import './skins/mine/index.less';
@import './skins/city/index.less';
@import './skins/classics/index.less';
@import './skins/businessGray/index.less';

View File

@@ -0,0 +1,148 @@
[mine-skin="businessGray"] {
.logo {
border-bottom: 0;
}
.layout-classic-header .layout-classic-header-container,
.arco-layout-sider,
.arco-layout-sider-light,
.arco-menu-light,
.tags-container,
.tags-container .tags,
.layout-banner-header
{
background: #2c3643;
}
.tags-container { border: 0; }
.tags-container .tags,
.layout-banner-header {
border: 0;
}
.tags-container .tags div {
color: var(--color-white);
}
.logo span,
.arco-breadcrumb div,
.arco-breadcrumb-item:last-child .layout-banner-header .banner-menus li.active,
.arco-menu-light .arco-menu-pop-header,
.sys-menus .arco-menu-icon .icon,
.layout-banner-header .banner-menus li {
color: var(--color-white);
fill: var(--color-white);
}
.layout-banner-header .banner-menus li:hover,
.arco-menu-light.arco-menu-horizontal .arco-menu-pop-header.arco-menu-selected:hover
{
background-color: rgba(0, 0, 0, 0.2);
}
.layout-banner-header .banner-menus li.active {
background-color: rgba(0, 0, 0, 0.5);
}
.operation-area .arco-btn-secondary
{
background-color: rgba(0, 0, 0, 0.2);
color: #fff;
}
.operation-area .arco-btn-secondary:hover,
.operation-area .arco-btn-secondary[type='button']:hover {
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
}
.arco-menu-light .arco-menu-item {
background-color: rgba(0, 0, 0, 0);
color: #fff;
fill: #fff;
}
.arco-menu-light .arco-menu-item:hover,
.arco-menu-light .arco-menu-inline-header:hover,
.arco-menu-light .arco-menu-item.arco-menu-selected {
background-color: rgba(0, 0, 0, 0.35);
}
.ma-menu .icon,
.arco-menu-selected .icon {
fill: #fff;
}
.arco-menu-light .arco-menu-inline-header,
.arco-menu-light .arco-menu-pop-header,
.arco-menu-light .arco-menu-collapse-button,
.arco-menu-light .arco-menu-collapse-button:hover {
background-color: rgba(255, 255, 255, 0);
}
.arco-menu-light .arco-menu-inline-header.arco-menu-selected:hover,
.arco-menu-light .arco-menu-pop-header:hover,
.arco-menu-light .arco-menu-pop-header.arco-menu-selected {
background-color: rgba(0, 0, 0, 0.35);
}
.arco-menu-light .arco-menu-pop-header .arco-icon,
.arco-menu-light .arco-menu-pop-header.arco-menu-selected .arco-menu-icon,
.arco-menu-light .arco-menu-item .arco-icon,
.arco-menu-light .arco-menu-item .arco-menu-icon,
.arco-menu-light .arco-menu-inline-header .iconify-icon,
.arco-menu-light .arco-menu-inline-header .arco-icon,
.arco-menu-light .arco-menu-inline-header {
color: #fff;
}
.layout-banner-header .banner-menus li.active {
background: rgb(var(--primary-6));
}
.layout-columns-left-panel {
.menu-title {
height: 51px;
padding-left: 10px;
font-weight: bold;
color: #fff;
background-color: #2c3643;
border-bottom: none;
}
}
.layout-columns-right-panel,
.layout-columns-right-panel .layout-header {
background-color: #2c3643;
box-shadow: none;
.work-area {
height: calc(100% - 87px);
}
}
.layout-columns-left-panel .sider {
background-color: #20262e;
}
.tags a{
border-radius: 0;
}
.tags a.active {
background-color: #555;
}
}
.sys-menus .arco-trigger-popup .icon {
fill: var(--color-text-1);
}
[mine-skin="businessGray"][arco-theme="dark"] {
.arco-trigger-menu .arco-trigger-menu-has-icon .arco-trigger-menu-icon {
fill: #fff;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 KiB

View File

@@ -0,0 +1,146 @@
[mine-skin="city"] {
.main-container {
background-image: url('@/style/skins/city/background.jpg') !important;
background-size: cover;
}
.logo {
border-bottom: 0;
}
.sys-menus .arco-menu-icon .icon {
fill: #fff;
}
.layout-classic-header .layout-classic-header-container,
.arco-layout-sider,
.arco-layout-sider-light,
.arco-menu-light,
.tags-container,
.tags-container .tags,
.layout-banner-header {
background: none;
}
.tags-container { border: 0; }
.tags,
.layout-banner-header {
border: 0;
}
.tags-container .tags div {
color: var(--color-white);
}
.logo span,
.arco-breadcrumb div,
.arco-breadcrumb-item:last-child .layout-banner-header .banner-menus li.active,
.arco-menu-light .arco-menu-pop-header,
.layout-banner-header .banner-menus li {
color: var(--color-white);
fill: var(--color-white);
}
.layout-banner-header .banner-menus li:hover {
background-color: rgba(0, 0, 0, 0.2);
}
.layout-banner-header .banner-menus li.active {
background-color: rgba(0, 0, 0, 0.5);
}
.operation-area .arco-btn-secondary {
background-color: rgba(0, 0, 0, 0.2);
color: #fff;
}
.operation-area .arco-btn-secondary:hover,
.operation-area .arco-btn-secondary[type='button']:hover {
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
}
.arco-menu-light .arco-menu-item {
background-color: rgba(0, 0, 0, 0);
color: #fff;
fill: #fff;
}
.arco-menu-light .arco-menu-item:hover,
.arco-menu-light .arco-menu-inline-header:hover,
.arco-menu-light .arco-menu-item.arco-menu-selected {
background-color: rgba(0, 0, 0, 0.35);
}
.ma-menu .icon,
.arco-menu-selected .icon {
fill: #fff;
}
.arco-menu-light .arco-menu-inline-header,
.arco-menu-light .arco-menu-pop-header,
.arco-menu-light .arco-menu-collapse-button,
.arco-menu-light .arco-menu-collapse-button:hover {
background-color: rgba(255, 255, 255, 0);
}
.arco-menu-light .arco-menu-inline-header.arco-menu-selected:hover,
.arco-menu-light .arco-menu-pop-header:hover,
.arco-menu-light .arco-menu-pop-header.arco-menu-selected,
.arco-menu-light.arco-menu-horizontal .arco-menu-pop-header.arco-menu-selected:hover
{
background-color: rgba(0, 0, 0, 0.35);
}
.arco-menu-light .arco-menu-pop-header .arco-icon,
.arco-menu-light .arco-menu-pop-header.arco-menu-selected .arco-menu-icon,
.arco-menu-light .arco-menu-item .arco-icon,
.arco-menu-light .arco-menu-item .arco-menu-icon,
.arco-menu-light .arco-menu-inline-header .arco-icon,
.arco-menu-light .arco-menu-inline-header {
color: #fff;
}
.layout-banner-header .banner-menus li.active {
background: rgb(var(--primary-6));
}
.work-area {
background-color: rgba(255, 255, 255, 0.85);
border-radius: 4px;
}
.layout-columns-left-panel {
.menu-title {
height: 51px; padding-left: 10px; font-weight: bold;
color: #fff;
background-color: rgba(255, 255, 255, 0);
border-bottom: none;
}
}
.layout-columns-right-panel,
.layout-columns-right-panel .layout-header {
background-color: rgba(255, 255, 255, 0);
box-shadow: none;
.work-area {
height: calc(100% - 85px);
}
}
.layout-columns-left-panel .sider {
background-color: rgba(0, 0, 0, 0.2);
}
}
[mine-skin="city"][arco-theme="dark"] {
.work-area {
background-color: rgba(0, 0, 0, 0.80);
}
.arco-trigger-menu .arco-trigger-menu-has-icon .arco-trigger-menu-icon {
fill: #fff;
}
}

View File

@@ -0,0 +1,93 @@
[mine-skin="classics"] {
.logo {
border-bottom: 0;
}
.arco-layout-sider,
.arco-layout-sider-light,
.arco-menu-light
{
background: #1c1e23;
}
.logo span,
.arco-menu-light .arco-menu-pop-header,
.layout-banner-header .banner-menus li {
color: rgb(var(--primary-5));
fill: rgb(var(--primary-5));
}
.arco-menu-light.arco-menu-horizontal .arco-menu-pop-header.arco-menu-selected:hover
{
background-color: #2e3033;
}
.arco-menu-light .arco-menu-item {
background-color: rgba(0, 0, 0, 0);
color: #fff;
fill: #fff;
}
.arco-menu-light .arco-menu-item:hover,
.arco-menu-light .arco-menu-inline-header:hover,
.arco-menu-light .arco-menu-item.arco-menu-selected {
// background-color: #ffffff14;
background-color: rgb(var(--primary-6));
}
.ma-menu .icon,
.arco-menu-selected .icon {
fill: #fff;
}
.arco-menu-light .arco-menu-inline-header,
.arco-menu-light .arco-menu-pop-header,
.arco-menu-light .arco-menu-collapse-button,
.arco-menu-light .arco-menu-collapse-button:hover {
background-color: rgba(255, 255, 255, 0);
}
.arco-menu-light .arco-menu-inline-header.arco-menu-selected:hover,
.arco-menu-light .arco-menu-pop-header:hover,
.arco-menu-light .arco-menu-pop-header.arco-menu-selected {
background-color: rgba(0, 0, 0, 0.35);
}
.arco-menu-light .arco-menu-pop-header .arco-icon,
.arco-menu-light .arco-menu-pop-header .iconify-icon,
.arco-menu-light .arco-menu-pop-header.arco-menu-selected .arco-menu-icon,
.arco-menu-light .arco-menu-item .arco-icon,
.arco-menu-light .arco-menu-item .arco-menu-icon,
.arco-menu-light .arco-menu-inline-header .iconify-icon,
.arco-menu-light .arco-menu-inline-header .arco-icon,
.arco-menu-light .arco-menu-inline-header {
color: #fff;
}
.layout-columns-left-panel {
.menu-title {
font-weight: bold;
color: #fff;
background-color: #232324;
border-bottom: none;
}
}
.layout-columns-left-panel .sider {
background-color: #17171a;
}
.parent-menu.active {
background-color: #ffffff14;
}
.parent-menu:hover {
background-color: #ffffff14;
}
}

View File

@@ -0,0 +1,56 @@
[mine-skin="mine"] {
.arco-menu-light .arco-menu-has-icon .arco-icon,
.arco-menu-light .arco-menu-has-icon .iconify-icon,
.arco-menu-light .arco-menu-item .arco-icon,
.arco-menu-light .arco-menu-item .iconify-icon {
color: var(--color-text-1);
}
.arco-trigger-menu .arco-trigger-menu-has-icon .arco-trigger-menu-icon,
.sys-menus .arco-menu-icon .icon {
fill: var(--color-text-2);
}
.arco-menu-light .arco-menu-item.arco-menu-selected .arco-icon,
.arco-menu-light .arco-menu-item.arco-menu-selected .iconify-icon {
color: rgb(var(--primary-6));
}
.arco-menu-light .arco-menu-inline-header.arco-menu-selected .arco-menu-icon .arco-icon,
.arco-menu-light .arco-menu-inline-header.arco-menu-selected .arco-menu-icon .iconify-icon {
color: rgb(var(--primary-6));
}
.layout-banner-header .banner-menus li.active {
background-color: rgb(var(--primary-1));
color: rgb(var(--primary-6));
fill: rgb(var(--primary-6));
}
.layout-columns-left-panel .sider {
background-color: var(--color-bg-1);
}
.parent-menu {
color: var(--color-text-2);
fill: var(--color-text-2);
}
.parent-menu:hover {
background-color: var(--color-fill-2);
fill: var(--color-text-2);
}
.parent-menu.active {
background-color: rgb(var(--primary-1));
color: rgb(var(--primary-6));
fill: rgb(var(--primary-6));
}
.sys-menus .arco-menu-item.arco-menu-selected .icon {
fill: rgb(var(--primary-6));
}
}

View File

@@ -0,0 +1,261 @@
import checkAuth from '@/directives/auth/auth'
import checkRole from '@/directives/role/role'
import useClipboard from 'vue-clipboard3'
import { Notification, Message } from '@arco-design/web-vue'
import { nextTick } from 'vue'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import router from '@/router'
import { useTagStore, useKeepAliveStore } from '@/store'
import tool from '@/utils/tool'
export const refreshTag = () => {
const route = router.currentRoute.value
const keepStore = useKeepAliveStore()
NProgress.start()
keepStore.removeKeepAlive(route)
keepStore.hidden()
nextTick(() => {
keepStore.addKeepAlive(route)
keepStore.display()
NProgress.done()
})
}
export const addTag = (tag) => {
const tagStore = useTagStore()
const keepStore = useKeepAliveStore()
tagStore.addTag(tag)
keepStore.addKeepAlive(tag)
}
export const closeTag = (tag) => {
const tagStore = useTagStore()
const keepStore = useKeepAliveStore()
const t = tagStore.removeTag(tag)
keepStore.removeKeepAlive(tag)
router.push({ path: t.path, query: tool.getRequestParams(t.path) })
}
export const success = (title, content) => {
Notification.success({ title, content, closable: true })
}
export const info = (title, content) => {
Notification.info({ title, content, closable: true })
}
export const error = (title, content) => {
Notification.error({ title, content, closable: true })
}
// 检查权限函数
export const auth = (name) => checkAuth(name)
// 检查角色函数
export const role = (name) => checkRole(name)
// 复制
export const copy = (text) => {
try {
useClipboard().toClipboard(text)
Message.success('已成功复制到剪切板')
} catch (e) {
Message.error('复制失败')
}
}
function transitionJsonToString(jsonObj, callback) {
// 转换后的jsonObj受体对象
var _jsonObj = null
// 判断传入的jsonObj对象是不是字符串如果是字符串需要先转换为对象再转换为字符串这样做是为了保证转换后的字符串为双引号
if (Object.prototype.toString.call(jsonObj) !== '[object String]') {
try {
_jsonObj = JSON.stringify(jsonObj)
} catch (error) {
// 转换失败错误信息
console.error('您传递的json数据格式有误请核对...')
console.error(error)
callback(error)
}
} else {
try {
jsonObj = jsonObj.replace(/(\')/g, '"')
_jsonObj = JSON.stringify(JSON.parse(jsonObj))
} catch (error) {
// 转换失败错误信息
console.error('您传递的json数据格式有误请核对....')
console.error(error)
// callback(error);
}
}
return _jsonObj
}
export const formatJson = (jsonObj, callback) => {
// 正则表达式匹配规则变量
var reg = null
// 转换后的字符串变量
var formatted = ''
// 换行缩进位数
var pad = 0
// 一个tab对应空格位数
var PADDING = ' '
// json对象转换为字符串变量
var jsonString = transitionJsonToString(jsonObj, callback)
if (!jsonString) {
return jsonString
}
// 存储需要特殊处理的字符串段
var _index = []
// 存储需要特殊处理的“再数组中的开始位置变量索引
var _indexStart = null
// 存储需要特殊处理的“再数组中的结束位置变量索引
var _indexEnd = null
// 将jsonString字符串内容通过\r\n符分割成数组
var jsonArray = []
// 正则匹配到{,}符号则在两边添加回车换行
jsonString = jsonString.replace(/([\{\}])/g, '\r\n$1\r\n')
// 正则匹配到[,]符号则在两边添加回车换行
jsonString = jsonString.replace(/([\[\]])/g, '\r\n$1\r\n')
// 正则匹配到,符号则在两边添加回车换行
jsonString = jsonString.replace(/(\,)/g, '$1\r\n')
// 正则匹配到要超过一行的换行需要改为一行
jsonString = jsonString.replace(/(\r\n\r\n)/g, '\r\n')
// 正则匹配到单独处于一行的,符号时需要去掉换行,将,置于同行
jsonString = jsonString.replace(/\r\n\,/g, ',')
// 特殊处理双引号中的内容
jsonArray = jsonString.split('\r\n')
jsonArray.forEach(function (node, index) {
// 获取当前字符串段中"的数量
var num = node.match(/\"/g) ? node.match(/\"/g).length : 0
// 判断num是否为奇数来确定是否需要特殊处理
if (num % 2 && !_indexStart) {
_indexStart = index
}
if (num % 2 && _indexStart && _indexStart != index) {
_indexEnd = index
}
// 将需要特殊处理的字符串段的其实位置和结束位置信息存入,并对应重置开始时和结束变量
if (_indexStart && _indexEnd) {
_index.push({
start: _indexStart,
end: _indexEnd
})
_indexStart = null
_indexEnd = null
}
})
// 开始处理双引号中的内容,将多余的"去除
_index.reverse().forEach(function (item, index) {
var newArray = jsonArray.slice(item.start, item.end + 1)
jsonArray.splice(item.start, item.end + 1 - item.start, newArray.join(''))
})
// 奖处理后的数组通过\r\n连接符重组为字符串
jsonString = jsonArray.join('\r\n')
// 将匹配到:后为回车换行加大括号替换为冒号加大括号
jsonString = jsonString.replace(/\:\r\n\{/g, ':{')
// 将匹配到:后为回车换行加中括号替换为冒号加中括号
jsonString = jsonString.replace(/\:\r\n\[/g, ':[')
// 将上述转换后的字符串再次以\r\n分割成数组
jsonArray = jsonString.split('\r\n')
// 将转换完成的字符串根据PADDING值来组合成最终的形态
jsonArray.forEach(function (item, index) {
// console.log(item);
var i = 0
// 表示缩进的位数以tab作为计数单位
var indent = 0
// 表示缩进的位数,以空格作为计数单位
var padding = ''
if (item.match(/\{$/) || item.match(/\[$/)) {
// 匹配到以{和[结尾的时候indent加1
indent += 1
} else if (
item.match(/\}$/) ||
item.match(/\]$/) ||
item.match(/\},$/) ||
item.match(/\],$/)
) {
// 匹配到以}和]结尾的时候indent减1
if (pad !== 0) {
pad -= 1
}
} else {
indent = 0
}
for (i = 0; i < pad; i++) {
padding += PADDING
}
formatted += padding + item + '\n'
pad += indent
})
// 返回的数据需要去除两边的空格
return formatted.trim()
}
// 判断是否弹出层全屏
export const setModalSizeEvent = (callback) => {
callback({ isFull: window.screen.width < 768, width: window.screen.width })
}
// 加载远程js
export const loadScript = (src, callback) => {
const s = document.createElement('script')
s.type = 'text/javascript'
s.src = src
s.onload = s.onreadystatechange = function () {
if (
!this.readyState ||
this.readyState === 'loaded' ||
this.readyState === 'complete'
) {
callback && callback()
s.onload = s.onreadystatechange = null
}
}
document.body.appendChild(s)
}
// 加载远程css
export const loadCss = (href, callback) => {
const s = document.createElement('link')
s.type = 'text/css'
s.rel = 'stylesheet'
s.media = 'all'
s.href = href
s.onload = s.onreadystatechange = function () {
if (
!this.readyState ||
this.readyState === 'loaded' ||
this.readyState === 'complete'
) {
callback && callback()
s.onload = s.onreadystatechange = null
}
}
document.body.appendChild(s)
}
export const discount = (discount, price) => {
return (
(price * (discount === '0.00' || discount === 0 ? 10 : discount)) /
10
).toFixed(2)
}
export const versionCompare = (v1, v2) => {
// 将版本号转换成数字数组
v1 = v1.split('.')
v2 = v2.split('.')
// 对齐版本号的长度
while (v1.length < v2.length) v1.push('0')
while (v2.length < v1.length) v2.push('0')
// 转换成数字数组
v1 = v1.map(Number)
v2 = v2.map(Number)
for (let i = 0; i < v1.length; i++) {
if (v1[i] < v2[i]) return -1 // v1 < v2
if (v1[i] > v2[i]) return 1 // v1 > v2
}
return 0 // v1 == v2
}

View File

@@ -0,0 +1,135 @@
// 打印类属性、方法定义
/* eslint-disable */
class Print
{
dom = null
options = {}
constructor (dom, options = {}) {
// if (!(this instanceof Print)) return new Print(dom, options)
this.options = this.extend({ 'noPrint': '.no-print' }, options)
if ( typeof dom === 'string' ) {
try {
this.dom = document.querySelector(dom)
} catch {
this.dom = document.createElement('div')
this.dom.innerHTML = dom
}
} else {
this.isDOM(dom)
this.dom = this.isDOM(dom) ? dom : dom.$el
}
this.init()
}
init () {
this.writeIframe(this.getStyle() + this.getHtml())
}
extend (obj, obj2) {
for (let key in obj2) {
obj[key] = obj2[key]
}
return obj
}
getStyle () {
let str = '', styles = document.querySelectorAll('style,link')
for (let i = 0; i < styles.length; i++) {
str += styles[i].outerHTML
}
str += '<style>' + (this.options.noPrint ? this.options.noPrint : '.no-print') + '{ display: none; }</style>'
str += '<style>html, body{ background-color: #fff; }</style>'
return str
}
getHtml () {
const inputs = document.querySelectorAll('input');
const textAreas = document.querySelectorAll('textarea');
const selects = document.querySelectorAll('select');
for (let k = 0; k < inputs.length; k++) {
if (inputs[k].type === 'checkbox' || inputs[k].type === 'radio') {
if (inputs[k].checked === true) {
inputs[k].setAttribute('checked', "checked")
} else {
inputs[k].removeAttribute('checked')
}
} else if (inputs[k].type === 'text') {
inputs[k].setAttribute('value', inputs[k].value)
} else {
inputs[k].setAttribute('value', inputs[k].value)
}
}
for (let k = 0; k < textAreas.length; k++) {
if (textAreas[k].type === 'textarea') {
textAreas[k].innerHTML = textAreas[k].value
}
}
for (let k = 0; k < selects.length; k++) {
if (selects[k].type === 'select-one') {
let child = selects[k].children;
for (let i in child) {
if (child[i].tagName === 'OPTION') {
if (child[i].selected === true) {
child[i].setAttribute('selected', "selected")
} else {
child[i].removeAttribute('selected')
}
}
}
}
}
return this.dom.outerHTML
}
writeIframe (content) {
let w, doc, iframe = document.createElement('iframe'), f = document.body.appendChild(iframe)
iframe.id = 'myIframe'
iframe.setAttribute('style', 'position:absolute; width:0; height:0; top:-10px; left:-10px;')
w = f.contentWindow ?? f.contentDocument
doc = f.contentDocument ?? f.contentWindow.document
doc.open()
doc.write(content)
doc.close()
const _this = this
iframe.onload = () => {
_this.toPrint(w)
setTimeout(() => { document.body.removeChild(iframe) }, 100)
}
}
toPrint (frameWindow) {
try {
setTimeout(() => {
frameWindow.focus()
try {
if (! frameWindow.document.execCommand('print', false, null)) {
frameWindow.print()
}
} catch (e) {
frameWindow.print()
}
frameWindow.close()
}, 10)
} catch (err) {
console.log('err', err)
}
}
isDOM (obj) {
return (typeof HTMLElement === 'object')
? obj instanceof HTMLElement
: obj && typeof obj === 'object' && obj.nodeType === 1 && typeof obj.nodeName === 'string'
}
}
export default Print

View File

@@ -0,0 +1,732 @@
export const Push = function (options) {
this.doNotConnect = 0;
options = options || {};
options.heartbeat = options.heartbeat || 25000;
options.pingTimeout = options.pingTimeout || 10000;
this.config = options;
this.uid = 0;
this.channels = {};
this.connection = null;
this.pingTimeoutTimer = 0;
Push.instances.push(this);
this.createConnection();
}
Push.prototype.checkoutPing = function () {
var _this = this;
setTimeout(function () {
if (_this.connection.state === 'connected') {
_this.connection.send('{"event":"pusher:ping","data":{}}');
if (_this.pingTimeoutTimer) {
clearTimeout(_this.pingTimeoutTimer);
_this.pingTimeoutTimer = 0;
}
_this.pingTimeoutTimer = setTimeout(function () {
_this.connection.closeAndClean();
if (!_this.connection.doNotConnect) {
_this.connection.waitReconnect();
}
}, _this.config.pingTimeout);
}
}, this.config.heartbeat);
};
Push.prototype.channel = function (name) {
return this.channels.find(name);
};
Push.prototype.allChannels = function () {
return this.channels.all();
};
Push.prototype.createConnection = function () {
if (this.connection) {
throw Error('Connection already exist');
}
var _this = this;
var url = this.config.url;
function updateSubscribed() {
for (var i in _this.channels) {
_this.channels[i].subscribed = false;
}
}
this.connection = new Connection({
url: url,
app_key: this.config.app_key,
onOpen: function () {
_this.connection.state = 'connecting';
_this.checkoutPing();
},
onMessage: function (params) {
if (_this.pingTimeoutTimer) {
clearTimeout(_this.pingTimeoutTimer);
_this.pingTimeoutTimer = 0;
}
params = JSON.parse(params.data);
var event = params.event;
var channel_name = params.channel;
if (event === 'pusher:pong') {
_this.checkoutPing();
return;
}
if (event === 'pusher:error') {
throw Error(params.data.message);
}
var data = JSON.parse(params.data), channel;
if (event === 'pusher_internal:subscription_succeeded') {
channel = _this.channels[channel_name];
channel.subscribed = true;
channel.processQueue();
channel.emit('pusher:subscription_succeeded');
return;
}
if (event === 'pusher:connection_established') {
_this.connection.socket_id = data.socket_id;
_this.connection.state = 'connected';
_this.subscribeAll();
}
if (event.indexOf('pusher_internal') !== -1) {
console.log("Event '" + event + "' not implement");
return;
}
channel = _this.channels[channel_name];
if (channel) {
channel.emit(event, data);
}
},
onClose: function () {
updateSubscribed();
},
onError: function () {
updateSubscribed();
}
});
};
Push.prototype.disconnect = function () {
this.connection.doNotConnect = 1;
this.connection.close();
};
Push.prototype.subscribeAll = function () {
if (this.connection.state !== 'connected') {
return;
}
for (var channel_name in this.channels) {
//this.connection.send(JSON.stringify({event:"pusher:subscribe", data:{channel:channel_name}}));
this.channels[channel_name].processSubscribe();
}
};
Push.prototype.unsubscribe = function (channel_name) {
if (this.channels[channel_name]) {
delete this.channels[channel_name];
if (this.connection.state === 'connected') {
this.connection.send(JSON.stringify({ event: "pusher:unsubscribe", data: { channel: channel_name } }));
}
}
};
Push.prototype.unsubscribeAll = function () {
var channels = Object.keys(this.channels);
if (channels.length) {
if (this.connection.state === 'connected') {
for (var channel_name in this.channels) {
this.unsubscribe(channel_name);
}
}
}
this.channels = {};
};
Push.prototype.subscribe = function (channel_name) {
if (this.channels[channel_name]) {
return this.channels[channel_name];
}
if (channel_name.indexOf('private-') === 0) {
return createPrivateChannel(channel_name, this);
}
if (channel_name.indexOf('presence-') === 0) {
return createPresenceChannel(channel_name, this);
}
return createChannel(channel_name, this);
};
Push.instances = [];
function createChannel(channel_name, push) {
var channel = new Channel(push.connection, channel_name);
push.channels[channel_name] = channel;
channel.subscribeCb = function () {
push.connection.send(JSON.stringify({ event: "pusher:subscribe", data: { channel: channel_name } }));
}
return channel;
}
function createPrivateChannel(channel_name, push) {
var channel = new Channel(push.connection, channel_name);
push.channels[channel_name] = channel;
channel.subscribeCb = function () {
__ajax({
url: push.config.auth,
type: 'POST',
data: { channel_name: channel_name, socket_id: push.connection.socket_id },
success: function (data) {
data = JSON.parse(data);
data.channel = channel_name;
push.connection.send(JSON.stringify({ event: "pusher:subscribe", data: data }));
},
error: function (e) {
throw Error(e);
}
});
};
channel.processSubscribe();
return channel;
}
function createPresenceChannel(channel_name, push) {
return createPrivateChannel(channel_name, push);
}
function Connection(options) {
this.dispatcher = new Dispatcher();
__extends(this, this.dispatcher);
var properies = ['on', 'off', 'emit'];
for (var i in properies) {
this[properies[i]] = this.dispatcher[properies[i]];
}
this.options = options;
this.state = 'initialized'; //initialized connecting connected disconnected
this.doNotConnect = 0;
this.reconnectInterval = 1;
this.connection = null;
this.reconnectTimer = 0;
this.connect();
}
Connection.prototype.updateNetworkState = function (state) {
var old_state = this.state;
this.state = state;
if (old_state !== state) {
this.emit('state_change', { previous: old_state, current: state });
}
};
Connection.prototype.connect = function () {
this.doNotConnect = 0;
if (this.state === 'connected') {
console.log('networkState is "' + this.state + '" and do not need connect');
return;
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = 0;
}
this.closeAndClean();
var options = this.options;
var websocket = new WebSocket(options.url + '/app/' + options.app_key);
this.updateNetworkState('connecting');
var _this = this;
websocket.onopen = function (res) {
_this.reconnectInterval = 1;
if (_this.doNotConnect) {
_this.updateNetworkState('disconnected');
websocket.close();
return;
}
if (options.onOpen) {
options.onOpen(res);
}
};
if (options.onMessage) {
websocket.onmessage = options.onMessage;
}
websocket.onclose = function (res) {
websocket.onmessage = websocket.onopen = websocket.onclose = websocket.onerror = null;
_this.updateNetworkState('disconnected');
if (!_this.doNotConnect) {
_this.waitReconnect();
}
if (options.onClose) {
options.onClose(res);
}
};
websocket.onerror = function (res) {
_this.close();
if (!_this.doNotConnect) {
_this.waitReconnect();
}
if (options.onError) {
options.onError(res);
}
};
this.connection = websocket;
}
Connection.prototype.closeAndClean = function () {
if (this.connection) {
var websocket = this.connection;
websocket.onmessage = websocket.onopen = websocket.onclose = websocket.onerror = null;
try {
websocket.close();
} catch (e) { }
this.updateNetworkState('disconnected');
}
};
Connection.prototype.waitReconnect = function () {
if (this.state === 'connected' || this.state === 'connecting') {
return;
}
if (!this.doNotConnect) {
this.updateNetworkState('connecting');
var _this = this;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
this.reconnectTimer = setTimeout(function () {
_this.connect();
}, this.reconnectInterval);
if (this.reconnectInterval < 1000) {
this.reconnectInterval = 1000;
} else {
// 每次重连间隔增大一倍
this.reconnectInterval = this.reconnectInterval * 2;
}
// 有网络的状态下重连间隔最大2秒
if (this.reconnectInterval > 2000 && navigator.onLine) {
_this.reconnectInterval = 2000;
}
}
}
Connection.prototype.send = function (data) {
if (this.state !== 'connected') {
console.trace('networkState is "' + this.state + '", can not send ' + data);
return;
}
this.connection.send(data);
}
Connection.prototype.close = function () {
this.updateNetworkState('disconnected');
this.connection.close();
}
var __extends = (this && this.__extends) || function (d, b) {
for (var p in b) if (b.hasOwnProperty(p)) { d[p] = b[p]; }
function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
};
function Channel(connection, channel_name) {
this.subscribed = false;
this.dispatcher = new Dispatcher();
this.connection = connection;
this.channelName = channel_name;
this.subscribeCb = null;
this.queue = [];
__extends(this, this.dispatcher);
var properies = ['on', 'off', 'emit'];
for (var i in properies) {
this[properies[i]] = this.dispatcher[properies[i]];
}
}
Channel.prototype.processSubscribe = function () {
if (this.connection.state !== 'connected') {
return;
}
this.subscribeCb();
};
Channel.prototype.processQueue = function () {
if (this.connection.state !== 'connected' || !this.subscribed) {
return;
}
for (var i in this.queue) {
this.queue[i]();
}
this.queue = [];
};
Channel.prototype.trigger = function (event, data) {
if (event.indexOf('client-') !== 0) {
throw new Error("Event '" + event + "' should start with 'client-'");
}
var _this = this;
this.queue.push(function () {
_this.connection.send(JSON.stringify({ event: event, data: data, channel: _this.channelName }));
});
this.processQueue();
};
////////////////
var Collections = (function () {
var exports = {};
function extend(target) {
var sources = [];
for (var _i = 1; _i < arguments.length; _i++) {
sources[_i - 1] = arguments[_i];
}
for (var i = 0; i < sources.length; i++) {
var extensions = sources[i];
for (var property in extensions) {
if (extensions[property] && extensions[property].constructor &&
extensions[property].constructor === Object) {
target[property] = extend(target[property] || {}, extensions[property]);
}
else {
target[property] = extensions[property];
}
}
}
return target;
}
exports.extend = extend;
function stringify() {
var m = ["Push"];
for (var i = 0; i < arguments.length; i++) {
if (typeof arguments[i] === "string") {
m.push(arguments[i]);
}
else {
m.push(safeJSONStringify(arguments[i]));
}
}
return m.join(" : ");
}
exports.stringify = stringify;
function arrayIndexOf(array, item) {
var nativeIndexOf = Array.prototype.indexOf;
if (array === null) {
return -1;
}
if (nativeIndexOf && array.indexOf === nativeIndexOf) {
return array.indexOf(item);
}
for (var i = 0, l = array.length; i < l; i++) {
if (array[i] === item) {
return i;
}
}
return -1;
}
exports.arrayIndexOf = arrayIndexOf;
function objectApply(object, f) {
for (var key in object) {
if (Object.prototype.hasOwnProperty.call(object, key)) {
f(object[key], key, object);
}
}
}
exports.objectApply = objectApply;
function keys(object) {
var keys = [];
objectApply(object, function (_, key) {
keys.push(key);
});
return keys;
}
exports.keys = keys;
function values(object) {
var values = [];
objectApply(object, function (value) {
values.push(value);
});
return values;
}
exports.values = values;
function apply(array, f, context) {
for (var i = 0; i < array.length; i++) {
f.call(context || (window), array[i], i, array);
}
}
exports.apply = apply;
function map(array, f) {
var result = [];
for (var i = 0; i < array.length; i++) {
result.push(f(array[i], i, array, result));
}
return result;
}
exports.map = map;
function mapObject(object, f) {
var result = {};
objectApply(object, function (value, key) {
result[key] = f(value);
});
return result;
}
exports.mapObject = mapObject;
function filter(array, test) {
test = test || function (value) {
return !!value;
};
var result = [];
for (var i = 0; i < array.length; i++) {
if (test(array[i], i, array, result)) {
result.push(array[i]);
}
}
return result;
}
exports.filter = filter;
function filterObject(object, test) {
var result = {};
objectApply(object, function (value, key) {
if ((test && test(value, key, object, result)) || Boolean(value)) {
result[key] = value;
}
});
return result;
}
exports.filterObject = filterObject;
function flatten(object) {
var result = [];
objectApply(object, function (value, key) {
result.push([key, value]);
});
return result;
}
exports.flatten = flatten;
function any(array, test) {
for (var i = 0; i < array.length; i++) {
if (test(array[i], i, array)) {
return true;
}
}
return false;
}
exports.any = any;
function all(array, test) {
for (var i = 0; i < array.length; i++) {
if (!test(array[i], i, array)) {
return false;
}
}
return true;
}
exports.all = all;
function encodeParamsObject(data) {
return mapObject(data, function (value) {
if (typeof value === "object") {
value = safeJSONStringify(value);
}
return encodeURIComponent(base64_1["default"](value.toString()));
});
}
exports.encodeParamsObject = encodeParamsObject;
function buildQueryString(data) {
var params = filterObject(data, function (value) {
return value !== undefined;
});
return map(flatten(encodeParamsObject(params)), util_1["default"].method("join", "=")).join("&");
}
exports.buildQueryString = buildQueryString;
function decycleObject(object) {
var objects = [], paths = [];
return (function derez(value, path) {
var i, name, nu;
switch (typeof value) {
case 'object':
if (!value) {
return null;
}
for (i = 0; i < objects.length; i += 1) {
if (objects[i] === value) {
return { $ref: paths[i] };
}
}
objects.push(value);
paths.push(path);
if (Object.prototype.toString.apply(value) === '[object Array]') {
nu = [];
for (i = 0; i < value.length; i += 1) {
nu[i] = derez(value[i], path + '[' + i + ']');
}
}
else {
nu = {};
for (name in value) {
if (Object.prototype.hasOwnProperty.call(value, name)) {
nu[name] = derez(value[name], path + '[' + JSON.stringify(name) + ']');
}
}
}
return nu;
case 'number':
case 'string':
case 'boolean':
return value;
}
}(object, '$'));
}
exports.decycleObject = decycleObject;
function safeJSONStringify(source) {
try {
return JSON.stringify(source);
}
catch (e) {
return JSON.stringify(decycleObject(source));
}
}
exports.safeJSONStringify = safeJSONStringify;
return exports;
})();
var Dispatcher = (function () {
function Dispatcher(failThrough) {
this.callbacks = new CallbackRegistry();
this.global_callbacks = [];
this.failThrough = failThrough;
}
Dispatcher.prototype.on = function (eventName, callback, context) {
this.callbacks.add(eventName, callback, context);
return this;
};
Dispatcher.prototype.on_global = function (callback) {
this.global_callbacks.push(callback);
return this;
};
Dispatcher.prototype.off = function (eventName, callback, context) {
this.callbacks.remove(eventName, callback, context);
return this;
};
Dispatcher.prototype.emit = function (eventName, data) {
var i;
for (i = 0; i < this.global_callbacks.length; i++) {
this.global_callbacks[i](eventName, data);
}
var callbacks = this.callbacks.get(eventName);
if (callbacks && callbacks.length > 0) {
for (i = 0; i < callbacks.length; i++) {
callbacks[i].fn.call(callbacks[i].context || (window), data);
}
}
else if (this.failThrough) {
this.failThrough(eventName, data);
}
return this;
};
return Dispatcher;
}());
var CallbackRegistry = (function () {
function CallbackRegistry() {
this._callbacks = {};
}
CallbackRegistry.prototype.get = function (name) {
return this._callbacks[prefix(name)];
};
CallbackRegistry.prototype.add = function (name, callback, context) {
var prefixedEventName = prefix(name);
this._callbacks[prefixedEventName] = this._callbacks[prefixedEventName] || [];
this._callbacks[prefixedEventName].push({
fn: callback,
context: context
});
};
CallbackRegistry.prototype.remove = function (name, callback, context) {
if (!name && !callback && !context) {
this._callbacks = {};
return;
}
var names = name ? [prefix(name)] : Collections.keys(this._callbacks);
if (callback || context) {
this.removeCallback(names, callback, context);
}
else {
this.removeAllCallbacks(names);
}
};
CallbackRegistry.prototype.removeCallback = function (names, callback, context) {
Collections.apply(names, function (name) {
this._callbacks[name] = Collections.filter(this._callbacks[name] || [], function (oning) {
return (callback && callback !== oning.fn) ||
(context && context !== oning.context);
});
if (this._callbacks[name].length === 0) {
delete this._callbacks[name];
}
}, this);
};
CallbackRegistry.prototype.removeAllCallbacks = function (names) {
Collections.apply(names, function (name) {
delete this._callbacks[name];
}, this);
};
return CallbackRegistry;
}());
function prefix(name) {
return "_" + name;
}
function __ajax(options) {
options = options || {};
options.type = (options.type || 'GET').toUpperCase();
options.dataType = options.dataType || 'json';
var params = formatParams(options.data);
var xhr;
if (window.XMLHttpRequest) {
xhr = new XMLHttpRequest();
} else {
xhr = ActiveXObject('Microsoft.XMLHTTP');
}
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
var status = xhr.status;
if (status >= 200 && status < 300) {
options.success && options.success(xhr.responseText, xhr.responseXML);
} else {
options.error && options.error(status);
}
}
}
if (options.type === 'GET') {
xhr.open('GET', options.url + '?' + params, true);
xhr.send(null);
} else if (options.type === 'POST') {
xhr.open('POST', options.url, true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.send(params);
}
}
function formatParams(data) {
var arr = [];
for (var name in data) {
arr.push(encodeURIComponent(name) + '=' + encodeURIComponent(data[name]));
}
return arr.join('&');
}

View File

@@ -0,0 +1,192 @@
import axios from 'axios'
import { Message } from '@arco-design/web-vue'
import tool from '@/utils/tool'
import { get, isEmpty } from 'lodash'
import qs from 'qs'
import { h } from 'vue'
import { IconFaceFrownFill } from '@arco-design/web-vue/dist/arco-vue-icon'
import router from '@/router'
function createExternalService() {
// 创建一个外部网络 axios 实例
const service = axios.create()
// HTTP request 拦截器
service.interceptors.request.use(
(config) => config,
(error) => Promise.reject(error)
)
// HTTP response 拦截器
service.interceptors.response.use(
(response) => response,
(error) => {
Promise.reject(error.response ?? null)
}
)
return service
}
function createService() {
// 创建一个 axios 实例
const service = axios.create()
// HTTP request 拦截器
service.interceptors.request.use(
(config) => config,
(error) => {
console.log(error)
// 失败
return Promise.reject(error)
}
)
// HTTP response 拦截器
service.interceptors.response.use(
(response) => {
if (
(response.headers['content-disposition'] ||
!/^application\/json/.test(response.headers['content-type'])) &&
response.status === 200
) {
return response
} else if (response.data.size) {
response.data.code = 500
response.data.message = '服务器内部错误'
response.data.success = false
} else if (response.data.code && response.data.code !== 200) {
if (response.data.code === 401) {
throttle(() => {
Message.error({
content: response.data.message || response.data.msg,
icon: () => h(IconFaceFrownFill)
})
tool.local.clear()
router.push({ name: 'login' })
})()
} else {
Message.error({
content: response.data.message || response.data.msg,
icon: () => h(IconFaceFrownFill)
})
}
}
return response.data
},
(error) => {
const err = (text) => {
Message.error({
content:
error.response && error.response.data && error.response.data.message
? error.response.data.message
: text,
icon: () => h(IconFaceFrownFill)
})
}
if (error.response && error.response.data) {
switch (error.response.status) {
case 404:
err('服务器资源不存在')
break
case 500:
err('服务器内部错误')
break
case 401:
throttle(() => {
err('登录状态已过期,需要重新登录')
tool.local.clear()
router.push({ name: 'login' })
})()
break
case 403:
err('没有权限访问该资源')
break
default:
err('未知错误!')
}
} else {
err('请求超时,服务器无响应!')
}
return Promise.resolve({
code: error.response.status || 500,
message: error.response.statusText || '未知错误'
})
}
)
return service
}
//节流
function throttle(fn, wait = 1500) {
return function () {
let context = this
if (!throttle.timer) {
fn.apply(context, arguments)
throttle.timer = setTimeout(function () {
throttle.timer = null
}, wait)
}
}
}
function stringify(data) {
return qs.stringify(data, { allowDots: true, encode: false })
}
function formatToken(token) {
return token ? `Bearer ${token}` : null
}
/**
* @description 创建请求方法
* @param service
* @param externalService
*/
function createRequest(service, externalService) {
return function (config) {
const env = import.meta.env
const token = tool.local.get(env.VITE_APP_TOKEN_PREFIX)
const setting = tool.local.get('setting')
const configDefault = {
headers: Object.assign(
{
Authorization: formatToken(token),
'Accept-Language': setting?.language || 'zh_CN',
'Content-Type': get(
config,
'headers.Content-Type',
'application/json;charset=UTF-8'
)
},
config.headers
),
timeout: 10000,
data: {}
}
delete config.headers
// return
const option = Object.assign(configDefault, config)
// json
if (!isEmpty(option.params)) {
option.url = option.url + '?' + stringify(option.params)
option.params = {}
}
if (!/^(http|https)/g.test(option.url)) {
option.baseURL =
env.VITE_APP_OPEN_PROXY === 'true'
? env.VITE_APP_PROXY_PREFIX
: env.VITE_APP_BASE_URL
return service(option)
} else {
return externalService(option)
}
}
}
export const service = createService()
export const externalService = createExternalService()
export const request = createRequest(service, externalService)

View File

@@ -0,0 +1,525 @@
import CryptoJS from 'crypto-js'
import CityLinkageJson from '@/components/ma-cityLinkage/lib/city.json'
/**
* 根据类型获取颜色
*/
const typeColor = (type = 'default') => {
let color = ''
switch (type) {
case 'default':
color = '#35495E'
break
case 'primary':
color = '#3488ff'
break
case 'success':
color = '#43B883'
break
case 'warning':
color = '#e6a23c'
break
case 'danger':
color = '#f56c6c'
break
default:
break
}
return color
}
const tool = {}
/**
* LocalStorage
*/
tool.local = {
set(table, settings) {
let _set = JSON.stringify(settings)
return localStorage.setItem(table, _set)
},
get(table) {
let data = localStorage.getItem(table)
try {
data = JSON.parse(data)
} catch (err) {
return null
}
return data
},
remove(table) {
return localStorage.removeItem(table)
},
clear() {
return localStorage.clear()
}
}
/**
* SessionStorage
*/
tool.session = {
set(table, settings) {
let _set = JSON.stringify(settings)
return sessionStorage.setItem(table, _set)
},
get(table) {
let data = sessionStorage.getItem(table)
try {
data = JSON.parse(data)
} catch (err) {
return null
}
return data
},
remove(table) {
return sessionStorage.removeItem(table)
},
clear() {
return sessionStorage.clear()
}
}
/**
* CookieStorage
*/
tool.cookie = {
set(name, value, config = {}) {
var cfg = {
expires: null,
path: null,
domain: null,
secure: false,
httpOnly: false,
...config
}
var cookieStr = `${name}=${escape(value)}`
if (cfg.expires) {
var exp = new Date()
exp.setTime(exp.getTime() + parseInt(cfg.expires) * 1000)
cookieStr += `;expires=${exp.toGMTString()}`
}
if (cfg.path) {
cookieStr += `;path=${cfg.path}`
}
if (cfg.domain) {
cookieStr += `;domain=${cfg.domain}`
}
document.cookie = cookieStr
},
get(name) {
var arr = document.cookie.match(
new RegExp('(^| )' + name + '=([^;]*)(;|$)')
)
if (arr != null) {
return unescape(arr[2])
} else {
return null
}
},
remove(name) {
var exp = new Date()
exp.setTime(exp.getTime() - 1)
document.cookie = `${name}=;expires=${exp.toGMTString()}`
}
}
/**
* 全屏操作
*/
tool.screen = (element) => {
let isFull = !!(
document.webkitIsFullScreen ||
document.mozFullScreen ||
document.msFullscreenElement ||
document.fullscreenElement
)
if (isFull) {
if (document.exitFullscreen) {
document.exitFullscreen()
} else if (document.msExitFullscreen) {
document.msExitFullscreen()
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen()
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen()
}
} else {
if (element.requestFullscreen) {
element.requestFullscreen()
} else if (element.msRequestFullscreen) {
element.msRequestFullscreen()
} else if (element.mozRequestFullScreen) {
element.mozRequestFullScreen()
} else if (element.webkitRequestFullscreen) {
element.webkitRequestFullscreen()
}
}
}
/**
* 获取设备信息
*/
tool.getDevice = function () {
const hasTouchScreen =
'ontouchstart' in window || navigator.maxTouchPoints > 0
const isSmallScreen = window.innerWidth < 768
if (hasTouchScreen && isSmallScreen) {
return 'mobile'
} else {
return 'desktop'
}
}
/**
* 处理图片
*/
tool.parseImage = (url) => {
if (url === undefined) {
return import.meta.env.VITE_APP_BASE + 'not-image.png'
}
if (typeof url === 'string' && url !== null) {
return url
} else {
if (url !== null) {
return url[0]
} else {
return import.meta.env.VITE_APP_BASE + 'not-image.png'
}
}
}
/**
* 城市代码翻译成名称
*/
tool.cityToCode = function (
province,
city = undefined,
area = undefined,
split = ' / '
) {
try {
let provinceData = CityLinkageJson.filter(
(item) => province == item.code
)[0]
if (!city) {
return provinceData.name
}
let cityData = provinceData.children.filter((item) => city == item.code)[0]
if (!area) {
return [provinceData.name, cityData.name].join(split)
}
let areaData = cityData.children.filter((item) => area == item.code)[0]
return [provinceData.name, cityData.name, areaData.name].join(split)
} catch (e) {
return ''
}
}
/**
* 复制对象
*/
tool.objCopy = (obj) => {
if (obj === undefined) {
return undefined
}
return JSON.parse(JSON.stringify(obj))
}
/**
* 生车随机id
*/
tool.generateId = function () {
return Math.floor(
Math.random() * 100000 + Math.random() * 20000 + Math.random() * 5000
)
}
/**
* 生成全球唯一标识
* @returns uuid
*/
tool.uuid = () => {
const hexList = []
for (let i = 0; i <= 15; i++) {
hexList[i] = i.toString(16)
}
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
}
/**
* 日期格式化
*/
tool.dateFormat = (date, fmt = 'yyyy-MM-dd hh:mm:ss', isDefault = '-') => {
if (!date) date = Number(new Date())
if (date.toString().length == 10) {
date = date * 1000
}
date = new Date(date)
if (date.valueOf() < 1) {
return isDefault
}
let o = {
'M+': date.getMonth() + 1, //月份
'd+': date.getDate(), //日
'h+': date.getHours(), //小时
'm+': date.getMinutes(), //分
's+': date.getSeconds(), //秒
'q+': Math.floor((date.getMonth() + 3) / 3), //季度
S: date.getMilliseconds() //毫秒
}
if (/(y+)/.test(fmt)) {
fmt = fmt.replace(
RegExp.$1,
(date.getFullYear() + '').substr(4 - RegExp.$1.length)
)
}
for (let k in o) {
if (new RegExp('(' + k + ')').test(fmt)) {
fmt = fmt.replace(
RegExp.$1,
RegExp.$1.length == 1 ? o[k] : ('00' + o[k]).substr(('' + o[k]).length)
)
}
}
return fmt
}
/**
* 千分符
*/
tool.groupSeparator = (num) => {
num = num + ''
if (!num.includes('.')) {
num += '.'
}
return num
.replace(/(\d)(?=(\d{3})+\.)/g, function ($0, $1) {
return $1 + ','
})
.replace(/\.$/, '')
}
/**
* md5加密
*/
tool.md5 = (str) => {
return CryptoJS.MD5(str).toString()
}
/**
* Base64加密解密
*/
tool.base64 = {
encode(data) {
return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(data))
},
decode(cipher) {
return CryptoJS.enc.Base64.parse(cipher).toString(CryptoJS.enc.Utf8)
}
}
/**
* AES加密解密
*/
tool.aes = {
encode(data, secretKey) {
const result = CryptoJS.AES.encrypt(
data,
CryptoJS.enc.Utf8.parse(secretKey),
{
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
}
)
return result.toString()
},
decode(cipher, secretKey) {
const result = CryptoJS.AES.decrypt(
cipher,
CryptoJS.enc.Utf8.parse(secretKey),
{
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
}
)
return CryptoJS.enc.Utf8.stringify(result)
}
}
/**
* 打印信息
*/
tool.capsule = (title, info, type = 'primary') => {
console.log(
`%c ${title} %c ${info} %c`,
'background:#35495E; padding: 1px; border-radius: 3px 0 0 3px; color: #fff;',
`background:${typeColor(
type
)}; padding: 1px; border-radius: 0 3px 3px 0; color: #fff;`,
'background:transparent'
)
}
/**
* 文件大小单位处理
*/
tool.formatSize = (size) => {
if (typeof size == 'undefined') {
return '0'
}
let units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
let index = 0
for (let i = 0; size >= 1024 && i < 5; i++) {
size /= 1024
index = i
}
return Math.round(size, 2) + units[index]
}
/**
* 下载资源
*/
tool.download = (res, downName = '') => {
const aLink = document.createElement('a')
let fileName = downName
let blob = res //第三方请求返回blob对象
//通过后端接口返回
if (res.headers && res.data) {
blob = new Blob([res.data], {
type: res.headers['content-type'].replace(';charset=utf8', '')
})
if (!downName) {
const contentDisposition = decodeURI(res.headers['content-disposition'])
const result = contentDisposition.match(/filename=\"(.+)/gi)
fileName = result[0].replace(/filename=\"/gi, '')
fileName = fileName.replace('"', '')
}
}
aLink.href = URL.createObjectURL(blob)
// 设置下载文件名称
aLink.setAttribute('download', fileName)
document.body.appendChild(aLink)
aLink.click()
document.body.removeChild(aLink)
URL.revokeObjectURL(aLink.href)
}
/**
* 对象转url参数
* @param {*} data
* @param {*} isPrefix
*/
tool.httpBuild = (data, isPrefix = false) => {
let prefix = isPrefix ? '?' : ''
let _result = []
for (let key in data) {
let value = data[key]
// 去掉为空的参数
if (['', undefined, null].includes(value)) {
continue
}
if (value.constructor === Array) {
value.forEach((_value) => {
_result.push(
encodeURIComponent(key) + '[]=' + encodeURIComponent(_value)
)
})
} else {
_result.push(encodeURIComponent(key) + '=' + encodeURIComponent(value))
}
}
return _result.length ? prefix + _result.join('&') : ''
}
/**
* 获取URL请求参数
*/
tool.getRequestParams = (url) => {
const theRequest = new Object()
if (url.indexOf('?') != -1) {
const params = url.split('?')[1].split('&')
for (let i = 0; i < params.length; i++) {
const param = params[i].split('=')
theRequest[param[0]] = decodeURIComponent(param[1])
}
}
return theRequest
}
tool.attachUrl = (path) => {
// 非完整url地址在此处理
return path
}
tool.viewImage = (path) => {
// 非完整url地址在此处理
return path
}
tool.showFile = (path) => {
// 非完整url地址在此处理
return path
}
/**
* 获取token
*/
tool.getToken = () => {
return tool.local.get(import.meta.env.VITE_APP_TOKEN_PREFIX)
}
/**
* 转Unix时间戳
*/
tool.toUnixTime = (date) => {
return Math.floor(new Date(date).getTime() / 1000)
}
/**
* 通过value获取颜色
*/
tool.getColor = (value, data, colors = []) => {
if (!data) {
return ''
}
if (colors && colors.length > 0) {
const index = data.findIndex((item) => item.value == value)
return colors[index] ?? ''
} else {
const item = data.find((item) => item.value == value)
return item?.color ?? ''
}
}
/**
* 通过value获取label
*/
tool.getLabel = (value, data) => {
if (!data) {
return ''
}
const item = data.find((item) => item.value == value)
return item?.label ?? ''
}
export default tool

View File

@@ -0,0 +1,56 @@
<template>
<div class="w-full lg:w-9/12 ma-content-block rounded-sm p-3 mt-3">
<div class="flex justify-between">
系统公告
<a-link>更多</a-link>
</div>
<a-table :data="data" :columns="columns" class="mt-2" :pagination="false">
<template #title="{ record }">
<a-link @click="viewDetail(record)">{{ record.title }}</a-link>
</template>
</a-table>
<a-modal v-model:visible="detailVisible" fullscreen :footer="false">
<template #title>公告详情</template>
<a-typography :style="{ marginTop: '-30px' }">
<a-typography-title class="text-center">
{{ row?.title }}
</a-typography-title>
<a-typography-paragraph class="text-right" style="font-size: 13px; color: var(--color-text-3)">
<a-space size="large">
<span>创建时间{{ row?.create_time }}</span>
</a-space>
</a-typography-paragraph>
<a-typography-paragraph>
<div v-html="row?.content"></div>
</a-typography-paragraph>
</a-typography>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import commonApi from '@/api/common'
const data = ref([])
const columns = reactive([
{ title: '标题', dataIndex: 'title', slotName: 'title' },
{ title: '发布时间', dataIndex: 'create_time', width: 180, align: 'right' },
])
const row = ref({})
const detailVisible = ref(false)
const viewDetail = async (record) => {
row.value = record
detailVisible.value = true
}
const getNoticeList = async () => {
const res = await commonApi.getNoticeList({ limit: 5, orderBy: 'id', orderType: 'desc' })
data.value = res.data.data
}
getNoticeList()
</script>

View File

@@ -0,0 +1,90 @@
<template>
<div class="w-full mx-auto">
<a-grid :cols="{ xs: 1, sm: 12, md: 24 }" :row-gap="16" class="panel ma-content-block mt-3 p-4">
<a-grid-item class="panel-col" :span="6">
<a-space>
<a-avatar :size="54" class="col-avatar" style="padding: 10px">
<img alt="avatar" src="@/assets/image/user.svg" />
</a-avatar>
<a-statistic title="用户统计" :value="data.user" :value-from="0" animation show-group-separator>
<template #suffix><span class="unit"></span> </template>
</a-statistic>
</a-space>
</a-grid-item>
<a-grid-item class="panel-col" :span="6">
<a-space>
<a-avatar :size="54" class="col-avatar" style="padding: 10px">
<img alt="avatar" src="@/assets/image/attach.svg" />
</a-avatar>
<a-statistic title="附件统计" :value="data.attach" :value-from="0" animation show-group-separator>
<template #suffix><span class="unit"></span> </template>
</a-statistic>
</a-space>
</a-grid-item>
<a-grid-item class="panel-col" :span="6">
<a-space>
<a-avatar :size="54" class="col-avatar" style="padding: 10px">
<img alt="avatar" src="@/assets/image/login.svg" />
</a-avatar>
<a-statistic title="登录统计" :value="data.login" :value-from="0" animation show-group-separator>
<template #suffix><span class="unit"></span> </template>
</a-statistic>
</a-space>
</a-grid-item>
<a-grid-item class="panel-col" :span="6">
<a-space>
<a-avatar :size="54" class="col-avatar" style="padding: 10px">
<img alt="avatar" src="@/assets/image/action.svg" />
</a-avatar>
<a-statistic title="操作统计" :value="data.operate" :value-from="0" animation show-group-separator>
<template #suffix><span class="unit"></span> </template>
</a-statistic>
</a-space>
</a-grid-item>
</a-grid>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import api from '@/api/common'
const data = ref({
user: 0,
attach: 0,
login: 0,
operate: 0
})
const getData = async () => {
const res = await api.getStatistics()
data.value = res.data
}
getData()
</script>
<style scoped lang="less">
.arco-grid.panel {
margin-bottom: 0;
}
.panel-col {
padding-left: 43px;
border-right: 1px solid rgb(var(--gray-2));
}
.col-avatar {
margin-right: 12px;
background-color: var(--color-fill-2);
}
.up-icon {
color: rgb(var(--red-6));
}
.unit {
margin-left: 8px;
color: rgb(var(--gray-8));
font-size: 12px;
}
:deep(.panel-border) {
margin: 4px 0 0 0;
}
</style>

View File

@@ -0,0 +1,184 @@
<template>
<div class="ma-content-block p-3 mt-3">
<a-card
:bordered="false"
class="general-card"
:header-style="{ paddingTop: '10px', paddingBottom: 0 }"
:body-style="{
paddingTop: '20px',
}"
title="登录统计">
<sa-chart height="300px" :option="loginChartOptions" />
</a-card>
</div>
</template>
<script setup>
import { nextTick, onMounted, ref } from 'vue'
import { graphic } from 'echarts'
import api from '@/api/common'
function graphicFactory(side) {
return {
type: 'text',
bottom: '8',
...side,
style: {
text: '',
textAlign: 'center',
fill: '#4E5969',
fontSize: 12,
},
}
}
const xAxis = ref([])
const chartsData = ref([])
const graphicElements = ref([graphicFactory({ left: '2.6%' }), graphicFactory({ right: 0 })])
const loginChartOptions = ref({})
const getData = async () => {
const res = await api.loginChart()
xAxis.value = res.data.login_date
chartsData.value = res.data.login_count
loginChartOptions.value = {
grid: {
left: '2.6%',
right: '0',
top: '10',
bottom: '30',
},
xAxis: {
type: 'category',
offset: 2,
data: xAxis.value,
boundaryGap: false,
axisLabel: {
color: '#4E5969',
formatter(value, idx) {
if (idx === 0) return ''
if (idx === xAxis.value.length - 1) return ''
return `${value}`
},
},
axisLine: {
show: false,
},
axisTick: {
show: false,
},
splitLine: {
show: true,
interval: (idx) => {
if (idx === 0) return false
if (idx === xAxis.value.length - 1) return false
return true
},
lineStyle: {
color: '#E5E8EF',
},
},
axisPointer: {
show: true,
lineStyle: {
color: '#23ADFF',
width: 2,
},
},
},
yAxis: {
type: 'value',
axisLine: {
show: false,
},
axisLabel: {
formatter(value, idx) {
if (idx === 0) return value
return `${value}`
},
},
splitLine: {
show: true,
lineStyle: {
type: 'dashed',
color: '#E5E8EF',
},
},
},
tooltip: {
trigger: 'axis',
formatter(params) {
return `<div class="login-chart">
<p class="tooltip-title">${params[0].axisValueLabel}</p>
<div class="content-panel"><span>登录次数</span><span class="tooltip-value">${Number(params[0].value).toLocaleString()}</span></div>
</div>`
},
},
graphic: {
elements: graphicElements.value,
},
series: [
{
data: chartsData.value,
type: 'line',
smooth: true,
symbolSize: 12,
emphasis: {
focus: 'series',
itemStyle: {
borderWidth: 2,
},
},
lineStyle: {
width: 3,
color: new graphic.LinearGradient(0, 0, 1, 0, [
{
offset: 0,
color: 'rgba(30, 231, 255, 1)',
},
{
offset: 0.5,
color: 'rgba(36, 154, 255, 1)',
},
{
offset: 1,
color: 'rgba(111, 66, 251, 1)',
},
]),
},
showSymbol: false,
areaStyle: {
opacity: 0.8,
color: new graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: 'rgba(17, 126, 255, 0.16)',
},
{
offset: 1,
color: 'rgba(17, 128, 255, 0)',
},
]),
},
},
],
}
}
getData()
</script>
<style lang="less" scoped>
.general-card {
border-radius: 4px;
border: none;
:deep(.arco-card-header) {
height: auto;
padding: 20px;
border: none;
}
}
</style>

View File

@@ -0,0 +1,34 @@
<template>
<div class="w-full lg:w-3/12 ma-content-block rounded-sm ml-0 lg:ml-3 p-3 mt-3">
<div class="flex">SaiAdmin 相关</div>
<div class="block lg:grid lg:grid-cols-2 lg:gap-1 mt-3">
<a-card class="rounded-sm text-center" :body-style="{ padding: 0 }" :bordered="false">
<a-button type="outline" class="w-4/5" @click="openPage('https://saithink.top')">官方网站</a-button>
</a-card>
<a-card class="rounded-sm text-center mt-2 lg:mt-0" :body-style="{ padding: 0 }" :bordered="false">
<a-button type="outline" class="w-4/5" @click="openPage('https://saithink.top/guide/introduction/')">
开发文档
</a-button>
</a-card>
<a-card class="rounded-sm text-center mt-2" :body-style="{ padding: 0 }" :bordered="false">
<a-button type="outline" class="w-4/5" @click="openPage('https://github.com/saithink/saiadmin')">
Github
</a-button>
</a-card>
<a-card class="rounded-sm text-center mt-2" :body-style="{ padding: 0 }" :bordered="false">
<a-button type="outline" class="w-4/5" @click="openPage('https://gitee.com/appsai/saiadmin')"> Gitee </a-button>
</a-card>
</div>
<div class="w-11/12 mx-auto mt-3">
<a-tag class="ml-0.5">SaiAdmin v{{ config.version }} release</a-tag>
</div>
</div>
</template>
<script setup>
import config from '@/../package.json'
const openPage = (url = '') => {
window.open(url)
}
</script>

View File

@@ -0,0 +1,58 @@
<template>
<div class="flex justify-between">
<div class="ma-content-block rounded-sm flex justify-between w-full p-3">
<div class="pl-0 flex inline-block">
<a-avatar :size="75" class="hidden lg:inline-block">
<img :src="userStore.user && userStore.user.avatar ? userStore.user.avatar : avatar" />
</a-avatar>
<div class="pl-3 mt-2">
<div class="content-block-title">{{ userStore.user.nickname || userStore.user.username }}欢迎回来</div>
<div class="leading-5 mt-2">
<a-tag class="tag-primary">免费开源可商用</a-tag>
欢迎使用SaiAdmin后台权限管理系统喜欢的请去点个 Star谢谢
</div>
</div>
</div>
<div class="datetime ml-5 hidden md:block">
<h2 class="text-3xl text-center">{{ time }}</h2>
<p class="text-base">{{ day }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useUserStore } from '@/store'
import dayjs from 'dayjs'
import avatar from '@/assets/avatar.jpg'
const userStore = useUserStore()
const visible = ref(false)
const time = ref(null)
const day = ref(null)
onMounted(() => {
showTime()
setInterval(() => showTime(), 1000)
})
const showTime = () => {
time.value = dayjs().format('HH:mm:ss')
day.value = dayjs().format('YYYY年MM月DD日')
}
const donate = () => (visible.value = true)
</script>
<style scoped>
.datetime {
background: rgb(var(--primary-6));
color: #fff;
width: 160px;
text-align: center;
border-radius: 3px;
padding: 5px 10px;
}
</style>

View File

@@ -0,0 +1,18 @@
<template>
<st-welcome />
<st-count />
<st-login-chart />
<div class="block lg:flex">
<st-announced />
<st-saiadmin />
</div>
</template>
<script setup>
import StCount from './components/st-count.vue'
import StWelcome from './components/st-welcome.vue'
import StLoginChart from './components/st-loginChart.vue'
import StSaiadmin from './components/st-saiadmin.vue'
import StAnnounced from './components/st-announced.vue'
</script>

View File

@@ -0,0 +1,3 @@
<template>
<div>自行开发</div>
</template>

View File

@@ -0,0 +1,16 @@
<template>
<a-layout-content class="flex flex-col">
<statistics v-if="userStore.user && userStore.user.dashboard === 'statistics'" />
<work-panel v-else-if="userStore.user" />
</a-layout-content>
</template>
<script setup>
import { useUserStore } from '@/store'
import Statistics from './components/statistics.vue'
import WorkPanel from './components/work-panel.vue'
const userStore = useUserStore()
</script>
<script>
export default { name: 'dashboard' }
</script>

View File

@@ -0,0 +1,128 @@
<template>
<a-form class="w-full md:w-full mt-3" :model="password" @submit="modifyPassword">
<a-form-item
label="旧密码"
field="oldPassword"
label-col-flex="80px"
:rules="[{ required: true, message: '旧密码必填'}]"
>
<a-input-password
v-model="password.oldPassword"
allow-clear
autocomplete="off"
/>
</a-form-item>
<a-form-item
label="新密码"
field="newPassword"
label-col-flex="80px"
:rules="[{ required: true, message: '新密码必填'}]"
>
<a-input-password
v-model="password.newPassword"
@input="checkSafe"
@clear="() => passwordSafePercent = 0"
autocomplete="off"
allow-clear
/>
</a-form-item>
<a-form-item label="密码安全" label-col-flex="80px">
<a-progress
:steps="3"
status="success"
:percent="passwordSafePercent"
animation
:show-text="false"
/>
</a-form-item>
<a-form-item
label="确认密码"
field="newPassword_confirmation"
label-col-flex="80px"
:rules="[{ required: true, message: '确认密码必填' }]"
>
<a-input-password
allow-clear
v-model="password.newPassword_confirmation"
autocomplete="off"
/>
</a-form-item>
<a-form-item label-col-flex="80px">
<a-button html-type="submit" type="primary">保存</a-button>
</a-form-item>
</a-form>
<a-modal v-model:visible="visible" @ok="resetLogin">
<template #title>提示</template>
密码已经修改成功需要重新登录系统点击确定跳转登录页面
</a-modal>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { Message } from '@arco-design/web-vue'
import user from '@/api/system/user'
import tool from '@/utils/tool'
import { useRouter } from 'vue-router'
const router = useRouter()
const password = reactive({
oldPassword: '',
newPassword: '',
newPassword_confirmation: ''
})
const visible = ref(false)
const passwordSafePercent = ref(0)
const resetLogin = () => {
router.push({name:'login'})
}
const modifyPassword = async (data) => {
if (! data.errors) {
if (data.values.newPassword !== data.values.newPassword_confirmation) {
Message.error('确认密码与新密码不一致')
return
}
const response = await user.modifyPassword(data.values)
if (response.code === 200) {
tool.local.clear()
visible.value = true
} else {
Message.error(response.message)
}
}
}
const checkSafe = (password) => {
if (password.length < 1) {
passwordSafePercent.value = 0
return
}
if (! (password.length >= 6) ) {
passwordSafePercent.value = 0
return
}
passwordSafePercent.value = 0.1
if (/\d/.test(password)) {
passwordSafePercent.value += 0.1
}
if (/[a-z]/.test(password)) {
passwordSafePercent.value += 0.1
}
if (/[A-Z]/.test(password)) {
passwordSafePercent.value += 0.3
}
if (/[`~!@#$%^&*()_+<>?:"{},./;'[\]]/.test(password)) {
passwordSafePercent.value += 0.4
}
}
</script>

View File

@@ -0,0 +1,45 @@
<template>
<a-form class="w-full md:w-full mt-3" :model="userInfo" @submit="modifyInfo">
<a-form-item label="账户名" label-col-flex="80px">
<a-input disabled :default-value="userInfo.username" allow-clear />
</a-form-item>
<a-form-item label="昵称" label-col-flex="80px">
<a-input v-model="userInfo.nickname" allow-clear />
</a-form-item>
<a-form-item label="手机" label-col-flex="80px">
<a-input v-model="userInfo.phone" allow-clear />
</a-form-item>
<a-form-item label="邮箱" label-col-flex="80px">
<a-input v-model="userInfo.email" allow-clear />
</a-form-item>
<a-form-item label="个人签名" label-col-flex="80px">
<a-textarea v-model="userInfo.signed" :max-length="255" class="h-28" show-word-limit allow-clear />
</a-form-item>
<a-form-item label-col-flex="80px">
<a-button html-type="submit" type="primary">保存</a-button>
</a-form-item>
</a-form>
</template>
<script setup>
import { reactive } from 'vue'
import { useUserStore } from '@/store'
import { Message } from '@arco-design/web-vue'
import user from '@/api/system/user'
const userStore = useUserStore()
const userInfo = reactive({
...userStore.user
})
const modifyInfo = async (data) => {
data.values.avatar = userStore.user.avatar
const response = await user.updateInfo(data.values)
if (response.code === 200) {
Message.success(response.message)
userStore.user = data.values
return
}
}
</script>

View File

@@ -0,0 +1,113 @@
<template>
<div class="block">
<div class="user-header rounded-sm text-center">
<div class="pt-3 mx-auto avatar-box">
<sa-upload-image v-model="userInfo.avatar" rounded />
</div>
<div>
<a-tag size="large" class="mt-3 rounded-full tag-primary">
{{ (userStore.user && userStore.user.nickname) || (userStore.user && userStore.user.username) }}
</a-tag>
</div>
</div>
<a-layout-content class="block lg:flex lg:justify-between">
<div class="ma-content-block w-full lg:w-6/12 mt-3 p-4">
<a-tabs type="rounded">
<a-tab-pane key="info" title="个人资料">
<user-infomation />
</a-tab-pane>
<a-tab-pane key="safe" title="安全设置">
<modify-password />
</a-tab-pane>
</a-tabs>
</div>
<div class="ma-content-block w-full lg:w-6/12 mt-3 p-4 ml-0 lg:ml-3">
<a-tabs type="rounded">
<a-tab-pane key="login-log" title="登录日志">
<a-timeline class="pl-5 mt-3" v-if="loginLogList && loginLogList.length">
<a-timeline-item :label="`地理位置;${item.ip_location},操作系统:${item.os}`" v-for="(item, idx) in loginLogList" :key="idx">
您于 {{ item.login_time }} 登录系统{{ item.message }}
</a-timeline-item>
</a-timeline>
<a-empty v-else />
</a-tab-pane>
<a-tab-pane key="operation-log" title="操作日志">
<a-timeline class="pl-5 mt-3" v-if="operationLogList && operationLogList.length">
<a-timeline-item
:label="`地理位置;${item.ip_location},方式:${item.method},路由:${item.router}`"
v-for="(item, idx) in operationLogList"
:key="idx">
您于 {{ item.create_time }} 执行了 {{ item.service_name }}
</a-timeline-item>
</a-timeline>
<a-empty v-else />
</a-tab-pane>
</a-tabs>
</div>
</a-layout-content>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { useUserStore } from '@/store'
import { Message } from '@arco-design/web-vue'
import user from '@/api/system/user'
import commonApi from '@/api/common'
import ModifyPassword from './components/modifyPassword.vue'
import UserInfomation from './components/userInfomation.vue'
const userStore = useUserStore()
const userInfo = reactive({
...userStore.user,
})
const loginLogList = ref([])
const operationLogList = ref([])
const requestParams = reactive({
limit: 5,
})
onMounted(() => {
commonApi.getLoginLogList(Object.assign(requestParams, { orderBy: 'login_time', orderType: 'desc' })).then((res) => {
loginLogList.value = res.data.data
})
commonApi.getOperationLogList(Object.assign(requestParams, { orderBy: 'create_time', orderType: 'desc' })).then((res) => {
operationLogList.value = res.data.data
})
})
userInfo.avatar = userStore?.user?.avatar ?? undefined
watch(
() => userInfo.avatar,
async (newAvatar) => {
if (newAvatar) {
const response = await user.updateInfo({ avatar: newAvatar })
if (response.code === 200) {
Message.success('头像修改成功')
userStore.user.avatar = newAvatar
}
}
}
)
</script>
<script>
export default { name: 'userCenter' }
</script>
<style scoped>
.avatar-box {
width: 130px;
}
.user-header {
width: 100%;
height: 200px;
background: url('@/assets/userBanner.jpg') no-repeat;
background-size: cover;
}
</style>

View File

@@ -0,0 +1,297 @@
<script setup>
import { reactive, ref } from 'vue'
import loginApi from '@/api/login'
import { useUserStore } from '@/store'
import { useRouter, useRoute } from 'vue-router'
import packageJson from '../../package.json'
import { useAppStore } from '@/store'
const appStore = useAppStore()
const router = useRouter()
const route = useRoute()
const captcha = ref(null)
const loading = ref(false)
let isDevelop = import.meta.env.VITE_APP_ENV === 'development'
var odata = isDevelop ? { username: 'admin', password: '123456', code: '' } : { username: '', password: '', code: '' }
const form = reactive(odata)
const refreshCaptcha = () => {
form.code = ''
form.uuid = ''
loginApi.getCaptch().then((res) => {
if (res.code === 200) {
captcha.value = res.data.image
form.uuid = res.data.uuid
}
})
}
refreshCaptcha()
const userStore = useUserStore()
const redirect = route.query.redirect ? route.query.redirect : '/'
const handleSubmit = async ({ values, errors }) => {
if (loading.value) {
return
}
loading.value = true
if (!errors) {
const result = await userStore.login(form)
if (!result) {
loading.value = false
refreshCaptcha()
return
}
router.push(redirect)
}
loading.value = false
}
</script>
<template>
<div class="login-container" :style="{ background: appStore.mode === 'dark' ? '#2e2e30e3' : '' }">
<h3 class="login-logo">
<img src="../assets/logo.png" alt="logo" />
<span>{{ $title }}</span>
</h3>
<div class="login-width md:w-10/12 w-11/12 mx-auto flex justify-between h-full items-center">
<div class="w-6/12 mx-auto left-panel rounded-l pl-5 pr-5 hidden md:block">
<div class="logo">
<span>{{ $title }} v{{ packageJson.version }}</span>
</div>
<div class="slogan flex justify-end">
<span>---- {{ $t('sys.login.slogan') }}</span>
</div>
</div>
<div class="md:w-6/12 w-11/12 md:rounded-r mx-auto pl-5 pr-5 pb-10">
<h2 class="mt-10 text-3xl pb-0 mb-10 login-title">{{ $t('sys.login.title') }}</h2>
<a-form :model="form" @submit="handleSubmit">
<a-form-item
field="username"
:hide-label="true"
:rules="[{ required: true, message: $t('sys.login.usernameNotice') }]">
<a-input
v-model="form.username"
class="w-full"
size="large"
:placeholder="$t('sys.login.username')"
allow-clear>
<template #prefix><icon-user /></template>
</a-input>
</a-form-item>
<a-form-item
field="password"
:hide-label="true"
:rules="[{ required: true, message: $t('sys.login.passwordNotice') }]">
<a-input-password v-model="form.password" :placeholder="$t('sys.login.password')" size="large" allow-clear>
<template #prefix><icon-lock /></template>
</a-input-password>
</a-form-item>
<a-form-item
field="code"
:hide-label="true"
:rules="[
{
required: true,
match: /^[a-zA-Z0-9]{4}$/,
message: $t('sys.login.verifyCodeNotice'),
},
]">
<a-input v-model="form.code" :placeholder="$t('sys.login.verifyCode')" size="large" allow-clear>
<template #prefix><icon-safe /></template>
<template #append>
<img :src="captcha" style="height: 120px; height: 36px; cursor: pointer" @click="refreshCaptcha" />
</template>
</a-input>
</a-form-item>
<a-form-item :hide-label="true" class="mt-5">
<a-button html-type="submit" type="primary" long size="large" :loading="loading">
{{ $t('sys.login.loginBtn') }}
</a-button>
</a-form-item>
<a-divider orientation="center">{{ $t('sys.login.otherLoginType') }}</a-divider>
<div class="flex w-3/4 pt-2 mx-auto items-stretch justify-around">
<a-avatar class="other-login wechat"><icon-wechat /></a-avatar>
<a-avatar class="other-login alipay"><icon-alipay-circle /></a-avatar>
<a-avatar class="other-login qq"><icon-qq /></a-avatar>
<a-avatar class="other-login weibo"><icon-weibo /></a-avatar>
</div>
</a-form>
</div>
</div>
<div class="login-bg">
<div class="fly bg-fly-circle1"></div>
<div class="fly bg-fly-circle2"></div>
<div class="fly bg-fly-circle3"></div>
<div class="fly bg-fly-circle4"></div>
</div>
</div>
</template>
<style scoped lang="less">
.login-container {
width: 100%;
height: 100%;
position: absolute;
background: rgb(145 185 233 / 50%);
background-size: cover;
z-index: 3;
.login-logo {
width: 100%;
height: 80px;
font-weight: 700;
font-size: 20px;
line-height: 32px;
display: flex;
padding: 0 20px;
align-items: center;
color: var(--color-text-1);
img {
width: 45px;
height: 45px;
margin-right: 10px;
}
}
.login-width {
max-width: 950px;
background-color: var(--color-bg-3);
padding: 10px;
height: 500px;
position: relative;
top: 40%;
margin-top: -255px;
border-radius: 10px;
z-index: 999;
box-shadow: 0 2px 4px 2px #00000014;
}
.left-panel {
height: 491px;
background-image: url(@/assets/login_picture.svg);
background-repeat: no-repeat;
background-position: center 60px;
background-size: contain;
}
.logo {
display: flex;
margin-top: 20px;
color: #333;
span {
font-size: 28px;
margin-left: 15px;
color: rgb(var(--primary-6));
}
}
.slogan {
font-size: 16px;
line-height: 50px;
color: var(--color-text-1);
}
.login-title {
color: var(--color-text-1);
}
:deep(.arco-input-append) {
padding: 0 !important;
}
.other-login {
cursor: pointer;
}
.qq:hover,
.alipay:hover {
background: #165dff;
}
.wechat:hover {
background: #0f9c02;
}
.weibo:hover {
background: #f3ce2b;
}
}
.login-bg {
width: 100%;
overflow: hidden;
z-index: 1;
}
.fly {
pointer-events: none;
position: fixed;
z-index: 9999;
}
.bg-fly-circle1 {
left: 40px;
top: 100px;
width: 100px;
height: 100px;
border-radius: 50%;
background: linear-gradient(to right, rgba(var(--primary-6), 0.07), rgba(var(--primary-6), 0.04));
animation: move-ce64e0ea 2.5s linear infinite;
}
.bg-fly-circle2 {
left: 15%;
bottom: 5%;
width: 150px;
height: 150px;
border-radius: 50%;
background: linear-gradient(to right, rgba(var(--primary-6), 0.08), rgba(var(--primary-6), 0.04));
animation: move-ce64e0ea 3s linear infinite;
}
.bg-fly-circle3 {
right: 12%;
top: 90px;
width: 145px;
height: 145px;
border-radius: 50%;
background: linear-gradient(to right, rgba(var(--primary-6), 0.1), rgba(var(--primary-6), 0.04));
animation: move-ce64e0ea 2.5s linear infinite;
}
.bg-fly-circle4 {
right: 5%;
top: 60%;
width: 160px;
height: 160px;
border-radius: 50%;
background: linear-gradient(to right, rgba(var(--primary-6), 0.02), rgba(var(--primary-6), 0.04));
animation: move-ce64e0ea 3.5s linear infinite;
}
@keyframes move-ce64e0ea {
0% {
transform: translateY(0) scale(1);
}
50% {
transform: translateY(25px) scale(1.1);
}
to {
transform: translateY(0) scale(1);
}
}
</style>

View File

@@ -0,0 +1,285 @@
<template>
<div class="ma-content-block lg:flex justify-between">
<div class="w-full p-4 pr-4 border-r border-gray-100 lg:w-2/12">
<sa-tree-slider
:data="sliderData"
:border="false"
search-placeholder="搜索资源类型"
:field-names="{ title: 'label', key: 'value' }"
@click="handlerClick"
icon="icon-folder"
v-model="defaultKey" />
</div>
<div class="lg:w-10/12 w-full">
<!-- CRUD 组件 -->
<sa-table ref="crudRef" :options="options" :columns="columns" :searchForm="searchForm">
<!-- 搜索区 start -->
<template #tableSearch>
<a-col :sm="7" :xs="24">
<a-form-item field="origin_name" label="原文件名">
<a-input v-model="searchForm.origin_name" placeholder="请输入原文件名" allow-clear />
</a-form-item>
</a-col>
<a-col :sm="7" :xs="24">
<a-form-item field="storage_mode" label="存储模式">
<sa-select v-model="searchForm.storage_mode" dict="upload_mode" placeholder="请选择存储模式" />
</a-form-item>
</a-col>
<a-col :sm="10" :xs="24">
<a-form-item field="create_time" label="上传时间">
<a-range-picker v-model="searchForm.create_time" />
</a-form-item>
</a-col>
</template>
<!-- 搜索区 end -->
<!-- 表格按钮后置扩展 -->
<template #tableAfterButtons>
<a-input-group v-if="mode === 'window'">
<a-button @click="selectAll">
<template #icon><icon-select-all /></template>全选
</a-button>
<a-button @click="flushAll">
<template #icon><icon-eraser /></template>清除
</a-button>
</a-input-group>
</template>
<!-- 工具按钮扩展 -->
<template #tools>
<a-tooltip :content="mode === 'list' ? '切换橱窗模式' : '切换列表模式'">
<a-button shape="circle" @click="switchMode"
><icon-apps v-if="mode === 'list'" /><icon-list v-else
/></a-button>
</a-tooltip>
</template>
<!-- 自定义内容 -->
<template #crudContent="tableData">
<a-checkbox-group v-if="mode === 'window'" v-model="selecteds" @change="handlerChange">
<a-image-preview-group infinite>
<a-space class="window-list">
<template v-for="record in tableData.data" :key="record.id">
<div class="mb-2 image-content">
<a-checkbox :value="record.id" class="checkbox">
<template #checkbox="{ checked }">
<a-tag :checked="checked" color="arcoblue" checkable><icon-check /> 选择</a-tag>
</template>
</a-checkbox>
<a-image
width="190"
height="190"
show-loader
:title="record.origin_name"
:description="`大小:${record.size_info}`"
:src="/image/g.test(record.mime_type) ? record.url : NotImage">
<template #extra>
<div class="actions">
<a-tooltip content="下载此文件">
<span class="action" @click="download(record)"><icon-download /></span>
</a-tooltip>
<a-tooltip>
<span class="action"><icon-info-circle /></span>
<template #content>
<div>存储名称{{ record.object_name }}</div>
<div>存储目录{{ record.storage_path }}</div>
<div>上传时间{{ record.create_time }}</div>
<div>存储模式{{ tool.getLabel(record.storage_mode, dictList['upload_mode']) }}</div>
</template>
</a-tooltip>
</div>
</template>
</a-image>
</div>
</template>
</a-space>
</a-image-preview-group>
</a-checkbox-group>
</template>
<!-- 自定义table渲染 -->
<template #url="{ record }">
<a-image
class="list-image"
v-if="/image/g.test(record.mime_type)"
width="40px"
height="40px"
:src="tool.attachUrl(record.url)" />
<a-avatar v-else shape="square" style="top: 0px">{{ record.suffix }}</a-avatar>
</template>
<!-- 操作列前置扩展 -->
<template #operationBeforeExtend="{ record }">
<a-link @click="download(record)"><icon-download /> 下载</a-link>
</template>
</sa-table>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, reactive, computed, nextTick } from 'vue'
import api from '@/api/system/attachment'
import commonApi from '@/api/common'
import { Message, Modal } from '@arco-design/web-vue'
import tool from '@/utils/tool'
import { useDictStore } from '@/store'
import NotImage from '@/assets/not-image.png'
const dictList = useDictStore().data
const crudRef = ref()
const mode = ref('list')
const defaultKey = ref(['all'])
const sliderData = ref([])
const selecteds = ref([])
// 分类搜索点击
const handlerClick = async (value) => {
defaultKey.value = value
const type = value[0] === 'all' ? undefined : value[0]
searchForm.value.mime_type = type
crudRef.value.refresh()
}
// 模式切换
const switchMode = () => {
mode.value = mode.value === 'list' ? 'window' : 'list'
}
// 下载
const download = async (record) => {
const response = await commonApi.downloadById(record.id)
if (response) {
tool.download(response, record.origin_name)
Message.success('请求成功,文件开始下载')
} else {
Message.error('文件下载失败')
}
}
// 选中事件
const handlerChange = (val) => {
selecteds.value = val
crudRef.value.setSelecteds(val)
}
// 全选
const selectAll = () => {
crudRef.value.getTableData().map((item) => selecteds.value.push(item.id))
crudRef.value.setSelecteds(selecteds.value)
}
// 清除选中
const flushAll = () => {
selecteds.value = []
crudRef.value.clearSelected()
}
// 搜索表单
const searchForm = ref({
origin_name: '',
mime_type: '',
storage_mode: '',
create_time: [],
})
// SaTable 基础配置
const options = reactive({
api: api.getPageList,
rowSelection: { showCheckedAll: true },
singleLine: true,
delete: {
show: true,
auth: ['/core/attachment/destroy'],
func: async (params) => {
const resp = await api.destroy(params)
if (resp.code === 200) {
Message.success(`删除成功!`)
crudRef.value?.refresh()
}
},
},
})
// SaTable 列配置
const columns = reactive([
{ title: '预览', dataIndex: 'url', width: 80 },
{ title: '存储名称', dataIndex: 'object_name', width: 220 },
{ title: '原文件名', dataIndex: 'origin_name', width: 150 },
{ title: '存储模式', dataIndex: 'storage_mode', type: 'dict', dict: 'upload_mode', type: 'dict', width: 110 },
{ title: '资源类型', dataIndex: 'mime_type', width: 130 },
{ title: '存储目录', dataIndex: 'storage_path', width: 130 },
{ title: '文件大小', dataIndex: 'size_info', width: 130 },
{ title: '上传时间', dataIndex: 'create_time', width: 180 },
])
// 页面加载完成执行
onMounted(async () => {
const treeData = dictList['attachment_type']
sliderData.value = [{ label: '所有', value: 'all' }, ...treeData]
crudRef.value?.refresh()
})
</script>
<script>
export default { name: 'system:attachment' }
</script>
<style scoped>
:deep(.arco-image-img) {
object-fit: contain;
background-color: var(--color-fill-4);
}
:deep(.arco-image-with-footer-inner .arco-image-footer) {
padding: 9px;
}
:deep(.arco-image-footer-caption-title) {
font-size: 14px;
}
:deep(.arco-image-footer-extra) {
position: relative;
}
:deep(.arco-avatar-square) {
top: -6px;
}
.window-list {
display: flex;
width: 100%;
flex-wrap: wrap;
flex-direction: row;
align-content: center;
}
.image-content {
position: relative;
}
.image-content .checkbox {
position: absolute;
z-index: 10;
right: -16px;
color: #fff;
}
:deep(.arco-tag-checkable) {
color: #fff;
background: rgba(0, 0, 0, 0.5);
}
.actions {
display: flex;
align-items: center;
position: absolute;
right: 9px;
bottom: -24px;
}
.action {
padding: 5px 4px;
font-size: 14px;
margin-left: 6px;
border-radius: 2px;
line-height: 1;
cursor: pointer;
}
.action:first-child {
margin-left: 0;
}
.action:hover {
background: rgba(0, 0, 0, 0.5);
}
</style>

View File

@@ -0,0 +1,95 @@
<template>
<a-modal
v-model:visible="visible"
:title="'配置组' + (mode == 'add' ? '-新增' : '-编辑')"
draggable
width="600px"
:ok-loading="loading"
@cancel="close"
@before-ok="submit">
<a-form ref="formRef" :model="formData" :rules="rules" :auto-label-width="true">
<a-form-item label="组名称(中文)" field="name">
<a-input v-model="formData.name" placeholder="请输入组名称" />
</a-form-item>
<a-form-item label="组标识(英文)" field="code">
<a-input v-model="formData.code" placeholder="请输入组标识" />
</a-form-item>
<a-form-item label="备注" field="remark">
<a-textarea v-model="formData.remark" placeholder="请输入备注" />
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import config from '@/api/system/config'
const visible = ref(false)
const loading = ref(false)
const mode = ref('')
const formRef = ref()
const emit = defineEmits(['success'])
// 打开弹窗
const open = (type = 'add') => {
mode.value = type
visible.value = true
formRef.value.resetFields()
}
// 表单信息
const formData = reactive({
id: '',
name: '',
code: '',
remark: '',
})
// 验证规则
const rules = {
name: [{ required: true, message: '组名称不能为空' }],
code: [{ required: true, message: '组标识不能为空' }],
}
// 设置数据
const setFormData = async (data) => {
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
formData[key] = data[key]
}
}
}
// 数据保存
const submit = async (done) => {
const validate = await formRef.value?.validate()
if (!validate) {
loading.value = true
let data = { ...formData }
let result = {}
if (mode.value === 'add') {
data.id = undefined
result = await config.saveConfigGroup(data)
} else {
result = await config.updateConfigGroup(data.id, data)
}
if (result.code === 200) {
Message.success('操作成功')
emit('success')
done(true)
}
// 防止连续点击提交
setTimeout(() => {
loading.value = false
}, 500)
}
done(false)
}
// 关闭弹窗
const close = () => (visible.value = false)
defineExpose({ open, setFormData })
</script>

View File

@@ -0,0 +1,152 @@
<template>
<component
is="a-modal"
v-model:visible="visible"
:width="800"
:title="'参数配置' + (mode == 'add' ? '-新增' : '-编辑')"
:mask-closable="false"
:ok-loading="loading"
@cancel="close"
@before-ok="submit">
<!-- 表单信息 start -->
<a-form ref="formRef" :model="formData" :rules="rules" :auto-label-width="true">
<a-form-item label="配置组" field="group_id">
<a-select
v-model="formData.group_id"
:options="groupData"
:field-names="{ label: 'name', value: 'id' }"
placeholder="请选择配置组"
disabled />
</a-form-item>
<a-form-item label="配置标题" field="name">
<a-input v-model="formData.name" placeholder="请输入配置标题" />
</a-form-item>
<a-form-item label="配置标识" field="key">
<a-input v-model="formData.key" placeholder="请输入配置标识" />
</a-form-item>
<a-form-item label="配置值" field="value">
<a-textarea v-model="formData.value" placeholder="请输入配置值" />
</a-form-item>
<a-form-item label="排序" field="sort">
<a-input-number v-model="formData.sort" :min="0" :max="999" placeholder="请输入排序" />
</a-form-item>
<a-form-item label="输入组件" field="input_type">
<a-select v-model="formData.input_type" :options="inputComponent" placeholder="请选择输入组件" />
</a-form-item>
<a-form-item label="配置说明" field="remark">
<a-textarea v-model="formData.remark" placeholder="请输入备注" />
</a-form-item>
<a-form-item
v-if="['select', 'radio'].includes(formData.input_type)"
label="配置数据"
field="config_select_data"
extra='用于配置下拉、单选、复选的数据,格式例子:[{"label":"数据一", "value":"shuju1"},...]'>
<ma-codeEditor v-model="formData.config_select_data" :height="200" placeholder="请输入配置数据" />
</a-form-item>
</a-form>
<!-- 表单信息 end -->
</component>
</template>
<script setup>
import { ref, reactive, nextTick } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import api from '@/api/system/config'
import { inputComponent } from './js/inputType.js'
import MaCodeEditor from '@/components/ma-codeEditor/index.vue'
const emit = defineEmits(['success'])
// 引用定义
const visible = ref(false)
const loading = ref(false)
const mode = ref('')
const formRef = ref()
const groupData = ref([])
// 表单初始值
const initialFormData = {
id: null,
group_id: '',
name: '',
key: '',
value: '',
input_type: 'input',
config_select_data: '',
sort: 100,
remark: '',
}
// 表单信息
const formData = reactive({ ...initialFormData })
// 验证规则
const rules = {
group_id: [{ required: true, message: '所属组不能为空' }],
name: [{ required: true, message: '配置标题不能为空' }],
key: [{ required: true, message: '配置标识不能为空' }],
input_type: [{ required: true, message: '组件类型不能为空' }],
}
// 打开弹框
const open = async (type = 'add') => {
mode.value = type
// 重置表单数据
Object.assign(formData, initialFormData)
formRef.value.clearValidate()
visible.value = true
await initPage()
}
// 初始化页面数据
const initPage = async () => {
const resp = await api.getConfigGroupList()
groupData.value = resp.data
}
// 设置数据
const setFormData = async (data) => {
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
formData[key] = data[key]
}
}
}
// 数据保存
const submit = async (done) => {
const validate = await formRef.value?.validate()
if (!validate) {
loading.value = true
let data = { ...formData }
let result = {}
if (mode.value === 'add') {
// 添加数据
data.id = undefined
result = await api.save(data)
} else {
// 修改数据
if (data.config_select_data && typeof data.config_select_data === 'string') {
data.config_select_data = data.config_select_data.replace(/\r|\n|\s/g, '')
data.config_select_data = data.config_select_data.replace(',]', ']')
}
result = await api.update(data.id, data)
}
if (result.code === 200) {
Message.success('操作成功')
emit('success')
done(true)
}
// 防止连续点击提交
setTimeout(() => {
loading.value = false
}, 500)
}
done(false)
}
// 关闭弹窗
const close = () => (visible.value = false)
defineExpose({ open, setFormData })
</script>

View File

@@ -0,0 +1,9 @@
export const inputComponent = [
{ label: '文本框', value: 'input' },
{ label: '文本域', value: 'textarea' },
{ label: '下拉选择框', value: 'select' },
{ label: '单选框', value: 'radio' },
{ label: '图片上传', value: 'uploadImage' },
{ label: '文件上传', value: 'uploadFile' },
{ label: '富文本编辑器', value: 'wangEditor' }
]

View File

@@ -0,0 +1,112 @@
<template>
<a-modal fullscreen v-model:visible="visible" :footer="false" @close="handleClose">
<template #title>管理配置</template>
<div class="h-full">
<!-- CRUD 组件 -->
<sa-table ref="crudRef" :options="options" :columns="columns" :searchForm="searchForm">
<!-- 搜索区 start -->
<template #tableSearch>
<a-col :span="8">
<a-form-item field="name" label="配置标题">
<a-input v-model="searchForm.name" placeholder="请输入配置标题" allow-clear />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item field="key" label="配置标识">
<a-input v-model="searchForm.key" placeholder="请输入配置标识" allow-clear />
</a-form-item>
</a-col>
</template>
</sa-table>
<!-- 编辑表单 -->
<EditForm ref="editRef" @success="refresh" />
</div>
</a-modal>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import api from '@/api/system/config'
import EditForm from './edit.vue'
const emit = defineEmits(['close'])
const visible = ref(false)
const crudRef = ref()
const editRef = ref()
// 搜索表单
const searchForm = ref({
name: '',
key: '',
group_id: '',
orderBy: 'sort',
orderType: 'desc',
})
// 关闭窗口
const handleClose = () => {
visible.value = false
}
// 刷新页面
const refresh = async () => {
crudRef.value?.refresh()
emit('close')
}
// 打开窗口
const open = (id) => {
searchForm.value.group_id = id
visible.value = true
crudRef.value?.refresh()
}
// SaTable 基础配置
const options = reactive({
api: api.getConfigList,
rowSelection: { showCheckedAll: true },
singleLine: true,
add: {
show: true,
auth: ['/core/config/save'],
func: async () => {
editRef.value?.open()
editRef.value?.setFormData({ group_id: searchForm.value.group_id })
},
},
edit: {
show: true,
auth: ['/core/config/update'],
func: async (record) => {
editRef.value?.open('edit')
editRef.value?.setFormData(record)
},
},
delete: {
show: true,
auth: ['/core/config/destroy'],
func: async (params) => {
const resp = await api.destroy(params)
if (resp.code === 200) {
Message.success(`删除成功!`)
refresh()
}
},
},
})
// SaTable 列配置
const columns = reactive([
{ title: '配置标题', dataIndex: 'name', width: 220 },
{ title: '配置标识', dataIndex: 'key', width: 180 },
{ title: '配置值', dataIndex: 'value', width: 200 },
{ title: '排序', dataIndex: 'sort', width: 200 },
{ title: '输入组件', dataIndex: 'input_type', width: 180 },
{ title: '配置说明', dataIndex: 'remark', width: 180 },
])
defineExpose({ open })
</script>

View File

@@ -0,0 +1,338 @@
<template>
<div class="ma-content-block lg:flex justify-between">
<div class="lg:w-3/12 w-full h-full p-4 pr-2">
<a-list :max-height="650" :scrollbar="true" size="small">
<template #header>
<div class="flex justify-between items-center" :style="{ height: '30px' }">
<span>配置分组</span>
<a-tooltip content="添加组">
<a-button shape="round" size="small" @click="addGroupModal" type="primary" v-auth="['/core/config/save']">
<template #icon><icon-plus /></template>
</a-button>
</a-tooltip>
</div>
</template>
<a-list-item v-for="(item, index) in configGroupData">
<div class="flex justify-between items-center">
<a-button :type="title == item.name ? 'outline' : ''" @click="getConfigData(item.id, item.name, item.code)">
{{ item.name }}({{ item.code }})
</a-button>
<div class="flex">
<a-link v-auth="['/core/config/update']" @click="editGroupModal(item)">
<template #icon>
<icon-edit />
</template>
</a-link>
<a-link v-auth="['/core/config/destroy']" @click="openDeleteModal(item)">
<template #icon>
<icon-delete />
</template>
</a-link>
</div>
</div>
</a-list-item>
</a-list>
</div>
<div class="lg:w-9/12 w-full p-4 pl-2">
<!-- CRUD 组件 -->
<a-card :title="title" :loading="loading" :header-style="{ height: '45px' }">
<template #extra>
<a-button type="primary" shape="circle" v-auth="['/core/config/index']" @click="manageConfigModal()">
<icon-settings />
</a-button>
</template>
<div class="pl-4 pr-4">
<a-form :model="form" auto-label-width>
<template v-for="(item, index) in formArray">
<a-form-item :label="item.name" :field="item.key" :extra="item.remark" v-show="item.display">
<template v-if="item.input_type === 'select'">
<a-select
v-model="item.value"
:options="item.config_select_data"
@change="handleSelect($event, item)"
:placeholder="'请选择' + item.name" />
</template>
<template v-if="item.input_type === 'input'">
<a-input v-model="item.value" :placeholder="'请输入' + item.name" />
</template>
<template v-if="item.input_type === 'radio'">
<a-radio-group v-model="item.value" :options="item.config_select_data" />
</template>
<template v-if="item.input_type === 'textarea'">
<a-textarea v-model="item.value" :placeholder="'请输入' + item.name" />
</template>
<template v-if="item.input_type === 'uploadImage'">
<sa-upload-image v-model="item.value" />
</template>
<template v-if="item.input_type === 'uploadFile'">
<sa-upload-file v-model="item.value" />
</template>
<template v-if="item.input_type === 'wangEditor'">
<ma-wangEditor v-model="item.value" />
</template>
</a-form-item>
</template>
<a-form-item v-if="formArray.length > 0">
<a-button type="primary" @click="submit(formArray)">保存修改</a-button>
</a-form-item>
<a-form-item label="测试邮件" v-if="currentNode.code === 'email_config'">
<a-input v-model="email" placeholder="请输入正确的邮箱接收地址" />
<a-button type="primary" class="ml-2" @click="sendMail()">发送</a-button>
</a-form-item>
</a-form>
</div>
</a-card>
</div>
<!-- 删除配置组 -->
<a-modal v-model:visible="deleteVisible" type="warning" :on-before-ok="deleteConfigGroup">
<template #title>危险操作</template>
<div class="mb-2">
确定要删除 <span class="text-red-500 underline font-black">{{ deleteGroupData.name }}</span> 配置组吗
</div>
<div>
此操作会删除组和所属组配置项如果执行请在下面输入框输入<span class="text-red-500">{{
deleteGroupData.name
}}</span>
</div>
<a-input :placeholder="`请输入 ${deleteGroupData.name}`" class="mt-2" v-model="name" />
</a-modal>
<!-- 添加配置组表单 -->
<add-group ref="addGroupRef" @success="getConfigGroupList" />
<!-- 管理配置组 -->
<manage-config ref="manageConfigRef" @close="refresh" />
</div>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue'
import { Message } from '@arco-design/web-vue'
import { auth } from '@/utils/common'
import config from '@/api/system/config'
import AddGroup from './components/add-group.vue'
import ManageConfig from './components/manage-config.vue'
const manageConfigRef = ref()
const addGroupRef = ref()
const form = ref({})
const formArray = ref([])
const configGroupData = ref([])
const loading = ref(false)
const title = ref('')
const name = ref('')
const deleteGroupData = ref({ name: '' })
const deleteVisible = ref(false)
const currentNode = ref({})
const email = ref('')
// 刷新配置数据
const refresh = async () => {
getConfigData(currentNode.value.id, currentNode.value.name, currentNode.value.code)
}
// 删除配置组
const openDeleteModal = (data) => {
const id = data.id
if (id == 1 || id == 2 || id == 3) {
Message.info('该配置为系统核心配置,无法删除')
return
}
deleteGroupData.value = configGroupData.value.find((item) => item.id == id)
deleteVisible.value = true
}
// 获取配置组数据
const getConfigGroupList = async () => {
const response = await config.getConfigGroupList()
configGroupData.value = response.data
const item = configGroupData.value[0]
await getConfigData(item.id, item.name, item.code)
}
// 获取配置数据
const getConfigData = async (id, name, code) => {
currentNode.value.id = id
currentNode.value.name = name
currentNode.value.code = code
loading.value = true
const params = {
group_id: id,
orderBy: 'sort',
orderType: 'desc',
}
title.value = name
const response = await config.getConfigList(params)
response.data.map((item) => {
if (
item.key.indexOf('local_') !== -1 ||
item.key.indexOf('qiniu_') !== -1 ||
item.key.indexOf('cos_') !== -1 ||
item.key.indexOf('oss_') !== -1 ||
item.key.indexOf('s3_') !== -1
) {
item.display = false
} else {
item.display = true
}
return item
})
formArray.value = response.data
if (id === 2) {
formArray.value.map((item) => {
if (item.key === 'upload_mode') {
handleSelect(item.value, item)
}
})
}
loading.value = false
}
// 自定义处理切换显示
const handleSelect = async (val, ele) => {
if (ele.key === 'upload_mode') {
if (val == 1) {
formArray.value.map((item) => {
if (item.key.indexOf('local_') !== -1) {
item.display = true
}
if (
item.key.indexOf('qiniu_') !== -1 ||
item.key.indexOf('cos_') !== -1 ||
item.key.indexOf('oss_') !== -1 ||
item.key.indexOf('s3_') !== -1
) {
item.display = false
}
})
}
if (val == 2) {
formArray.value.map((item) => {
if (item.key.indexOf('oss_') !== -1) {
item.display = true
}
if (
item.key.indexOf('qiniu_') !== -1 ||
item.key.indexOf('cos_') !== -1 ||
item.key.indexOf('local_') !== -1 ||
item.key.indexOf('s3_') !== -1
) {
item.display = false
}
})
}
if (val == 3) {
formArray.value.map((item) => {
if (item.key.indexOf('qiniu_') !== -1) {
item.display = true
}
if (
item.key.indexOf('local_') !== -1 ||
item.key.indexOf('cos_') !== -1 ||
item.key.indexOf('oss_') !== -1 ||
item.key.indexOf('s3_') !== -1
) {
item.display = false
}
})
}
if (val == 4) {
formArray.value.map((item) => {
if (item.key.indexOf('cos_') !== -1) {
item.display = true
}
if (
item.key.indexOf('qiniu_') !== -1 ||
item.key.indexOf('local_') !== -1 ||
item.key.indexOf('oss_') !== -1 ||
item.key.indexOf('s3_') !== -1
) {
item.display = false
}
})
}
if (val == 5) {
formArray.value.map((item) => {
if (item.key.indexOf('s3_') !== -1) {
item.display = true
}
if (
item.key.indexOf('qiniu_') !== -1 ||
item.key.indexOf('cos_') !== -1 ||
item.key.indexOf('local_') !== -1 ||
item.key.indexOf('oss_') !== -1
) {
item.display = false
}
})
}
}
}
// 修改配置
const submit = async (params) => {
if (!auth('/core/config/save')) {
Message.info('没有权限修改配置')
return
}
const data = {
group_id: currentNode.value.id,
config: params,
}
const response = await config.batchUpdate(data)
if (response.code === 200) {
Message.success(response.message)
}
}
// 发送测试邮件
const sendMail = async () => {
const reg = /^[a-z0-9]+([._\\-]*[a-z0-9])*@([a-z0-9]+[-a-z0-9]*[a-z0-9]+.){1,63}[a-z0-9]+$/
if (!reg.test(email.value)) {
Message.info('请输入正确的邮箱地址')
return
}
const response = await config.testEmail({ email: email.value })
if (response.code === 200) {
Message.success(response.message)
}
}
// 管理配置数据
const manageConfigModal = () => {
const group_id = currentNode.value.id
manageConfigRef.value.open(group_id)
}
// 添加配置分组
const addGroupModal = () => addGroupRef.value?.open()
// 修改配置分组
const editGroupModal = (item) => {
addGroupRef.value?.open('edit')
addGroupRef.value?.setFormData(item)
}
// 删除配置分组
const deleteConfigGroup = async (done) => {
if (name.value !== deleteGroupData.value.name) {
Message.error(`输入错误,验证失败`)
done(false)
return
}
const response = await config.deleteConfigGroup({ ids: deleteGroupData.value.id })
if (response.code === 200) {
Message.success('配置删除成功')
deleteGroupData.value = {}
getConfigGroupList()
done(true)
}
}
// 页面加载完成执行
onMounted(() => {
getConfigGroupList()
})
</script>

View File

@@ -0,0 +1,128 @@
<template>
<div class="ma-content-block lg:flex justify-between">
<!-- CRUD 组件 -->
<sa-table
ref="crudRef"
:options="options"
:columns="columns"
:searchForm="searchForm"
@selectionChange="selectChange">
<!-- 搜索区 tableSearch -->
<template #tableSearch>
<a-col :sm="12" :xs="24">
<a-form-item field="name" label="表名称">
<a-input v-model="searchForm.name" placeholder="请输入表名称" allow-clear />
</a-form-item>
</a-col>
</template>
<!-- 表格操作按钮扩展 -->
<template #tableBeforeButtons>
<a-button @click="operate('optimize')" type="primary" status="success" v-auth="['/core/database/optimize']">
<template #icon><icon-tool /></template>优化表
</a-button>
<a-button @click="operate('clear')" type="primary" status="success" v-auth="['/core/database/fragment']">
<template #icon><icon-experiment /></template>清理碎片
</a-button>
</template>
<!-- 操作前置扩展 -->
<template #operationBeforeExtend="{ record }">
<a-link v-auth="['/core/database/detailed']" @click="tableStruct(record.name)"> <icon-layers /> 表结构 </a-link>
<a-link v-auth="['/core/database/recycle']" @click="tableRecycle(record.name)">
<icon-storage /> 回收站
</a-link>
</template>
</sa-table>
<!-- 表结构 -->
<struct-table ref="structTableRef" @success="refresh" />
<!-- 回收站 -->
<recycle-table ref="tableRecycleRef" @success="refresh" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import api from '@/api/system/database'
import RecycleTable from './recycle.vue'
import StructTable from './struct.vue'
const crudRef = ref()
const structTableRef = ref()
const tableRecycleRef = ref()
const selecteds = ref([])
const searchForm = reactive({
name: '',
})
// 选中数据
const selectChange = (val) => (selecteds.value = val)
// 打开表结构
const tableStruct = async (tableName) => {
structTableRef.value?.open(tableName)
}
// 打开回收站
const tableRecycle = async (tableName) => {
tableRecycleRef.value?.open(tableName)
}
// 操作方法
const operate = async (op) => {
if (selecteds.value.length === 0) {
Message.error('至少要选择一条数据')
return
}
let response
if (op === 'optimize') {
response = await api.optimize({ tables: selecteds.value })
}
if (op === 'clear') {
response = await api.fragment({ tables: selecteds.value })
}
if (response.code == 200) {
Message.success(response.message)
crudRef.value?.refresh()
}
}
// SaTable 基础配置
const options = reactive({
api: api.getPageList,
rowSelection: { showCheckedAll: true, key: 'name' },
operationColumnWidth: 180,
})
// SaTable 列配置
const columns = reactive([
{ title: '表名称', dataIndex: 'name', width: 200, align: 'left' },
{ title: '表注释', dataIndex: 'comment', width: 180 },
{ title: '表引擎', dataIndex: 'engine', width: 120 },
{ title: '数据更新时间', dataIndex: 'update_time', width: 180 },
{ title: '总行数', dataIndex: 'rows', width: 120 },
{ title: '碎片大小', dataIndex: 'data_free', width: 120 },
{ title: '数据大小', dataIndex: 'data_length', width: 120 },
{ title: '索引大小', dataIndex: 'index_length', width: 120 },
{ title: '字符集', dataIndex: 'collation', width: 180 },
{ title: '创建时间', dataIndex: 'create_time', width: 180 },
])
// 页面数据初始化
const initPage = async () => {}
// SaTable 数据刷新
const refresh = async () => {
crudRef.value?.refresh()
}
// 页面加载完成执行
onMounted(async () => {
initPage()
refresh()
})
</script>

View File

@@ -0,0 +1,132 @@
<template>
<a-drawer v-model:visible="visible" :width="tool.getDevice() === 'mobile' ? '100%' : '70%'" :footer="false">
<template #title>回收站数据 {{ tableForm.table }}</template>
<a-space class="pl-4">
<a-popconfirm content="确定要批量销毁数据吗?" position="bottom" @ok="batchDeleteAction">
<a-button type="primary" status="danger" v-auth="['/core/database/delete']">
<template #icon> <icon-delete /> </template> 批量销毁
</a-button>
</a-popconfirm>
<a-popconfirm content="确定要批量恢复数据吗?" position="bottom" @ok="batchRecoveryAction">
<a-button type="primary" status="success" v-auth="['/core/database/recovery']">
<template #icon> <icon-undo /> </template> 批量恢复
</a-button>
</a-popconfirm>
</a-space>
<!-- CRUD 组件 -->
<sa-table
ref="tableRef"
:options="tableCrud"
:searchForm="tableForm"
@selectionChange="selectChange"
:columns="[
{ title: '删除时间', dataIndex: 'delete_time', width: 180 },
{ title: '数据详情', dataIndex: 'json_data', width: 300 },
]">
<!-- 数据详情插槽 -->
<template #json_data="{ record }">
{{ JSON.stringify(record) }}
</template>
<!-- 操作 -->
<template #operationCell="{ record }">
<a-popconfirm content="确定要销毁该数据吗?" position="bottom" @ok="deleteAction(record)">
<a-link type="primary" v-auth="['/core/database/delete']"> <icon-delete /> 销毁 </a-link>
</a-popconfirm>
<a-popconfirm content="确定要恢复该数据吗?" position="bottom" @ok="recoveryAction(record)">
<a-link type="primary" v-auth="['/core/database/recovery']"> <icon-undo /> 恢复 </a-link>
</a-popconfirm>
</template>
</sa-table>
</a-drawer>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import tool from '@/utils/tool'
import { Message } from '@arco-design/web-vue'
import api from '@/api/system/database'
const visible = ref(false)
const tableRef = ref()
const selecteds = ref([])
const tableForm = ref({
table: '',
})
// 选中数据
const selectChange = (val) => (selecteds.value = val)
// SaTable 基础配置
const tableCrud = reactive({
api: api.getRecycle,
rowSelection: { showCheckedAll: true, key: 'id' },
pageSimple: true,
showSearch: false,
})
// 页面数据初始化
const initPage = async () => {
tableRef.value?.refresh()
}
// 打开弹框
const open = async (tablename) => {
tableForm.value.table = tablename
visible.value = true
await initPage()
}
// 批量销毁置
const batchDeleteAction = async () => {
console.log(selecteds.value)
if (selecteds.value && selecteds.value.length > 0) {
const params = { table: tableForm.value.table, ids: selecteds.value }
const resp = await api.delete(params)
if (resp.code === 200) {
Message.success(`销毁成功!`)
tableRef.value?.clearSelected()
tableRef.value?.refresh()
}
} else {
Message.error('至少选择一条数据')
}
}
// 批量恢复数据
const batchRecoveryAction = async () => {
if (selecteds.value && selecteds.value.length > 0) {
const params = { table: tableForm.value.table, ids: selecteds.value }
const resp = await api.recovery(params)
if (resp.code === 200) {
Message.success(`恢复成功!`)
selecteds.value = []
tableRef.value?.clearSelected()
tableRef.value?.refresh()
}
} else {
Message.error('至少选择一条数据')
}
}
// 销毁数据
const deleteAction = async (record) => {
const params = { table: tableForm.value.table, ids: [record.id] }
const resp = await api.delete(params)
if (resp.code === 200) {
Message.success(`销毁成功!`)
tableRef.value?.refresh()
}
}
// 恢复数据
const recoveryAction = async (record) => {
const params = { table: tableForm.value.table, ids: [record.id] }
const resp = await api.recovery(params)
if (resp.code === 200) {
Message.success(`恢复成功!`)
tableRef.value?.refresh()
}
}
defineExpose({ open })
</script>

View File

@@ -0,0 +1,60 @@
<template>
<a-modal v-model:visible="visible" :width="tool.getDevice() === 'mobile' ? '100%' : '900px'" :footer="false">
<template #title>表结构信息</template>
<!-- CRUD 组件 -->
<sa-table
ref="tableRef"
:options="tableCrud"
:searchForm="tableForm"
:columns="[
{ title: '字段名称', dataIndex: 'column_name', width: '180' },
{ title: '字段类型', dataIndex: 'column_type', width: '100' },
{ title: '索引', dataIndex: 'column_key', type: 'dict', render: 'tag', options: optionData, width: '100' },
{ title: '默认值', dataIndex: 'default_value', width: '100' },
{ title: '字段注释', dataIndex: 'column_comment', width: '180' },
]" />
</a-modal>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import tool from '@/utils/tool'
import api from '@/api/system/database'
const visible = ref(false)
const tableRef = ref()
const optionData = ref([
{ label: '主键', value: 'PRI' },
{ label: '唯一键', value: 'UNI' },
{ label: '普通索引', value: 'MUL' },
{ label: '', value: '' },
])
// 搜索表单
const tableForm = ref({
table: '',
})
// SaTable 基础配置
const tableCrud = reactive({
api: api.getDetailed,
pageSimple: true,
showSearch: false,
operationColumn: false,
})
// 页面数据初始化
const initPage = async () => {
tableRef.value?.refresh()
}
// 打开弹框
const open = async (tablename) => {
tableForm.value.table = tablename
visible.value = true
await initPage()
}
defineExpose({ open })
</script>

View File

@@ -0,0 +1,145 @@
<template>
<component
is="a-modal"
v-model:visible="visible"
:width="tool.getDevice() === 'mobile' ? '100%' : '600px'"
:title="title"
:mask-closable="false"
:ok-loading="loading"
@cancel="close"
@before-ok="submit">
<!-- 表单信息 start -->
<a-form ref="formRef" :model="formData" :rules="rules" :auto-label-width="true">
<a-form-item field="parent_id" label="上级部门">
<a-tree-select
v-model="formData.parent_id"
:data="deptData"
:field-names="{ key: 'value', title: 'label' }"
allow-clear
placeholder="请选择上级部门">
</a-tree-select>
</a-form-item>
<a-form-item label="部门名称" field="name">
<a-input v-model="formData.name" placeholder="请输入部门名称" />
</a-form-item>
<a-form-item label="排序数字" field="sort">
<a-input-number v-model="formData.sort" placeholder="请输入排序数字" />
</a-form-item>
<a-form-item label="状态" field="status">
<sa-radio v-model="formData.status" dict="data_status" placeholder="请选择状态" />
</a-form-item>
<a-form-item label="备注" field="remark">
<a-textarea v-model="formData.remark" placeholder="请输入备注" />
</a-form-item>
</a-form>
<!-- 表单信息 end -->
</component>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { useUserStore } from '@/store'
import commonApi from '@/api/common'
import tool from '@/utils/tool'
import api from '@/api/system/dept'
const emit = defineEmits(['success'])
// 引用定义
const formRef = ref()
const mode = ref('')
const visible = ref(false)
const loading = ref(false)
const userStore = useUserStore()
const userInfo = reactive({
...userStore.user,
})
const deptData = ref([])
let title = computed(() => {
return '部门管理' + (mode.value == 'add' ? '-新增' : '-编辑')
})
// 表单初始值
const initialFormData = {
id: '',
parent_id: '',
level: '',
name: '',
status: 1,
sort: 100,
remark: '',
}
// 表单信息
const formData = reactive({ ...initialFormData })
// 验证规则
const rules = {
parent_id: [{ required: true, message: '上级部门不能为空' }],
name: [{ required: true, message: '部门名称不能为空' }],
}
// 初始化页面数据
const initPage = async () => {
const resp = await await commonApi.commonGet('/core/dept/index?tree=true&filter=false')
if (userInfo.id === 1) {
deptData.value = [{ label: '无上级部门', value: 0, children: resp.data }]
} else {
deptData.value = resp.data
}
}
// 打开弹框
const open = async (type = 'add') => {
mode.value = type
// 重置表单数据
Object.assign(formData, initialFormData)
formRef.value.clearValidate()
visible.value = true
await initPage()
}
// 设置数据
const setFormData = async (data) => {
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
formData[key] = data[key]
}
}
}
// 数据保存
const submit = async (done) => {
const validate = await formRef.value?.validate()
if (!validate) {
loading.value = true
let data = { ...formData }
let result = {}
if (mode.value === 'add') {
// 添加数据
data.id = undefined
result = await api.save(data)
} else {
// 修改数据
result = await api.update(data.id, data)
}
if (result.code === 200) {
Message.success('操作成功')
emit('success')
done(true)
}
// 防止连续点击提交
setTimeout(() => {
loading.value = false
}, 500)
}
done(false)
}
// 关闭弹窗
const close = () => (visible.value = false)
defineExpose({ open, setFormData })
</script>

View File

@@ -0,0 +1,126 @@
<template>
<div class="ma-content-block">
<!-- CRUD 组件 -->
<sa-table ref="crudRef" :options="options" :columns="columns" :searchForm="searchForm">
<!-- 搜索区 tableSearch -->
<template #tableSearch>
<a-col :sm="8" :xs="24">
<a-form-item field="name" label="部门名称">
<a-input v-model="searchForm.name" placeholder="请输入部门名称" allow-clear />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="create_time" label="时间范围">
<a-range-picker v-model="searchForm.create_time" />
</a-form-item>
</a-col>
</template>
<!-- 搜索区 end -->
<!-- Table 自定义渲染 -->
<template #leader="{ record }">
<div v-if="record.leader.length > 0">
<a-tag v-for="item in record.leader" :key="item.id" class="ml-2">{{ item.username }}</a-tag>
</div>
<div v-else></div>
</template>
<!-- 操作列 -->
<template #operationCell="{ record }">
<div v-if="record.disabled"></div>
</template>
<!-- 操作前置扩展 -->
<template #operationBeforeExtend="{ record }">
<a-link v-if="!record.disabled" v-auth="['/core/dept/leaders']" @click="openLeaderModal(record)">
<icon-user /> 领导列表
</a-link>
</template>
</sa-table>
<!-- 编辑表单 -->
<edit-form ref="editRef" @success="refresh" />
<!-- 领导列表 -->
<leader-list ref="leaderRef" @success="refresh" />
</div>
</template>
<script setup>
import { onMounted, ref, reactive, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import EditForm from './edit.vue'
import LeaderList from './leader.vue'
import api from '@/api/system/dept'
// 引用定义
const crudRef = ref()
const editRef = ref()
const leaderRef = ref()
// 打开领导列表设置
const openLeaderModal = (record) => {
leaderRef.value.open(record)
}
// 搜索表单
const searchForm = ref({
name: '',
create_time: [],
})
// SaTable 基础配置
const options = reactive({
api: api.getPageList,
rowSelection: { showCheckedAll: true },
isExpand: true,
operationColumnWidth: 220,
add: {
show: true,
auth: ['/core/dept/save'],
func: async () => {
editRef.value?.open()
},
},
edit: {
show: true,
auth: ['/core/dept/update'],
func: async (record) => {
editRef.value?.open('edit')
editRef.value?.setFormData(record)
},
},
delete: {
show: true,
auth: ['/core/dept/destroy'],
func: async (params) => {
const resp = await api.destroy(params)
if (resp.code === 200) {
Message.success(`删除成功!`)
crudRef.value?.refresh()
}
},
},
})
// SaTable 列配置
const columns = reactive([
{ title: '部门名称', dataIndex: 'name', width: 180 },
{ title: '领导列表', dataIndex: 'leader' },
{ title: '排序', dataIndex: 'sort', width: 100 },
{ title: '状态', dataIndex: 'status', type: 'dict', dict: 'data_status', width: 120 },
{ title: '创建时间', dataIndex: 'create_time', width: 180 },
])
// 页面数据初始化
const initPage = async () => {}
// SaTable 数据请求
const refresh = async () => {
crudRef.value?.refresh()
}
// 页面加载完成执行
onMounted(async () => {
initPage()
refresh()
})
</script>

View File

@@ -0,0 +1,105 @@
<template>
<a-modal v-model:visible="visible" fullscreen :footer="false" @close="handleClose">
<template #title>部门领导列表</template>
<a-alert>部门的领导人可以跨部门设置</a-alert>
<sa-table ref="crudRef" :options="options" :columns="columns" :searchForm="searchForm">
<!-- 搜索区 tableSearch -->
<template #tableSearch>
<a-col :sm="8" :xs="24">
<a-form-item field="username" label="用户名">
<a-input v-model="searchForm.username" placeholder="请输入用户名" allow-clear />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="nickname" label="用户昵称">
<a-input v-model="searchForm.nickname" placeholder="请输入用户昵称" allow-clear />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="status" label="状态">
<sa-select v-model="searchForm.status" dict="data_status" placeholder="请选择状态" allow-clear />
</a-form-item>
</a-col>
</template>
<!-- 操作前置扩展 -->
<template #tableBeforeButtons>
<sa-user text="新增领导" :onlyId="false" :isEcho="false" v-model="users" @success="selectedSuccess" />
</template>
</sa-table>
</a-modal>
</template>
<script setup>
import { ref, reactive } from 'vue'
import api from '@/api/system/dept'
import { Message } from '@arco-design/web-vue'
const emit = defineEmits(['success'])
const visible = ref(false)
const crudRef = ref()
const deptId = ref()
const users = ref([])
// 搜索表单
const searchForm = ref({
username: '',
nickname: '',
dept_id: null,
status: '',
})
// 打开弹框
const open = (row) => {
deptId.value = row.id
visible.value = true
searchForm.value.dept_id = deptId.value
crudRef.value?.refresh()
}
// 成功添加
const selectedSuccess = async () => {
const data = users.value.map((item) => {
return { user_id: item.id, username: item.username }
})
const response = await api.addLeader({ id: deptId.value, users: data })
if (response.code === 200) {
users.value = []
crudRef.value?.refresh()
}
}
const handleClose = async () => {
emit('success', true)
}
// SaTable 基础配置
const options = reactive({
api: api.getLeaderList,
rowSelection: { showCheckedAll: true },
singleLine: true,
delete: {
show: true,
auth: ['/core/dept/destroy'],
func: async (params) => {
params.id = deptId.value
const resp = await api.delLeader(params)
if (resp.code === 200) {
Message.success(`删除成功!`)
crudRef.value?.refresh()
}
},
},
})
// SaTable 列配置
const columns = reactive([
{ title: '用户名', dataIndex: 'username' },
{ title: '用户昵称', dataIndex: 'nickname' },
{ title: '手机', dataIndex: 'phone' },
{ title: '邮箱', dataIndex: 'email' },
{ title: '状态', dataIndex: 'status', width: 100, type: 'dict', dict: 'data_status' },
])
defineExpose({ open })
</script>

View File

@@ -0,0 +1,133 @@
<template>
<div>
<a-modal v-model:visible="visible" fullscreen :footer="false">
<template #title>维护 {{ currentRow.name }} 字典数据</template>
<!-- CRUD 组件 -->
<sa-table ref="crudRef" :options="options" :columns="columns" :searchForm="searchForm">
<!-- 搜索区 tableSearch -->
<template #tableSearch>
<a-col :sm="8" :xs="24">
<a-form-item field="label" label="字典标签">
<a-input v-model="searchForm.label" placeholder="请输入字典标签" allow-clear />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="value" label="字典键值">
<a-input v-model="searchForm.value" placeholder="请输入字典键值" allow-clear />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="status" label="状态">
<sa-select v-model="searchForm.status" dict="data_status" placeholder="请选择状态" />
</a-form-item>
</a-col>
</template>
<!-- Table 自定义渲染 -->
<template #color="{ record }">
<a-tag :color="record.color">{{ record.color }}</a-tag>
</template>
<template #status="{ record }">
<sa-switch v-model="record.status" @change="changeStatus($event, record.id)"></sa-switch>
</template>
</sa-table>
</a-modal>
<!-- 编辑表单 -->
<edit-form ref="editRef" @success="refresh" />
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { dict } from '@/api/system/dict'
import { Message } from '@arco-design/web-vue'
import EditForm from './edit-data.vue'
const crudRef = ref()
const editRef = ref()
const visible = ref(false)
const currentRow = ref({})
// 搜索表单
const searchForm = ref({
code: '',
type_id: null,
label: '',
value: '',
status: '',
orderBy: 'sort',
orderType: 'desc',
})
// 修改状态
const changeStatus = async (status, id) => {
const response = await dict.changeStatus({ id, status })
if (response.code === 200) {
Message.success(response.message)
crudRef.value.refresh()
}
}
// 打开弹框
const open = (row) => {
currentRow.value = row
searchForm.value.code = row.code
searchForm.value.type_id = row.id
crudRef.value?.refresh()
visible.value = true
}
// SaTable 基础配置
const options = reactive({
api: dict.getPageList,
rowSelection: { showCheckedAll: true },
singleLine: true,
add: {
show: true,
auth: ['/core/dictType/save'],
func: async () => {
editRef.value?.open()
editRef.value?.setFormData({ type_id: searchForm.value.type_id, code: searchForm.value.code })
},
},
edit: {
show: true,
auth: ['/core/dictType/update'],
func: async (record) => {
editRef.value?.open('edit')
editRef.value?.setFormData(record)
},
},
delete: {
show: true,
auth: ['/core/dictType/destroy'],
func: async (params) => {
const resp = await dict.destroyDictData(params)
if (resp.code === 200) {
Message.success(`删除成功!`)
crudRef.value?.refresh()
}
},
},
})
// SaTable 列配置
const columns = reactive([
{ title: '字典标签', dataIndex: 'label', width: 220 },
{ title: '字典键值', dataIndex: 'value', width: 220 },
{ title: '颜色', dataIndex: 'color', width: 120 },
{ title: '排序', dataIndex: 'sort', width: 180 },
{ title: '状态', dataIndex: 'status', width: 180 },
{ title: '创建时间', dataIndex: 'create_time', width: 180 },
])
// SaTable 数据请求
const refresh = async () => {
crudRef.value?.refresh()
}
// 页面加载完成执行
defineExpose({ open })
</script>
<style scoped></style>

View File

@@ -0,0 +1,130 @@
<template>
<component
is="a-modal"
v-model:visible="visible"
:width="tool.getDevice() === 'mobile' ? '100%' : '600px'"
:title="title"
:mask-closable="false"
:ok-loading="loading"
@cancel="close"
@before-ok="submit">
<!-- 表单信息 start -->
<a-form ref="formRef" :model="formData" :rules="rules" :auto-label-width="true">
<a-form-item label="字典标签" field="label">
<a-input v-model="formData.label" placeholder="请输入字典标签" />
</a-form-item>
<a-form-item label="字典键值" field="value">
<a-input v-model="formData.value" placeholder="请输入字典键值" />
</a-form-item>
<a-form-item label="颜色" field="color">
<ma-color-picker v-model="formData.color" />
</a-form-item>
<a-form-item label="排序" field="sort">
<a-input-number v-model="formData.sort" placeholder="请输入排序" />
</a-form-item>
<a-form-item label="状态" field="status">
<sa-radio v-model="formData.status" dict="data_status" placeholder="请选择状态" />
</a-form-item>
<a-form-item label="备注" field="remark">
<a-textarea v-model="formData.remark" placeholder="请输入备注" />
</a-form-item>
</a-form>
<!-- 表单信息 end -->
</component>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import tool from '@/utils/tool'
import { dict } from '@/api/system/dict'
const emit = defineEmits(['success'])
// 引用定义
const formRef = ref()
const mode = ref('')
const visible = ref(false)
const loading = ref(false)
let title = computed(() => {
return '字典数据' + (mode.value == 'add' ? '-新增' : '-编辑')
})
// 表单初始值
const initialFormData = {
id: '',
type_id: null,
code: '',
label: '',
value: '',
color: '',
status: 1,
sort: 100,
remark: '',
}
// 表单信息
const formData = reactive({ ...initialFormData })
// 验证规则
const rules = {
label: [{ required: true, message: '字典标签不能为空' }],
value: [{ required: true, message: '字典键值不能为空' }],
}
// 打开弹框
const open = async (type = 'add') => {
mode.value = type
// 重置表单数据
Object.assign(formData, initialFormData)
formRef.value.clearValidate()
visible.value = true
await initPage()
}
// 初始化页面数据
const initPage = async () => {}
// 设置数据
const setFormData = async (data) => {
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
formData[key] = data[key]
}
}
}
// 数据保存
const submit = async (done) => {
const validate = await formRef.value?.validate()
if (!validate) {
loading.value = true
let data = { ...formData }
let result = {}
if (mode.value === 'add') {
// 添加数据
data.id = undefined
result = await dict.addDictData(data)
} else {
// 修改数据
result = await dict.editDictData(data.id, data)
}
if (result.code === 200) {
Message.success('操作成功')
emit('success')
done(true)
}
// 防止连续点击提交
setTimeout(() => {
loading.value = false
}, 500)
}
done(false)
}
// 关闭弹窗
const close = () => (visible.value = false)
defineExpose({ open, setFormData })
</script>

View File

@@ -0,0 +1,120 @@
<template>
<component
is="a-modal"
v-model:visible="visible"
:width="tool.getDevice() === 'mobile' ? '100%' : '600px'"
:title="title"
:mask-closable="false"
:ok-loading="loading"
@cancel="close"
@before-ok="submit">
<!-- 表单信息 start -->
<a-form ref="formRef" :model="formData" :rules="rules" :auto-label-width="true">
<a-form-item label="字典名称" field="name">
<a-input v-model="formData.name" placeholder="请输入字典名称" />
</a-form-item>
<a-form-item label="字典标识" field="code">
<a-input v-model="formData.code" placeholder="请输入字典标识" />
</a-form-item>
<a-form-item label="状态" field="status">
<sa-radio v-model="formData.status" dict="data_status" placeholder="请选择状态" />
</a-form-item>
<a-form-item label="备注" field="remark">
<a-textarea v-model="formData.remark" placeholder="请输入备注" />
</a-form-item>
</a-form>
<!-- 表单信息 end -->
</component>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import tool from '@/utils/tool'
import { dictType } from '@/api/system/dict'
const emit = defineEmits(['success'])
// 引用定义
const formRef = ref()
const mode = ref('')
const visible = ref(false)
const loading = ref(false)
let title = computed(() => {
return '数据字典' + (mode.value == 'add' ? '-新增' : '-编辑')
})
// 表单初始值
const initialFormData = {
id: '',
name: '',
code: '',
status: 1,
remark: '',
}
// 表单信息
const formData = reactive({ ...initialFormData })
// 验证规则
const rules = {
name: [{ required: true, message: '字典名称不能为空' }],
code: [{ required: true, message: '字典标识不能为空' }],
}
// 打开弹框
const open = async (type = 'add') => {
mode.value = type
// 重置表单数据
Object.assign(formData, initialFormData)
formRef.value.clearValidate()
visible.value = true
await initPage()
}
// 初始化页面数据
const initPage = async () => {}
// 设置数据
const setFormData = async (data) => {
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
formData[key] = data[key]
}
}
}
// 数据保存
const submit = async (done) => {
const validate = await formRef.value?.validate()
if (!validate) {
loading.value = true
let data = { ...formData }
let result = {}
if (mode.value === 'add') {
// 添加数据
data.id = undefined
result = await dictType.save(data)
} else {
// 修改数据
result = await dictType.update(data.id, data)
}
if (result.code === 200) {
Message.success('操作成功')
emit('success')
done(true)
}
// 防止连续点击提交
setTimeout(() => {
loading.value = false
}, 500)
}
done(false)
}
// 关闭弹窗
const close = () => (visible.value = false)
defineExpose({ open, setFormData })
</script>

View File

@@ -0,0 +1,135 @@
<template>
<div class="ma-content-block lg:flex justify-between">
<sa-table ref="crudRef" :options="options" :columns="columns" :searchForm="searchForm">
<!-- 搜索区 tableSearch -->
<template #tableSearch>
<a-col :sm="8" :xs="24">
<a-form-item field="name" label="名称">
<a-input v-model="searchForm.name" placeholder="请输入名称" allow-clear />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="code" label="标识">
<a-input v-model="searchForm.code" placeholder="请输入标识" allow-clear />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="status" label="状态">
<sa-select v-model="searchForm.status" dict="data_status" placeholder="请选择状态" />
</a-form-item>
</a-col>
</template>
<!-- Table 自定义渲染 -->
<!-- 字典标识列 -->
<template #code="{ record }">
<a-tooltip content="点击查看字典数据">
<a-link @click="openDictList(record)">{{ record.code }}</a-link>
</a-tooltip>
</template>
<!-- 状态开关 -->
<template #status="{ record }">
<sa-switch v-model="record.status" @change="changeStatus($event, record.id)"></sa-switch>
</template>
<!-- 操作前置扩展 -->
<template #operationBeforeExtend="{ record }">
<a-link v-auth="['/core/dictType/save']" @click="openDictList(record)"><icon-list /> 字典数据</a-link>
</template>
</sa-table>
<!-- 编辑表单 -->
<edit-form ref="editRef" @success="refresh" />
<!-- 数据列表 -->
<data-list ref="datalist" />
</div>
</template>
<script setup>
import { onMounted, computed, ref, reactive } from 'vue'
import { Message } from '@arco-design/web-vue'
import { dictType } from '@/api/system/dict'
import EditForm from './edit.vue'
import DataList from './dataList.vue'
const crudRef = ref()
const datalist = ref()
const editRef = ref()
// 搜索表单
const searchForm = ref({
name: '',
code: '',
status: '',
})
// 修改状态
const changeStatus = async (status, id) => {
const response = await dictType.changeStatus({ id, status })
if (response.code === 200) {
Message.success(response.message)
crudRef.value.refresh()
}
}
// 字典数据列表
const openDictList = async (row) => {
datalist.value.open(row)
}
// SaTable 基础配置
const options = reactive({
api: dictType.getPageList,
rowSelection: { showCheckedAll: true },
operationColumnWidth: 240,
add: {
show: true,
auth: ['/core/dictType/save'],
func: async () => {
editRef.value?.open()
},
},
edit: {
show: true,
auth: ['/core/dictType/update'],
func: async (record) => {
editRef.value?.open('edit')
editRef.value?.setFormData(record)
},
},
delete: {
show: true,
auth: ['/core/dictType/destroy'],
func: async (params) => {
const resp = await dictType.destroy(params)
if (resp.code === 200) {
Message.success(`删除成功!`)
crudRef.value?.refresh()
}
},
},
})
// SaTable 列配置
const columns = reactive([
{ title: '字典名称', dataIndex: 'name', width: 220, align: 'left' },
{ title: '字典标识', dataIndex: 'code', width: 260, align: 'left' },
{ title: '状态', dataIndex: 'status', width: 180 },
{ title: '创建时间', dataIndex: 'create_time', width: 180 },
])
// 页面数据初始化
const initPage = async () => {}
// SaTable 数据请求
const refresh = async () => {
crudRef.value?.refresh()
}
// 页面加载完成执行
onMounted(async () => {
initPage()
refresh()
})
</script>

View File

@@ -0,0 +1,110 @@
<template>
<div class="ma-content-block">
<!-- CRUD 组件 -->
<sa-table ref="crudRef" :options="options" :columns="columns" :searchForm="searchForm">
<!-- 搜索区 tableSearch -->
<template #tableSearch>
<a-col :sm="8" :xs="24">
<a-form-item field="from" label="发件人">
<a-input v-model="searchForm.from" placeholder="请输入发件人" allow-clear />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="email" label="收件人">
<a-input v-model="searchForm.email" placeholder="请输入收件人" allow-clear />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="code" label="验证码">
<a-input v-model="searchForm.code" placeholder="请输入验证码" allow-clear />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item label="发送状态" field="status">
<a-select
v-model="searchForm.status"
:field-names="{ label: 'label', value: 'value' }"
:options="optionData"
placeholder="请选择发送状态"
allow-clear />
</a-form-item>
</a-col>
<a-col :sm="16" :xs="24">
<a-form-item field="create_time" label="操作时间">
<a-range-picker v-model="searchForm.create_time" showTime style="width: 100%" />
</a-form-item>
</a-col>
</template>
<template #status="{ record }">
<a-tag color="green" v-if="record.status === 'success'">成功</a-tag>
<a-tag color="red" v-if="record.status === 'failure'">失败</a-tag>
</template>
</sa-table>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import api from '@/api/system/emailLog'
const crudRef = ref()
const optionData = ref([
{ label: '成功', value: 'success' },
{ label: '失败', value: 'failure' },
])
// 搜索表单
const searchForm = ref({
from: '',
email: '',
code: '',
status: '',
create_time: [],
orderBy: 'create_time',
orderType: 'desc',
})
// SaTable 基础配置
const options = reactive({
api: api.getPageList,
rowSelection: { showCheckedAll: true },
operationColumnWidth: 100,
delete: {
show: true,
auth: ['/core/email/destroy'],
func: async (params) => {
const resp = await api.destroy(params)
if (resp.code === 200) {
Message.success(`删除成功!`)
crudRef.value?.refresh()
}
},
},
})
// SaTable 列配置
const columns = reactive([
{ title: '服务Host', dataIndex: 'gateway', width: 120 },
{ title: '发件人', dataIndex: 'from', width: 180 },
{ title: '收件人', dataIndex: 'email', width: 180 },
{ title: '验证码', dataIndex: 'code', width: 100 },
{ title: '状态', dataIndex: 'status', width: 100 },
{ title: '发送结果', dataIndex: 'response', width: 180 },
{ title: '发送时间', dataIndex: 'create_time', width: 180 },
])
// 页面数据初始化
const initPage = async () => {}
// SaTable 数据请求
const refresh = async () => {
crudRef.value?.refresh()
}
// 页面加载完成执行
onMounted(async () => {
initPage()
refresh()
})
</script>

View File

@@ -0,0 +1,102 @@
<template>
<div class="ma-content-block lg:flex justify-between">
<!-- CRUD 组件 -->
<sa-table ref="crudRef" :options="options" :columns="columns" :searchForm="searchForm">
<!-- 搜索区 tableSearch -->
<template #tableSearch>
<a-col :sm="8" :xs="24">
<a-form-item field="username" label="登录用户">
<a-input v-model="searchForm.username" placeholder="请输入登录用户" />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="ip" label="登录IP">
<a-input v-model="searchForm.ip" placeholder="请输入登录IP" />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="status" label="状态">
<a-select v-model="searchForm.status" :options="selectData" placeholder="请选择状态" allowClear />
</a-form-item>
</a-col>
<a-col :sm="16" :xs="24">
<a-form-item field="login_time" label="登录时间">
<a-range-picker v-model="searchForm.login_time" showTime style="width: 100%" />
</a-form-item>
</a-col>
</template>
<!-- Table 自定义渲染 -->
<template #status="{ record }">
<a-tag v-if="record.status == 1" color="green">成功</a-tag>
<a-tag v-else color="red">失败</a-tag>
</template>
</sa-table>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import loginLog from '@/api/system/loginLog'
const crudRef = ref()
const selectData = [
{ label: '成功', value: 1 },
{ label: '失败', value: 2 },
]
// 搜索表单
const searchForm = ref({
name: '',
status: '',
login_time: [],
orderBy: 'login_time',
orderType: 'desc',
})
// SaTable 基础配置
const options = reactive({
api: loginLog.getPageList,
rowSelection: { showCheckedAll: true },
operationColumnWidth: 100,
delete: {
show: true,
auth: ['/core/logs/deleteLoginLog'],
func: async (params) => {
const resp = await loginLog.destroy(params)
if (resp.code === 200) {
Message.success(`删除成功!`)
crudRef.value?.refresh()
}
},
},
})
// SaTable 列配置
const columns = reactive([
{ title: '登录用户', dataIndex: 'username', width: 120 },
{ title: '登录状态', dataIndex: 'status', width: 100 },
{ title: '登录IP', dataIndex: 'ip', width: 150 },
{ title: '登录地点', dataIndex: 'ip_location', width: 150 },
{ title: '操作系统', dataIndex: 'os', width: 140 },
{ title: '浏览器', dataIndex: 'browser', width: 140 },
{ title: '登录信息', dataIndex: 'message', width: 120 },
{ title: '登录时间', dataIndex: 'login_time', width: 180 },
])
// 页面数据初始化
const initPage = async () => {}
// SaTable 数据请求
const refresh = async () => {
crudRef.value?.refresh()
}
// 页面加载完成执行
onMounted(async () => {
initPage()
refresh()
})
</script>

View File

@@ -0,0 +1,89 @@
<template>
<div class="ma-content-block">
<!-- CRUD 组件 -->
<sa-table ref="crudRef" :options="options" :columns="columns" :searchForm="searchForm">
<!-- 搜索区 tableSearch -->
<template #tableSearch>
<a-col :sm="8" :xs="24">
<a-form-item field="username" label="操作用户">
<a-input v-model="searchForm.username" placeholder="请输入操作用户" />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="router" label="操作路由">
<a-input v-model="searchForm.router" placeholder="请输入操作路由" />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="ip" label="操作IP">
<a-input v-model="searchForm.ip" placeholder="请输入登录IP" />
</a-form-item>
</a-col>
<a-col :sm="16" :xs="24">
<a-form-item field="create_time" label="操作时间">
<a-range-picker v-model="searchForm.create_time" showTime style="width: 100%" />
</a-form-item>
</a-col>
</template>
</sa-table>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import operLog from '@/api/system/operLog'
const crudRef = ref()
// 搜索表单
const searchForm = ref({
name: '',
status: '',
login_time: [],
orderBy: 'create_time',
orderType: 'desc',
})
// SaTable 基础配置
const options = reactive({
api: operLog.getPageList,
rowSelection: { showCheckedAll: true, onlyCurrent: false },
operationColumnWidth: 100,
delete: {
show: true,
auth: ['/core/logs/deleteOperLog'],
func: async (params) => {
const resp = await operLog.destroy(params)
if (resp.code === 200) {
Message.success(`删除成功!`)
crudRef.value?.refresh()
}
},
},
})
// SaTable 列配置
const columns = reactive([
{ title: '操作用户', dataIndex: 'username', width: 150 },
{ title: '业务名称', dataIndex: 'service_name', width: 150 },
{ title: '路由', dataIndex: 'router', width: 240 },
{ title: '操作IP', dataIndex: 'ip', width: 150 },
{ title: '操作地点', dataIndex: 'ip_location', width: 150 },
{ title: '操作时间', dataIndex: 'create_time', width: 180 },
])
// 页面数据初始化
const initPage = async () => {}
// SaTable 数据请求
const refresh = async () => {
crudRef.value?.refresh()
}
// 页面加载完成执行
onMounted(async () => {
initPage()
refresh()
})
</script>

View File

@@ -0,0 +1,175 @@
<template>
<component
is="a-drawer"
v-model:visible="visible"
:width="600"
:title="title"
:mask-closable="false"
:ok-loading="loading"
@cancel="close"
@before-ok="submit">
<!-- 表单信息 start -->
<a-form ref="formRef" :model="formData" :rules="rules" :auto-label-width="true">
<a-form-item label="上级菜单" field="parent_id">
<a-tree-select
v-model="formData.parent_id"
:data="menuData"
:field-names="{ key: 'id', title: 'name', children: 'children', icon: 'customIcon' }"
allow-clear
placeholder="请选择上级菜单">
</a-tree-select>
</a-form-item>
<a-form-item label="菜单名称" field="name">
<a-input v-model="formData.name" placeholder="请输入菜单名称" />
</a-form-item>
<a-form-item label="菜单类型" field="type">
<sa-radio v-model="formData.type" dict="menu_type" placeholder="请选择菜单类型" />
</a-form-item>
<a-form-item label="图标" field="icon" v-if="formData.type != 'B'">
<sa-icon-picker v-model="formData.icon" placeholder="请选择图标" />
</a-form-item>
<a-form-item :label="formData.type == 'B' ? '接口地址' : '菜单标识'" field="code">
<a-input v-model="formData.code" placeholder="请输入内容" />
</a-form-item>
<a-form-item label="路由地址" field="route" v-if="formData.type != 'B'">
<a-input v-model="formData.route" placeholder="请输入路由地址" />
</a-form-item>
<a-form-item label="组件地址" field="component" v-if="formData.type == 'M'">
<a-auto-complete
v-model="formData.component"
:data="componentList"
@search="querySearch"
allow-clear
placeholder="请输入组件地址" />
</a-form-item>
<a-form-item label="排序数字" field="sort">
<a-input-number v-model="formData.sort" placeholder="请输入排序数字" />
</a-form-item>
<a-form-item label="是否隐藏" field="is_hidden" v-if="formData.type != 'B'">
<sa-radio v-model="formData.is_hidden" dict="yes_or_no" placeholder="请选择是否隐藏" />
</a-form-item>
<a-form-item label="继承Layout" field="is_layout" v-if="formData.type != 'B'">
<sa-radio v-model="formData.is_layout" dict="yes_or_no" placeholder="请选择是否继承Layout" />
</a-form-item>
<a-form-item label="状态" field="status">
<sa-radio v-model="formData.status" dict="data_status" placeholder="请选择状态" />
</a-form-item>
<a-form-item label="备注" field="remark">
<a-textarea v-model="formData.remark" placeholder="请输入备注" />
</a-form-item>
</a-form>
<!-- 表单信息 end -->
</component>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import api from '@/api/system/menu'
const emit = defineEmits(['success'])
// 引用定义
const visible = ref(false)
const loading = ref(false)
const mode = ref('')
const formRef = ref()
const menuData = ref([])
const componentList = ref([])
let title = computed(() => {
return '菜单管理' + (mode.value == 'add' ? '-新增' : '-编辑')
})
// 表单初始值
const initialFormData = {
id: '',
parent_id: '',
name: '',
type: 'M',
icon: '',
code: '',
route: '',
component: '',
sort: 100,
is_hidden: 2,
is_layout: 1,
status: 1,
remark: '',
}
// 表单信息
const formData = reactive({ ...initialFormData })
// 验证规则
const rules = {
name: [{ required: true, message: '菜单名称不能为空' }],
code: [{ required: true, message: '菜单标识不能为空' }],
}
const modules = import.meta.glob('/src/views/**/*.vue')
// 搜索组件文件
const querySearch = (value) => {
const list = Object.keys(modules).map((item) => item.replace('/src/views/', '').replace('.vue', ''))
componentList.value = list
}
// 打开弹框
const open = async (type = 'add') => {
mode.value = type
// 重置表单数据
Object.assign(formData, initialFormData)
formRef.value.clearValidate()
visible.value = true
await initPage()
}
// 初始化页面数据
const initPage = async () => {
const resp = await api.getList()
menuData.value = resp.data
}
// 设置数据
const setFormData = async (data) => {
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
formData[key] = data[key]
}
}
}
// 数据保存
const submit = async (done) => {
const validate = await formRef.value?.validate()
if (!validate) {
loading.value = true
let data = { ...formData }
let result = {}
if (mode.value === 'add') {
// 添加数据
data.id = undefined
result = await api.save(data)
} else {
// 修改数据
result = await api.update(data.id, data)
}
if (result.code === 200) {
Message.success('操作成功')
emit('success')
done(true)
}
// 防止连续点击提交
setTimeout(() => {
loading.value = false
}, 500)
}
done(false)
}
// 关闭弹窗
const close = () => (visible.value = false)
defineExpose({ open, setFormData })
</script>

View File

@@ -0,0 +1,131 @@
<template>
<div class="ma-content-block lg:flex justify-between">
<!-- CRUD 组件 -->
<sa-table ref="crudRef" :options="options" :columns="columns" :searchForm="searchForm">
<!-- 搜索区 tableSearch -->
<template #tableSearch>
<a-col :span="8">
<a-form-item field="name" label="菜单名称">
<a-input v-model="searchForm.name" placeholder="请输入菜单名称" allow-clear />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item field="code" label="菜单标识">
<a-input v-model="searchForm.code" placeholder="请输入菜单标识" allow-clear />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item field="status" label="状态">
<sa-select v-model="searchForm.status" dict="data_status" placeholder="请选择状态" allow-clear />
</a-form-item>
</a-col>
</template>
<!-- Table 自定义渲染 -->
<!-- 图标列 -->
<template #icon="{ record }">
<sa-icon :icon="record.icon" />
</template>
<!-- 操作前置扩展 -->
<template #operationBeforeExtend="{ record }">
<a-link @click="openAdd(record.id)" v-if="record.type === 'M'" v-auth="['/core/menu/save']">
<icon-plus /> 新增
</a-link>
</template>
</sa-table>
<!-- 编辑表单 -->
<edit-form ref="editRef" @success="refresh" />
</div>
</template>
<script setup>
import { onMounted, ref, reactive, computed } from 'vue'
import api from '@/api/system/menu'
import { Message } from '@arco-design/web-vue'
import EditForm from './edit.vue'
const crudRef = ref()
const editRef = ref()
// 搜索表单
const searchForm = ref({
name: '',
code: '',
status: '',
})
// 添加子菜单
const openAdd = (id) => {
editRef.value?.open()
editRef.value?.setFormData({ parent_id: id })
}
// SaTable 基础配置
const options = reactive({
api: api.getList,
rowSelection: { showCheckedAll: true },
operationColumnWidth: 200,
add: {
show: true,
auth: ['/core/menu/save'],
func: async () => {
editRef.value?.open()
},
},
edit: {
show: true,
auth: ['/core/menu/update'],
func: async (record) => {
editRef.value?.open('edit')
editRef.value?.setFormData(record)
},
},
delete: {
show: true,
auth: ['/core/menu/destroy'],
func: async (params) => {
const resp = await api.destroy(params)
if (resp.code === 200) {
Message.success(`删除成功!`)
crudRef.value?.refresh()
}
},
},
isExpand: true,
})
// SaTable 列配置
const columns = reactive([
{ title: '菜单名称', dataIndex: 'name', width: 180 },
{ title: '菜单类型', dataIndex: 'type', type: 'dict', dict: 'menu_type', width: 100 },
{ title: '图标', dataIndex: 'icon', width: 80 },
{ title: '菜单标识', dataIndex: 'code', width: 150 },
{ title: '路由地址', dataIndex: 'route', width: 150 },
{ title: '视图组件', dataIndex: 'component', width: 200 },
{ title: '排序', dataIndex: 'sort', width: 80 },
{ title: '隐藏', dataIndex: 'is_hidden', type: 'dict', dict: 'yes_or_no', width: 80 },
{ title: '状态', dataIndex: 'status', type: 'dict', dict: 'data_status', width: 80 },
{ title: '创建时间', dataIndex: 'create_time', width: 180 },
])
// 页面数据初始化
const initPage = async () => {}
// SaTable 数据请求
const refresh = async () => {
crudRef.value?.refresh()
}
// 页面加载完成执行
onMounted(async () => {
initPage()
refresh()
})
</script>
<style scoped>
.icon {
width: 1em;
}
</style>

View File

@@ -0,0 +1,126 @@
<template>
<a-layout-content>
<div class="ma-content-block lg:flex p-4" v-if="cpu">
<a-skeleton animation v-if="loading" class="w-full">
<a-space direction="vertical" class="w-full" size="large">
<a-skeleton-line :rows="5" />
</a-space>
</a-skeleton>
<div class="flex justify-between w-full redis-info mt-3" v-else>
<a-descriptions :column="1" size="large" bordered title="CPU信息" class="lg:w-9/12 w-full">
<a-descriptions-item label="型号">{{ cpu.name }}</a-descriptions-item>
<a-descriptions-item label="核心数">{{ cpu.cores }}</a-descriptions-item>
<a-descriptions-item label="缓存">{{ cpu.cache }}</a-descriptions-item>
<a-descriptions-item label="使用率">{{ cpu.usage }}%</a-descriptions-item>
<a-descriptions-item label="空闲率">{{ cpu.free }}%</a-descriptions-item>
</a-descriptions>
<div class="echarts hidden lg:block">
<sa-chart :options="cpuOptions" width="350px" height="350px" />
</div>
</div>
</div>
<div class="ma-content-block lg:flex p-4 mt-3" v-if="memory">
<a-skeleton animation v-if="loading" class="w-full">
<a-space direction="vertical" class="w-full" size="large">
<a-skeleton-line :rows="5" />
</a-space>
</a-skeleton>
<div class="flex justify-between w-full redis-info mt-3" v-else>
<a-descriptions :column="1" size="large" bordered title="内存信息" class="lg:w-9/12 w-full">
<a-descriptions-item label="总内存">{{ memory.total }}G</a-descriptions-item>
<a-descriptions-item label="已使用内存">{{ memory.usage }}G</a-descriptions-item>
<a-descriptions-item label="PHP使用内存">{{ memory.php }}M</a-descriptions-item>
<a-descriptions-item label="空闲内存">{{ memory.free }}G</a-descriptions-item>
<a-descriptions-item label="使用率">{{ memory.rate }}%</a-descriptions-item>
</a-descriptions>
<div class="echarts hidden lg:block">
<sa-chart :options="memoryOptions" width="350px" height="350px" />
</div>
</div>
</div>
<div class="ma-content-block lg:flex p-4 mt-3" v-if="phpenv">
<a-skeleton animation v-if="loading" class="w-full">
<a-space direction="vertical" class="w-full" size="large">
<a-skeleton-line :rows="5" />
</a-space>
</a-skeleton>
<div class="flex justify-between w-full redis-info mt-3" v-else>
<a-descriptions :column="2" size="large" bordered title="PHP及环境信息" class="w-full">
<a-descriptions-item label="操作系统">{{ phpenv.os }}</a-descriptions-item>
<a-descriptions-item label="PHP版本">{{ phpenv.php_version }}</a-descriptions-item>
<a-descriptions-item label="系统物理路径">{{ phpenv.project_path }}</a-descriptions-item>
</a-descriptions>
</div>
</div>
</a-layout-content>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import monitor from '@/api/system/monitor'
const cpuOptions = ref({})
const memoryOptions = ref({})
const cpu = ref({})
const memory = ref({})
const phpenv = ref({})
const disk = ref({})
const loading = ref(true)
// 获取基本信息
const getCacheInfo = async () => {
loading.value = true
const response = await monitor.getServerInfo()
cpu.value = response.data?.cpu
memory.value = response.data?.memory
phpenv.value = response.data?.phpenv
disk.value = response.data?.disk
cpuOptions.value = {
tooltip: { formatter: '{b} : {c}%' },
series: [
{
name: 'CPU使用率',
type: 'gauge',
progress: { show: true },
detail: { valueAnimation: true, formatter: '{value}' },
data: [{ value: cpu.value.usage, name: 'CPU使用率' }],
},
],
}
memoryOptions.value = {
tooltip: { formatter: '{b} : {c}%' },
series: [
{
name: '内存使用率',
type: 'gauge',
progress: { show: true },
detail: { valueAnimation: true, formatter: '{value}' },
data: [{ value: memory.value.rate, name: '内存使用率' }],
},
],
}
loading.value = false
}
// 页面加载完成执行
onMounted(async () => {
getCacheInfo()
})
</script>
<style scoped>
.redis-info {
max-height: 260px;
overflow: hidden;
}
.echarts {
width: 350px;
position: relative;
top: -10px;
right: -10px;
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<component
is="a-modal"
v-model:visible="visible"
:width="tool.getDevice() === 'mobile' ? '100%' : '800px'"
:title="title"
:mask-closable="false"
:ok-loading="loading"
@cancel="close"
@before-ok="submit">
<!-- 表单信息 start -->
<a-form ref="formRef" :model="formData" :rules="rules" :auto-label-width="true">
<a-form-item label="公告标题" field="title">
<a-input v-model="formData.title" placeholder="请输入公告标题" />
</a-form-item>
<a-form-item label="公告类型" field="type">
<sa-select v-model="formData.type" dict="backend_notice_type" placeholder="请选择公告类型" />
</a-form-item>
<a-form-item label="公告内容" field="content">
<ma-wangEditor v-model="formData.content" placeholder="请输入公告内容" />
</a-form-item>
<a-form-item label="备注" field="remark">
<a-textarea v-model="formData.remark" placeholder="请输入备注" />
</a-form-item>
</a-form>
<!-- 表单信息 end -->
</component>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import tool from '@/utils/tool'
import api from '@/api/system/notice'
const emit = defineEmits(['success'])
// 引用定义
const formRef = ref()
const mode = ref('')
const visible = ref(false)
const loading = ref(false)
let title = computed(() => {
return '公告管理' + (mode.value == 'add' ? '-新增' : '-编辑')
})
// 表单初始值
const initialFormData = {
id: '',
title: '',
type: '1',
content: '',
remark: '',
user: '',
}
// 表单信息
const formData = reactive({ ...initialFormData })
// 验证规则
const rules = {
title: [{ required: true, message: '公告标题不能为空' }],
type: [{ required: true, message: '公告类型不能为空' }],
content: [{ required: true, message: '公告内容不能为空' }],
}
// 打开弹框
const open = async (type = 'add') => {
mode.value = type
// 重置表单数据
Object.assign(formData, initialFormData)
formRef.value.clearValidate()
visible.value = true
await initPage()
}
// 初始化页面数据
const initPage = async () => {}
// 设置数据
const setFormData = async (data) => {
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
formData[key] = data[key]
}
}
}
// 数据保存
const submit = async (done) => {
const validate = await formRef.value?.validate()
if (!validate) {
loading.value = true
let data = { ...formData }
let result = {}
if (mode.value === 'add') {
// 添加数据
data.id = undefined
result = await api.save(data)
} else {
// 修改数据
result = await api.update(data.id, data)
}
if (result.code === 200) {
Message.success('操作成功')
emit('success')
done(true)
}
// 防止连续点击提交
setTimeout(() => {
loading.value = false
}, 500)
}
done(false)
}
// 关闭弹窗
const close = () => (visible.value = false)
defineExpose({ open, setFormData })
</script>

View File

@@ -0,0 +1,98 @@
<template>
<div class="ma-content-block lg:flex justify-between">
<!-- CRUD 组件 -->
<sa-table ref="crudRef" :options="options" :columns="columns" :searchForm="searchForm">
<!-- 搜索区 tableSearch -->
<template #tableSearch>
<a-col :sm="8" :xs="24">
<a-form-item field="title" label="公告标题">
<a-input v-model="searchForm.title" placeholder="请输入公告名称" />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="type" label="公告类型">
<sa-select v-model="searchForm.type" dict="backend_notice_type" placeholder="请选择公告类型" />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="create_time" label="创建时间">
<a-range-picker v-model="searchForm.create_time" style="width: 100%" />
</a-form-item>
</a-col>
</template>
</sa-table>
<!-- 编辑表单 -->
<edit-form ref="editRef" @success="refresh" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import EditForm from './edit.vue'
import api from '@/api/system/notice'
const crudRef = ref()
const editRef = ref()
// 搜索表单
const searchForm = ref({
title: '',
type: '',
create_time: [],
})
// SaTable 基础配置
const options = reactive({
api: api.getPageList,
rowSelection: { showCheckedAll: true },
add: {
show: true,
auth: ['/core/notice/save'],
func: async () => {
editRef.value?.open()
},
},
edit: {
show: true,
auth: ['/core/notice/update'],
func: async (record) => {
editRef.value?.open('edit')
editRef.value?.setFormData(record)
},
},
delete: {
show: true,
auth: ['/core/notice/destroy'],
func: async (params) => {
const resp = await api.destroy(params)
if (resp.code === 200) {
Message.success(`删除成功!`)
crudRef.value?.refresh()
}
},
},
})
// SaTable 列配置
const columns = reactive([
{ title: '公告标题', dataIndex: 'title', width: 500 },
{ title: '公告类型', dataIndex: 'type', type: 'dict', dict: 'backend_notice_type', width: 180 },
{ title: '创建时间', dataIndex: 'create_time', width: 180 },
])
// 页面数据初始化
const initPage = async () => {}
// SaTable 数据请求
const refresh = async () => {
crudRef.value?.refresh()
}
// 页面加载完成执行
onMounted(async () => {
initPage()
refresh()
})
</script>

View File

@@ -0,0 +1,124 @@
<template>
<component
is="a-modal"
v-model:visible="visible"
:width="tool.getDevice() === 'mobile' ? '100%' : '600px'"
:title="title"
:mask-closable="false"
:ok-loading="loading"
@cancel="close"
@before-ok="submit">
<!-- 表单信息 start -->
<a-form ref="formRef" :model="formData" :rules="rules" :auto-label-width="true">
<a-form-item label="岗位名称" field="name">
<a-input v-model="formData.name" placeholder="请输入岗位名称" />
</a-form-item>
<a-form-item label="岗位标识" field="code">
<a-input v-model="formData.code" placeholder="请输入岗位标识" />
</a-form-item>
<a-form-item label="排序" field="sort">
<a-input-number v-model="formData.sort" placeholder="请输入排序" />
</a-form-item>
<a-form-item label="状态" field="status">
<sa-radio v-model="formData.status" dict="data_status" placeholder="请选择状态" />
</a-form-item>
<a-form-item label="备注" field="remark">
<a-textarea v-model="formData.remark" placeholder="请输入备注" />
</a-form-item>
</a-form>
<!-- 表单信息 end -->
</component>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import tool from '@/utils/tool'
import api from '@/api/system/post'
const emit = defineEmits(['success'])
// 引用定义
const formRef = ref()
const mode = ref('')
const visible = ref(false)
const loading = ref(false)
let title = computed(() => {
return '岗位管理' + (mode.value == 'add' ? '-新增' : '-编辑')
})
// 表单初始值
const initialFormData = {
id: '',
name: '',
code: '',
sort: 100,
status: 1,
remark: '',
}
// 表单信息
const formData = reactive({ ...initialFormData })
// 验证规则
const rules = {
name: [{ required: true, message: '岗位名称不能为空' }],
code: [{ required: true, message: '岗位标识不能为空' }],
}
// 打开弹框
const open = async (type = 'add') => {
mode.value = type
// 重置表单数据
Object.assign(formData, initialFormData)
formRef.value.clearValidate()
visible.value = true
await initPage()
}
// 初始化页面数据
const initPage = async () => {}
// 设置数据
const setFormData = async (data) => {
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
formData[key] = data[key]
}
}
}
// 数据保存
const submit = async (done) => {
const validate = await formRef.value?.validate()
if (!validate) {
loading.value = true
let data = { ...formData }
let result = {}
if (mode.value === 'add') {
// 添加数据
data.id = undefined
result = await api.save(data)
} else {
// 修改数据
result = await api.update(data.id, data)
}
if (result.code === 200) {
Message.success('操作成功')
emit('success')
done(true)
}
// 防止连续点击提交
setTimeout(() => {
loading.value = false
}, 500)
}
done(false)
}
// 关闭弹窗
const close = () => (visible.value = false)
defineExpose({ open, setFormData })
</script>

View File

@@ -0,0 +1,136 @@
<template>
<div class="ma-content-block lg:flex justify-between">
<!-- CRUD 组件 -->
<sa-table ref="crudRef" :options="options" :columns="columns" :searchForm="searchForm">
<!-- 搜索区 tableSearch -->
<template #tableSearch>
<a-col :sm="8" :xs="24">
<a-form-item field="name" label="岗位名称">
<a-input v-model="searchForm.name" placeholder="请输入岗位名称" allow-clear />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="status" label="状态">
<sa-select v-model="searchForm.status" dict="data_status" placeholder="请选择状态" />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="create_time" label="时间范围">
<a-range-picker v-model="searchForm.create_time" style="width: 100%" />
</a-form-item>
</a-col>
</template>
<!-- Table 自定义渲染 -->
<!-- 状态列 -->
<template #status="{ record }">
<sa-switch v-model="record.status" @change="changeStatus($event, record.id)"></sa-switch>
</template>
</sa-table>
<!-- 编辑表单 -->
<edit-form ref="editRef" @success="refresh" />
<!-- 查看表单 -->
<view-form ref="viewRef" @success="refresh" />
</div>
</template>
<script setup>
import { onMounted, ref, reactive } from 'vue'
import { Message } from '@arco-design/web-vue'
import EditForm from './edit.vue'
import ViewForm from './view.vue'
import api from '@/api/system/post'
const crudRef = ref()
const editRef = ref()
const viewRef = ref()
// 搜索表单
const searchForm = ref({
name: '',
status: '',
create_time: [],
})
// 修改状态
const changeStatus = async (status, id) => {
const response = await api.changeStatus({ id, status })
if (response.code === 200) {
Message.success(response.message)
crudRef.value.refresh()
}
}
// SaTable 基础配置
const options = reactive({
api: api.getPageList,
rowSelection: { showCheckedAll: true },
view: {
show: true,
auth: ['/core/post/read'],
func: async (record) => {
viewRef.value?.open(record)
},
},
add: {
show: true,
auth: ['/core/post/save'],
func: async () => {
editRef.value?.open()
},
},
edit: {
show: true,
auth: ['/core/post/update'],
func: async (record) => {
editRef.value?.open('edit')
editRef.value?.setFormData(record)
},
},
delete: {
show: true,
auth: ['/core/post/destroy'],
func: async (params) => {
const resp = await api.destroy(params)
if (resp.code === 200) {
Message.success(`删除成功!`)
crudRef.value?.refresh()
}
},
},
import: {
show: true,
url: '/core/post/import',
templateUrl: '/core/post/downloadTemplate',
auth: ['/core/post/import'],
},
export: { show: true, url: '/core/post/export', auth: ['/core/post/export'] },
})
// SaTable 列配置
const columns = reactive([
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '岗位名称', dataIndex: 'name', width: 120 },
{ title: '岗位标识', dataIndex: 'code', width: 180 },
{ title: '排序', dataIndex: 'sort', width: 180 },
{ title: '状态', dataIndex: 'status', type: 'dict', dict: 'data_status', width: 120 },
{ title: '备注', dataIndex: 'remark', width: 180 },
{ title: '创建时间', dataIndex: 'create_time', width: 180 },
])
// 页面数据初始化
const initPage = async () => {}
// SaTable 数据请求
const refresh = async () => {
crudRef.value?.refresh()
}
// 页面加载完成执行
onMounted(async () => {
initPage()
refresh()
})
</script>

View File

@@ -0,0 +1,67 @@
<template>
<component
is="a-drawer"
v-model:visible="visible"
:width="tool.getDevice() === 'mobile' ? '100%' : '60%'"
title="查看详情"
:footer="false">
<!-- 详情 start -->
<a-spin :loading="loading" class="w-full">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="编号">
<span v-text="formData?.id"></span>
</a-descriptions-item>
<a-descriptions-item label="岗位名称">
<span v-text="formData?.name"></span>
</a-descriptions-item>
<a-descriptions-item label="岗位标识">
<span v-text="formData?.code"></span>
</a-descriptions-item>
<a-descriptions-item label="排序">
<span v-text="formData?.sort"></span>
</a-descriptions-item>
<a-descriptions-item label="状态">
<sa-dict :value="formData?.status" dict="data_status" render="span" />
</a-descriptions-item>
<a-descriptions-item label="备注">
<span v-text="formData?.remark"></span>
</a-descriptions-item>
</a-descriptions>
</a-spin>
<!-- 详情 end -->
</component>
</template>
<script setup>
import { ref, reactive } from 'vue'
import tool from '@/utils/tool'
import api from '@/api/system/post'
const emit = defineEmits(['success'])
// 引用定义
const rowData = ref()
const formData = ref()
const visible = ref(false)
const loading = ref(false)
// 打开弹框
const open = async (record) => {
rowData.value = record
formData.value = {}
visible.value = true
await initPage()
}
// 初始化页面数据
const initPage = async () => {
loading.value = true
const resp = await api.read(rowData.value?.id)
if (resp.code === 200) {
formData.value = resp.data
}
loading.value = false
}
defineExpose({ open })
</script>

View File

@@ -0,0 +1,119 @@
<template>
<a-modal v-model:visible="visible" @cancel="close" @before-ok="submit">
<template #title>菜单权限</template>
<a-form :model="form">
<a-form-item label="角色名称" field="name">
<a-input disabled v-model="form.name" />
</a-form-item>
<a-form-item label="角色标识" field="code">
<a-input disabled v-model="form.code" />
</a-form-item>
<a-form-item label="菜单列表" field="menu_ids">
<a-spin :loading="loading" tip="菜单加载中..." class="w-full">
<div class="w-full">
<a-space class="mt-1.5" size="large">
<a-checkbox @change="handlerExpand">展开/折叠</a-checkbox>
<a-checkbox @change="handlerSelect">全选/全不选</a-checkbox>
<a-checkbox v-model="cancelLinkage" @change="handlerLinkage">关闭父子级联动</a-checkbox>
</a-space>
<div class="tree-container">
<sa-tree-slider
ref="tree"
:data="menuList"
checkable
:fieldNames="{ title: 'label', key: 'id' }"
searchPlaceholder="过滤菜单"
v-model:checked-keys="selectKeys"
:check-strictly="cancelLinkage"
:virtual-list-props="{ height: 300 }"
@click="handlerClick" />
</div>
</div>
</a-spin>
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup>
import { ref } from 'vue'
import role from '@/api/system/role'
import menu from '@/api/system/menu'
import { Message } from '@arco-design/web-vue'
const visible = ref(false)
const loading = ref(true)
const menuList = ref([])
const selectKeys = ref([])
const cancelLinkage = ref(false)
const tree = ref()
const form = ref({ name: undefined, code: undefined })
const emit = defineEmits(['success'])
// 打开弹窗
const open = async (row) => {
visible.value = true
form.value = { id: row.id, name: row.name, code: row.code }
handlerExpand(false)
handlerSelect(false)
handlerLinkage(false)
await setData(row.id)
}
// 展开/折叠
const handlerExpand = (value) => {
tree.value.saTree.expandAll(value)
}
// 全选/取消全选
const handlerSelect = (value) => {
tree.value.saTree.checkAll(value)
}
// 关联/取消关联
const handlerLinkage = (value) => {
cancelLinkage.value = value
}
// 点击树节点
const handlerClick = (value) => {
const t = tree.value.saTree
const nodes = t.getExpandedNodes().map((item) => item.id)
t.expandNode(value, nodes.includes(value[0]) ? false : true)
}
// 获取数据
const setData = async (roleId) => {
loading.value = true
const menuResponse = await menu.accessMenu({ tree: true })
menuList.value = menuResponse.data
const roleResponse = await role.getMenuByRole(roleId)
selectKeys.value = roleResponse.data.menus.map((item) => item.id)
selectKeys.value.length > 0 && handlerLinkage(true)
loading.value = false
}
// 数据保存
const submit = async (done) => {
const nodes = tree.value.saTree.getCheckedNodes()
const ids = nodes.map((item) => item.id)
const response = await role.updateMenuPermission(form.value.id, { menu_ids: ids })
response.code === 200 && Message.success(response.message)
emit('success')
done(true)
}
const close = () => (visible.value = false)
defineExpose({ open })
</script>
<style scoped>
.tree-container {
border: 1px solid var(--color-fill-2);
max-height: 350px;
padding-bottom: 8px;
margin-top: 5px;
}
</style>

View File

@@ -0,0 +1,141 @@
<template>
<component
is="a-modal"
v-model:visible="visible"
:width="tool.getDevice() === 'mobile' ? '100%' : '600px'"
:title="title"
:mask-closable="false"
:ok-loading="loading"
@cancel="close"
@before-ok="submit">
<!-- 表单信息 start -->
<a-form ref="formRef" :model="formData" :rules="rules" :auto-label-width="true">
<a-form-item field="parent_id" label="上级角色">
<a-tree-select
v-model="formData.parent_id"
:data="roleData"
:field-names="{ key: 'value', title: 'label' }"
allow-clear
placeholder="请选择上级角色">
</a-tree-select>
</a-form-item>
<a-form-item label="角色名称" field="name">
<a-input v-model="formData.name" placeholder="请输入角色名称" />
</a-form-item>
<a-form-item label="角色标识" field="code">
<a-input v-model="formData.code" placeholder="请输入角色标识" />
</a-form-item>
<a-form-item label="排序数字" field="sort">
<a-input-number v-model="formData.sort" placeholder="请输入排序数字" />
</a-form-item>
<a-form-item label="状态" field="status">
<sa-radio v-model="formData.status" dict="data_status" placeholder="请选择状态" />
</a-form-item>
<a-form-item label="备注" field="remark">
<a-textarea v-model="formData.remark" placeholder="请输入备注" />
</a-form-item>
</a-form>
<!-- 表单信息 end -->
</component>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import commonApi from '@/api/common'
import tool from '@/utils/tool'
import api from '@/api/system/role'
const emit = defineEmits(['success'])
// 引用定义
const formRef = ref()
const mode = ref('')
const visible = ref(false)
const loading = ref(false)
const roleData = ref([])
let title = computed(() => {
return '角色管理' + (mode.value == 'add' ? '-新增' : '-编辑')
})
// 表单初始值
const initialFormData = {
id: '',
parent_id: '',
level: '',
name: '',
code: '',
sort: 100,
status: 1,
remark: '',
}
// 表单信息
const formData = reactive({ ...initialFormData })
// 验证规则
const rules = {
parent_id: [{ required: true, message: '上级角色不能为空' }],
name: [{ required: true, message: '角色名称不能为空' }],
code: [{ required: true, message: '角色标识不能为空' }],
}
// 打开弹框
const open = async (type = 'add') => {
mode.value = type
// 重置表单数据
Object.assign(formData, initialFormData)
formRef.value.clearValidate()
visible.value = true
await initPage()
}
// 初始化页面数据
const initPage = async () => {
const resp = await commonApi.commonGet('/core/role/index?tree=true&filter=false')
roleData.value = resp.data
}
// 设置数据
const setFormData = async (data) => {
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
formData[key] = data[key]
}
}
}
// 数据保存
const submit = async (done) => {
const validate = await formRef.value?.validate()
if (!validate) {
loading.value = true
let data = { ...formData }
let result = {}
if (mode.value === 'add') {
// 添加数据
data.id = undefined
result = await api.save(data)
} else {
// 修改数据
result = await api.update(data.id, data)
}
if (result.code === 200) {
Message.success('操作成功')
emit('success')
done(true)
}
// 防止连续点击提交
setTimeout(() => {
loading.value = false
}, 500)
}
done(false)
}
// 关闭弹窗
const close = () => (visible.value = false)
defineExpose({ open, setFormData })
</script>

View File

@@ -0,0 +1,132 @@
<template>
<div class="ma-content-block lg:flex justify-between">
<!-- CRUD 组件 -->
<sa-table ref="crudRef" :options="options" :columns="columns" :searchForm="searchForm">
<!-- 搜索区 tableSearch -->
<template #tableSearch>
<a-col :sm="8" :xs="24">
<a-form-item field="name" label="角色名称">
<a-input v-model="searchForm.name" placeholder="请输入角色名称" allow-clear />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="code" label="角色标识">
<a-input v-model="searchForm.code" placeholder="请输入角色标识" allow-clear />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="status" label="状态">
<sa-select v-model="searchForm.status" dict="data_status" placeholder="请选择状态" alow-clear />
</a-form-item>
</a-col>
</template>
<!-- Table 自定义渲染 -->
<!-- 操作列 -->
<template #operationCell="{ record }">
<div v-if="record.disabled"></div>
</template>
<!-- 操作前置扩展 -->
<template #operationBeforeExtend="{ record }">
<a-space size="mini" v-if="record.id > 1 && !record.disabled">
<a-link v-auth="['/core/role/menuPermission']" @click="openMenuList(record)"> <icon-menu /> 菜单权限 </a-link>
</a-space>
</template>
</sa-table>
<!-- 编辑表单 -->
<edit-form ref="editRef" @success="refresh" />
<!-- 菜单权限 -->
<menu-permission ref="mpRef" @success="refresh" />
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import api from '@/api/system/role'
import { Message } from '@arco-design/web-vue'
import MenuPermission from './components/menuPermission.vue'
import EditForm from './edit.vue'
const crudRef = ref()
const editRef = ref()
const mpRef = ref()
// 搜索表单
const searchForm = ref({
name: '',
code: '',
status: '',
})
// 打开菜单权限
const openMenuList = (record) => {
mpRef.value.open(record)
}
// SaTable 基础配置
const options = reactive({
api: api.getPageList,
rowSelection: { showCheckedAll: true },
isExpand: true,
operationColumnWidth: 220,
add: {
show: true,
auth: ['/core/role/save'],
func: async () => {
editRef.value?.open()
},
},
edit: {
show: true,
auth: ['/core/role/update'],
func: async (record) => {
if (record.id === 1) {
Message.error('超级管理员角色不可编辑')
return false
}
editRef.value?.open('edit')
editRef.value?.setFormData(record)
},
},
delete: {
show: true,
auth: ['/core/role/destroy'],
func: async (params) => {
if (params.ids.includes(1)) {
Message.error('超级管理员角色不可删除')
return false
}
const resp = await api.destroy(params)
if (resp.code === 200) {
Message.success(`删除成功!`)
crudRef.value?.refresh()
}
},
},
})
// SaTable 列配置
const columns = reactive([
{ title: '角色名称', dataIndex: 'name', width: 220 },
{ title: '角色标识', dataIndex: 'code', width: 180 },
{ title: '排序', dataIndex: 'sort', width: 150 },
{ title: '状态', dataIndex: 'status', type: 'dict', dict: 'data_status', width: 100 },
])
// 页面数据初始化
const initPage = async () => {}
// SaTable 数据刷新
const refresh = async () => {
crudRef.value?.refresh()
}
// 页面加载完成执行
onMounted(() => {
initPage()
refresh()
})
</script>
<style scoped></style>

View File

@@ -0,0 +1,229 @@
<template>
<component
is="a-modal"
v-model:visible="visible"
:width="tool.getDevice() === 'mobile' ? '100%' : '800px'"
:title="title"
:mask-closable="false"
:ok-loading="loading"
@cancel="close"
@before-ok="submit">
<!-- 表单信息 start -->
<a-form ref="formRef" :model="formData" :rules="rules" :auto-label-width="true">
<a-row :gutter="16">
<a-col :span="24">
<a-form-item label="头像" field="avatar">
<sa-upload-image v-model="formData.avatar" :rounded="true" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="username" label="账户">
<a-input v-model="formData.username" :disabled="mode === 'edit'" placeholder="请输入账户" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="dept_id" label="所属部门">
<a-tree-select
v-model="formData.dept_id"
:data="deptData"
:field-names="{ key: 'value', title: 'label' }"
allow-clear
placeholder="请选择所属部门">
</a-tree-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="password" label="密码">
<a-input-password v-model="formData.password" :disabled="mode === 'edit'" placeholder="请输入密码" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="nickname" label="昵称">
<a-input v-model="formData.nickname" placeholder="请输入昵称" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="role_ids" label="角色">
<a-tree-select
v-model="formData.role_ids"
:data="roleData"
:field-names="{ key: 'value', title: 'label' }"
:tree-check-strictly="true"
allow-clear
tree-checkable
placeholder="请选择角色">
</a-tree-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="phone" label="手机">
<a-input v-model="formData.phone" placeholder="请输入手机" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="post_ids" label="岗位">
<a-select
v-model="formData.post_ids"
:options="postData"
:field-names="{ label: 'name', value: 'id' }"
multiple
allow-clear
placeholder="请选择岗位" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="email" label="邮箱">
<a-input v-model="formData.email" placeholder="请输入邮箱" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="24">
<a-form-item label="状态" field="status">
<sa-radio v-model="formData.status" dict="data_status" placeholder="请选择状态" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="24">
<a-form-item label="备注" field="remark">
<a-textarea v-model="formData.remark" placeholder="请输入备注" />
</a-form-item>
</a-col>
</a-row>
</a-form>
<!-- 表单信息 end -->
</component>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import api from '@/api/system/user'
import tool from '@/utils/tool'
import commonApi from '@/api/common'
const emit = defineEmits(['success'])
// 引用定义
const formRef = ref()
const mode = ref('')
const visible = ref(false)
const loading = ref(false)
const deptData = ref([])
const roleData = ref([])
const postData = ref([])
let title = computed(() => {
return '用户管理' + (mode.value == 'add' ? '-新增' : '-编辑')
})
// 表单初始值
const initialFormData = {
id: '',
avatar: '',
username: '',
nickname: '',
dept_id: '',
password: '',
role_ids: [],
phone: '',
post_ids: [],
email: '',
status: 1,
remark: '',
}
// 表单信息
const formData = reactive({ ...initialFormData })
// 验证规则
const rules = {
username: [{ required: true, message: '账户不能为空' }],
dept_id: [{ required: true, message: '部门不能为空' }],
role_ids: [{ required: true, message: '角色不能为空' }],
}
// 打开弹框
const open = async (type = 'add', id = '') => {
mode.value = type
// 重置表单数据
Object.assign(formData, initialFormData)
formRef.value.clearValidate()
visible.value = true
await initPage()
if (type == 'edit') {
const { data } = await api.read(id)
if (data.postList) {
const post = data.postList.map((item) => item.id)
data.post_ids = post
}
const role = data.roleList.map((item) => item.id)
data.role_ids = role
data.password = ''
setFormData(data)
}
}
// 初始化页面数据
const initPage = async () => {
const deptResp = await commonApi.commonGet('/core/dept/accessDept')
deptData.value = deptResp.data
const roleResp = await commonApi.commonGet('/core/role/accessRole')
roleData.value = roleResp.data
const postResp = await commonApi.commonGet('/core/post/accessPost')
postData.value = postResp.data
}
// 设置数据
const setFormData = async (data) => {
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
formData[key] = data[key]
}
}
}
// 数据保存
const submit = async (done) => {
const validate = await formRef.value?.validate()
if (!validate) {
loading.value = true
let data = { ...formData }
let result = {}
if (mode.value === 'add') {
// 添加数据
data.id = undefined
result = await api.save(data)
} else {
// 修改数据
result = await api.update(data.id, data)
}
if (result.code === 200) {
Message.success('操作成功')
emit('success')
done(true)
}
// 防止连续点击提交
setTimeout(() => {
loading.value = false
}, 500)
}
done(false)
}
// 关闭弹窗
const close = () => (visible.value = false)
defineExpose({ open, setFormData })
</script>

View File

@@ -0,0 +1,246 @@
<template>
<div class="ma-content-block lg:flex justify-between">
<div class="lg:w-2/12 pt-4 pl-2 pr-2">
<sa-tree-slider :data="depts" search-placeholder="搜索部门" @click="switchDept" v-model="defaultKey" />
</div>
<div class="lg:w-10/12 w-full">
<!-- CRUD 组件 -->
<sa-table ref="crudRef" :options="options" :columns="columns" :searchForm="searchForm" @resetSearch="handleReset">
<!-- 搜索区 tableSearch -->
<template #tableSearch>
<a-col :sm="8" :xs="24">
<a-form-item field="username" label="账号名称">
<a-input v-model="searchForm.username" placeholder="请输入账号名称" allow-clear />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="phone" label="手机">
<a-input v-model="searchForm.phone" placeholder="请输入手机" allow-clear />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="email" label="邮箱">
<a-input v-model="searchForm.email" placeholder="请输入邮箱" allow-clear />
</a-form-item>
</a-col>
<a-col :sm="16" :xs="24">
<a-form-item field="create_time" label="注册时间">
<a-range-picker v-model="searchForm.create_time" show-time style="width: 100%" />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="status" label="状态">
<sa-select v-model="searchForm.status" dict="data_status" placeholder="请选择状态" alow-clear />
</a-form-item>
</a-col>
</template>
<!-- Table 自定义渲染 -->
<!-- 状态列 -->
<template #status="{ record }">
<sa-switch
v-model="record.status"
:disabled="record.id == 1"
@change="changeStatus($event, record.id)"></sa-switch>
</template>
<!-- 头像列 -->
<template #avatar="{ record }">
<a-avatar>
<img :src="record.avatar ? record.avatar : avatar" style="object-fit: cover" />
</a-avatar>
</template>
<!-- 操作列 -->
<template #operationCell="{ record }">
<div v-if="record.id == 1">
<a-link @click="updateCache(record.id)"><icon-refresh /> 更新缓存</a-link>
</div>
</template>
<!-- 操作后置扩展 -->
<template #operationAfterExtend="{ record }">
<a-dropdown trigger="hover" v-if="record.id != 1" @select="selectOperation($event, record.id)">
<a-link><icon-double-right /> 更多</a-link>
<template #content>
<a-doption value="updateCache" v-auth="['/core/user/clearCache']">更新缓存</a-doption>
<a-doption value="setHomePage" v-auth="['/core/user/setHomePage']">设置首页</a-doption>
<a-doption value="resetPassword" v-auth="['/core/user/initUserPassword']">重置密码</a-doption>
</template>
</a-dropdown>
</template>
</sa-table>
</div>
<!-- 编辑表单 -->
<edit-form ref="editRef" @success="refresh" />
<!-- 设置首页 -->
<a-modal v-model:visible="setHomeVisible" @before-ok="saveHomePage">
<template #title>设置用户后台首页</template>
<a-form-item label="用户首页">
<sa-select v-model="homePage" placeholder="请选择用户首页" dict="dashboard"></sa-select>
</a-form-item>
</a-modal>
</div>
</template>
<script setup>
import { ref, onMounted, reactive, computed } from 'vue'
import api from '@/api/system/user'
import commonApi from '@/api/common'
import { Message, Modal } from '@arco-design/web-vue'
import EditForm from './edit.vue'
import avatar from '@/assets/avatar.jpg'
const depts = ref([{ label: '所有部门', value: 0 }])
const crudRef = ref()
const editRef = ref()
const setHomeVisible = ref(false)
const userid = ref()
const homePage = ref('')
const defaultKey = ref([0])
// 搜索表单
const searchForm = ref({
username: '',
phone: '',
email: '',
status: '',
create_time: [],
dept_id: '',
})
// SaTable 重置搜索
const handleReset = async () => {
defaultKey.value = [0]
searchForm.value.dept_id = ''
}
// 部门切换
const switchDept = (id) => {
searchForm.value.dept_id = id[0] === 0 ? '' : id[0]
crudRef.value.refresh()
defaultKey.value = id
}
// 修改状态
const changeStatus = async (status, id) => {
const response = await api.changeStatus({ id, status })
if (response.code === 200) {
Message.success(response.message)
crudRef.value.refresh()
}
}
// 更新缓存
const updateCache = (id) => {
api.clearCache({ id }).then((res) => res.code === 200 && Message.success(res.message))
}
// 重置密码
const resetPassword = async (id) => {
api.initUserPassword({ id }).then((res) => res.code === 200 && Message.success(res.message))
}
// 设置首页
const saveHomePage = async (done) => {
const resp = await api.setHomePage({ id: userid.value, dashboard: homePage.value })
if (resp.code === 200) {
Message.success(resp.message)
crudRef.value.refresh()
done(true)
}
done(false)
}
// 更多操作项
const selectOperation = (value, id) => {
if (value === 'resetPassword') {
Modal.info({
title: '提示',
content: '确定将该用户密码重置为 sai123456 吗?',
simple: false,
onBeforeOk: (done) => {
resetPassword(id)
done(true)
},
})
return
}
if (value === 'updateCache') {
updateCache(id)
return
}
if (value === 'setHomePage') {
setHomeVisible.value = true
userid.value = id
return
}
}
// SaTable 基础配置
const options = reactive({
api: api.getPageList,
operationColumnWidth: 210,
add: {
show: true,
auth: ['/core/user/save'],
func: async () => {
editRef.value?.open()
},
},
edit: {
show: true,
auth: ['/core/user/update'],
func: async (record) => {
editRef.value?.open('edit', record.id)
},
},
delete: {
show: true,
auth: ['/core/user/destroy'],
func: async (params) => {
const resp = await api.destroy(params)
if (resp.code === 200) {
Message.success(`删除成功!`)
crudRef.value?.refresh()
}
},
},
})
// SaTable 列配置
const columns = reactive([
{ title: '头像', dataIndex: 'avatar', width: 75 },
{ title: '账户', dataIndex: 'username', width: 130 },
{ title: '昵称', dataIndex: 'nickname', width: 120 },
{ title: '手机', dataIndex: 'phone', width: 150 },
{ title: '邮箱', dataIndex: 'email', width: 200 },
{ title: '状态', dataIndex: 'status', width: 100 },
{ title: '工作台', dataIndex: 'dashboard', width: 100 },
{ title: '注册时间', dataIndex: 'create_time', width: 180 },
])
// 页面数据初始化
const initPage = async () => {
const resp = await commonApi.commonGet('/core/dept/accessDept')
resp.data.map((item) => {
depts.value.push(item)
})
}
// SaTable 数据刷新
const refresh = async () => {
crudRef.value?.refresh()
}
// 页面加载完成执行
onMounted(() => {
initPage()
refresh()
})
</script>
<style scoped></style>

View File

@@ -0,0 +1,704 @@
<template>
<a-modal v-model:visible="visible" :on-before-ok="save" fullscreen unmount-on-close>
<template #title>编辑生成信息 - {{ record?.table_comment }}</template>
<a-spin :loading="loading" tip="加载数据中..." class="w-full">
<a-form :model="form" ref="formRef">
<a-tabs v-model:active-key="activeTab">
<a-tab-pane title="配置信息" key="base_config">
<a-divider orientation="left">基础信息</a-divider>
<a-row :gutter="24">
<a-col :span="8">
<a-form-item
label="表名称"
field="table_name"
label-col-flex="auto"
:label-col-style="{ width: '100px' }">
<a-input v-model="form.table_name" disabled />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item
label="表描述"
field="table_comment"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
:rules="[{ required: true, message: '表描述必填' }]">
<a-input v-model="form.table_comment" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item
label="实体类"
field="class_name"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
:rules="[{ required: true, message: '实体类必填' }]">
<a-input v-model="form.class_name" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="8">
<a-form-item
label="业务名称"
field="business_name"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
:rules="[{ required: true, message: '实体别名必填' }]">
<a-input v-model="form.business_name" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="数据源" field="source" label-col-flex="auto" :label-col-style="{ width: '94px' }">
<a-select placeholder="请选择数据源" v-model="form.source" :options="dataSourceList" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="备注信息" field="remark" label-col-flex="auto" :label-col-style="{ width: '94px' }">
<a-textarea v-model="form.remark" />
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="left">生成信息</a-divider>
<a-row :gutter="24">
<a-col :xs="24" :md="8" :xl="8">
<a-form-item
label="应用类型"
field="template"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
:rules="[{ required: true, message: '应用类型必选' }]"
extra="默认app模板,生成文件放app目录下">
<a-select
style="width: 100%"
v-model="form.template"
:options="[
{ label: 'webman应用[app]', value: 'app' },
{ label: 'webman插件[plugin]', value: 'plugin' },
]"
allow-clear
allow-search
placeholder="请选择生成模板" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8" :xl="8">
<a-form-item
label="应用名称"
field="namespace"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
:rules="[{ required: true, message: '应用名称必填' }]"
extra="plugin插件名称, 或者app下应用名称, 禁止使用saiadmin">
<a-input v-model="form.namespace" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8" :xl="8">
<a-form-item
label="包名"
field="package_name"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
extra="指定控制器文件所在控制器目录的二级目录名system">
<a-input allow-clear v-model="form.package_name" placeholder="请输入包名" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :xs="24" :md="8" :xl="8">
<a-form-item
label="生成类型"
field="tpl_category"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
:rules="[{ required: true, message: '生成类型必填' }]"
extra="单表须有主键树表须指定id、parent_id、name等字段">
<a-select
style="width: 100%"
v-model="form.tpl_category"
:options="[
{ label: '单表CRUD', value: 'single' },
{ label: '树表CRUD', value: 'tree' },
]"
allow-clear
allow-search
placeholder="请选择所属模块" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8" :xl="8">
<a-form-item
label="生成路径"
field="generate_path"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
:rules="[{ required: true, message: '生成路径必填' }]"
extra="前端根目录文件夹名称,必须与后端根目录同级">
<a-input v-model="form.generate_path" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8" :xl="8">
<a-form-item
label="模型类型"
field="generate_model"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
extra="根据不同选择生成不同的模型">
<a-radio-group v-model="form.generate_model">
<a-radio :value="1">软删除</a-radio>
<a-radio :value="2">非软删除</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :xs="24" :md="8" :xl="8">
<a-form-item
label="所属菜单"
field="belong_menu_id"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
extra="默认为工具菜单栏目下的子菜单。不选择则为顶级菜单栏目">
<a-cascader
v-model="form.belong_menu_id"
:options="menus"
expand-trigger="hover"
:style="{ width: '100%' }"
placeholder="生成功能所属菜单"
allow-search
allow-clear
check-strictly />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8" :xl="8">
<a-form-item
label="菜单名称"
field="menu_name"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
:rules="[{ required: true, message: '菜单名称必选' }]"
extra="显示在菜单栏目上的菜单名称、以及代码中的业务功能名称">
<a-input allow-clear v-model="form.menu_name" placeholder="请输入菜单名称" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="8">
<a-form-item
label="表单样式"
field="component_type"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
extra="设置新增和修改组件显示方式">
<a-radio-group v-model="form.component_type" type="button">
<a-radio :value="1">模态框</a-radio>
<a-radio :value="2">抽屉</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item
label="表单宽度"
field="form_width"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
extra="表单组件的宽度单位为px">
<a-input-number v-model="form.form_width" :min="200" :max="10000" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item
label="表单全屏"
field="is_full"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
extra="编辑表单是否全屏">
<a-radio-group v-model="form.is_full">
<a-radio :value="1"></a-radio>
<a-radio :value="2"></a-radio>
</a-radio-group>
</a-form-item>
</a-col>
</a-row>
<div v-if="form.tpl_category === 'tree'">
<a-divider orientation="left">树表配置</a-divider>
<a-row :gutter="24">
<a-col :xs="24" :md="8" :xl="8">
<a-form-item
label="树主ID"
field="tree_id"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
extra="指定树表的主要ID一般为主键">
<a-select
style="width: 100%"
v-model="formOptions.tree_id"
allow-clear
allow-search
placeholder="请选择树表的主ID">
<a-option
class="w-full"
v-for="(item, index) in form.columns"
:label="item.column_name + ' - ' + item.column_comment"
:value="item.column_name"
:key="index">
<div class="flex justify-between w-full">
<span>{{ item.column_name }}</span>
<span>{{ item.column_comment }}</span>
</div>
</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8" :xl="8">
<a-form-item
label="树父ID"
field="tree_parent_id"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
extra="指定树表的父ID比如parent_id">
<a-select
style="width: 100%"
v-model="formOptions.tree_parent_id"
allow-clear
allow-search
placeholder="请选择树表的父ID">
<a-option
class="w-full"
v-for="(item, index) in form.columns"
:label="item.column_name + ' - ' + item.column_comment"
:value="item.column_name"
:key="index">
<div class="flex justify-between w-full">
<span>{{ item.column_name }}</span>
<span>{{ item.column_comment }}</span>
</div>
</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8" :xl="8">
<a-form-item
label="树名称"
field="tree_name"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
extra="指定树显示的名称字段比如name">
<a-select
style="width: 100%"
v-model="formOptions.tree_name"
allow-clear
allow-search
placeholder="请选择树表的主ID">
<a-option
class="w-full"
v-for="(item, index) in form.columns"
:label="item.column_name + ' - ' + item.column_comment"
:value="item.column_name"
:key="index">
<div class="flex justify-between w-full">
<span>{{ item.column_name }}</span>
<span>{{ item.column_comment }}</span>
</div>
</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
</div>
</a-tab-pane>
<a-tab-pane title="字段配置" key="field_config">
<a-alert title="提示">
使用数组形式字段的组件请在模型设置
<a-tag class="tag-primary">获取器</a-tag>
<a-tag class="tag-primary">修改器</a-tag>
</a-alert>
<a-table :data="form.columns" :pagination="false" class="mt-3">
<template #columns>
<a-table-column dataIndex="sort" title="排序" :width="90">
<template #cell="{ rowIndex }"><a-input-number v-model="form.columns[rowIndex].sort" /></template>
</a-table-column>
<a-table-column dataIndex="column_name" title="字段名称" :width="150" tooltip></a-table-column>
<a-table-column dataIndex="column_comment" title="字段描述" :width="160">
<template #cell="{ rowIndex }"
><a-input v-model="form.columns[rowIndex].column_comment" allow-clear
/></template>
</a-table-column>
<a-table-column dataIndex="column_type" title="物理类型" :width="100"></a-table-column>
<a-table-column dataIndex="is_required" title="必填" :width="60">
<template #title
>必填
<a-tooltip content="全选 / 全不选" position="bottom"
><a-checkbox @change="handlerAll($event, 'required')"
/></a-tooltip>
</template>
<template #cell="{ rowIndex }"><a-checkbox v-model="form.columns[rowIndex].is_required" /></template>
</a-table-column>
<a-table-column dataIndex="is_insert" title="插入" :width="60">
<template #title
>表单
<a-tooltip content="全选 / 全不选" position="bottom"
><a-checkbox @change="handlerAll($event, 'insert')"
/></a-tooltip>
</template>
<template #cell="{ rowIndex }"><a-checkbox v-model="form.columns[rowIndex].is_insert" /></template>
</a-table-column>
<a-table-column dataIndex="is_list" title="列表" :width="60">
<template #title
>列表
<a-tooltip content="全选 / 全不选" position="bottom"
><a-checkbox @change="handlerAll($event, 'list')"
/></a-tooltip>
</template>
<template #cell="{ rowIndex }"><a-checkbox v-model="form.columns[rowIndex].is_list" /></template>
</a-table-column>
<a-table-column dataIndex="is_query" title="查询" :width="60">
<template #title
>查询
<a-tooltip content="全选 / 全不选" position="bottom"
><a-checkbox @change="handlerAll($event, 'query')"
/></a-tooltip>
</template>
<template #cell="{ rowIndex }"><a-checkbox v-model="form.columns[rowIndex].is_query" /></template>
</a-table-column>
<a-table-column dataIndex="is_sort" title="排序" :width="60">
<template #title
>排序
<a-tooltip content="全选 / 全不选" position="bottom"
><a-checkbox @change="handlerAll($event, 'sort')"
/></a-tooltip>
</template>
<template #cell="{ rowIndex }"><a-checkbox v-model="form.columns[rowIndex].is_sort" /></template>
</a-table-column>
<a-table-column dataIndex="query_type" title="查询方式" :width="150">
<template #cell="{ rowIndex }"
><a-select
v-model="form.columns[rowIndex].query_type"
:options="vars.queryType"
allow-clear></a-select
></template>
</a-table-column>
<a-table-column dataIndex="view_type" title="页面控件" :width="220">
<template #cell="{ record, rowIndex }">
<a-space>
<a-select
v-model="form.columns[rowIndex].view_type"
:style="{ width: '140px' }"
:options="vars.viewComponent"
@change="changeViewType(form.columns[rowIndex])"
allow-clear></a-select>
<a-link
v-if="notNeedSettingComponents.includes(record.view_type)"
@click="settingComponentRef.open(record)"
>设置</a-link
>
</a-space>
</template>
</a-table-column>
<a-table-column dataIndex="dict_type" title="数据字典" :width="160">
<template #cell="{ record, rowIndex }">
<a-select
v-model="form.columns[rowIndex].dict_type"
:options="dicts"
allow-clear
:field-names="{ label: 'name', value: 'code' }"
placeholder="选择数据字典"
:disabled="!['saSelect', 'radio', 'checkbox'].includes(record.view_type)"></a-select>
</template>
</a-table-column>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane title="关联配置" key="relation_config">
<a-alert title="提示">模型关联支持一对一一对多一对一反向多对多</a-alert>
<a-button @click="addRelation" type="primary" class="mt-3"><icon-plus /> 新增关联</a-button>
<div v-for="(item, index) in formOptions.relations" :key="index">
<a-divider orientation="left">
{{ item.name ? item.name : '定义新关联' }}
<a-link @click="delRelation(index)" class="ml-5"><icon-delete /> 删除定义</a-link>
</a-divider>
<a-row :gutter="24">
<a-col :xs="24" :md="12" :xl="12">
<a-form-item
label="关联类型"
field="type"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
extra="指定关联类型">
<a-select v-model="item.type" allow-clear allow-search placeholder="请选择关联类型">
<a-option
v-for="types in vars.realtionsType"
:key="types.value"
:value="types.value"
:label="types.name" />
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12" :xl="12">
<a-form-item
label="关联名称"
field="name"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
extra="设置关联名称,且是代码中调用的名称">
<a-input v-model="item.name" allow-clear placeholder="设置关联名称" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :xs="24" :md="12" :xl="12">
<a-form-item
label="关联模型"
field="model"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
extra="选择要关联的模型">
<a-input v-model="item.model" allow-clear placeholder="设置关联模型" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12" :xl="12">
<a-form-item
:label="
item.type === 'belongsTo' ? '外键' : item.type === 'belongsToMany' ? '外键' : '当前模型主键'
"
field="localKey"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
:extra="
item.type === 'belongsTo'
? '关联模型_id'
: item.type === 'belongsToMany'
? '关联模型_id'
: '当前模型主键'
">
<a-input v-model="item.localKey" allow-clear placeholder="设置键名" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :xs="24" :md="12" :xl="12" v-show="item.type === 'belongsToMany'">
<a-form-item
label="中间模型"
field="model"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
extra="多对多关联的中间模型">
<a-input v-model="item.table" allow-clear placeholder="请输入中间模型" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12" :xl="12">
<a-form-item
:label="item.type === 'belongsTo' ? '关联主键' : '外键'"
field="foreignKey"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
:extra="item.type === 'belongsTo' ? '关联模型主键' : '当前模型_id'">
<a-input style="width: 100%" v-model="item.foreignKey" allow-clear placeholder="设置键名" />
</a-form-item>
</a-col>
</a-row>
</div>
</a-tab-pane>
</a-tabs>
</a-form>
</a-spin>
<setting-component ref="settingComponentRef" @confrim="confrimSetting" />
</a-modal>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { Message } from '@arco-design/web-vue'
// 接口导入
import generate from '@/api/tool/generate'
import database from '@/api/system/database'
import menuApi from '@/api/system/menu'
import { dictType } from '@/api/system/dict'
import SettingComponent from './settingComponent.vue'
// 导入变量
import * as vars from '../js/vars.js'
const record = ref({})
const loading = ref(true)
const visible = ref(false)
const activeTab = ref('base_config')
const settingComponentRef = ref()
const notNeedSettingComponents = ref([
'uploadFile',
'uploadImage',
'editor',
'codeEditor',
'wangEditor',
'cityLinkage',
'date',
'userInfo',
])
const form = ref({
generate_menus: ['save', 'update', 'read', 'delete', 'recycle', 'recovery', 'realDestroy', 'changeStatus'],
columns: [],
})
const formRef = ref()
const emit = defineEmits(['success'])
// form扩展组
const formOptions = ref({
relations: [],
})
// 菜单列表
const menus = ref([])
// 角色列表
const roles = ref([])
// 字典列表
const dicts = ref([])
// 模型列表
const models = ref([])
// 表列表
const tables = ref([])
// 模块信息
const modules = ref([])
// 数据源
const dataSourceList = ref([])
const open = async (id) => {
visible.value = true
const source = await database.getDataSource()
dataSourceList.value = source.data.map((item) => {
return { label: item, value: item }
})
const response = await generate.readTable(id)
record.value = response.data
init()
loading.value = false
}
const confrimSetting = (name, value) => {
form.value.columns.find((item, idx) => {
if (item.column_name == name) {
form.value.columns[idx].options = value
}
})
Message.success('组件设置成功')
}
const changeViewType = (record) => {
if (record.view_type == 'uploadImage' || record.view_type == 'uploadFile') {
record.options = { multiple: false }
} else if (record.view_type == 'codeEditor' || record.view_type == 'editor' || record.view_type == 'wangEditor') {
record.options = { height: 400 }
} else if (record.view_type == 'date') {
record.options = { mode: 'date', showTime: false }
} else if (record.view_type == 'cityLinkage') {
record.options = { type: 'cascader', mode: 'code' }
} else {
record.options = {}
}
}
const save = async (done) => {
if (form.value.namespace === 'saiadmin') {
Message.error('应用名称不能为saiadmin')
return false
}
const validResult = await formRef.value.validate()
if (validResult) {
for (let i in validResult) {
Message.error(validResult[i].message)
}
return false
}
form.value.options = formOptions.value
const response = await generate.update(form.value.id, form.value)
if (response.code == 200) {
Message.success(response.message)
emit('success', true)
done(true)
} else {
return false
}
}
// 全选 / 全不选
const handlerAll = (value, type) =>
form.value.columns.map((item) => {
item['is_' + type] = value
})
// 新增关联定义
const addRelation = () => {
formOptions.value.relations.push({
name: '',
type: 'hasOne',
model: '',
foreignKey: '',
localKey: '',
table: '',
})
}
// 删除关联定义
const delRelation = (idx) => formOptions.value.relations.splice(idx, 1)
const init = () => {
// 设置form数据
for (let name in record.value) {
if (name === 'generate_menus') {
form.value[name] = record.value[name] ? record.value[name].split(',') : []
} else {
form.value[name] = record.value[name]
}
}
if (record.value.options && record.value.options.relations) {
formOptions.value.relations = record.value.options.relations
} else {
formOptions.value.relations = []
}
if (record.value.tpl_category === 'tree') {
formOptions.value.tree_id = record.value.options.tree_id
formOptions.value.tree_name = record.value.options.tree_name
formOptions.value.tree_parent_id = record.value.options.tree_parent_id
}
// 请求表字段
generate.getTableColumns({ table_id: record.value.id }).then((res) => {
form.value.columns = []
res.data.map((item) => {
item.is_required = item.is_required === 2
item.is_insert = item.is_insert === 2
item.is_edit = item.is_edit === 2
item.is_list = item.is_list === 2
item.is_query = item.is_query === 2
item.is_sort = item.is_sort === 2
form.value.columns.push(item)
})
})
// 请求菜单列表
menuApi.getList({ tree: true }).then((res) => {
menus.value = res.data
menus.value.unshift({ id: 0, value: 0, label: '顶级菜单' })
})
// 请求所有字典类型
dictType.getPageList({ saiType: 'all' }).then((res) => (dicts.value = res.data))
}
defineExpose({ open })
</script>
<script>
export default { name: 'setting:code:update' }
</script>
<style scoped>
:deep(.arco-select-option-content) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,128 @@
<template>
<a-modal fullscreen v-model:visible="visible" :on-before-ok="loadTable" :align-center="false" unmount-on-close>
<template #title>装载数据表</template>
<a-alert class="mb-3" closable>
1支持thinkorm配置多数据源
2载入表[sa_shop_category]会自动处理为[SaShopCategory]可以在载入后对类名进行修改[ShopCategory]
</a-alert>
<sa-table
ref="crudRef"
:options="options"
:columns="columns"
:searchForm="searchForm"
@selection-change="handlerSelection">
<!-- 搜索表单 start -->
<template #tableSearch>
<a-col :span="8">
<a-form-item field="name" label="表名称">
<a-input v-model="searchForm.name" placeholder="请输入数据表名称" allow-clear />
</a-form-item>
</a-col>
</template>
<!-- 搜索表单 end -->
<template #tableBeforeButtons>
<a-input-group>
<a-select
placeholder="切换数据源"
v-model="sourceName"
:options="dataSourceList"
style="width: 300px"></a-select>
<a-button type="primary" @click="switchSource">确定切换</a-button>
</a-input-group>
</template>
</sa-table>
</a-modal>
</template>
<script setup>
import { ref, reactive, nextTick } from 'vue'
import api from '@/api/system/database'
import generate from '@/api/tool/generate'
import { Message } from '@arco-design/web-vue'
const crudRef = ref()
const selecteds = ref([])
const visible = ref(false)
const sourceName = ref('mysql')
const newName = ref({})
const newComment = ref({})
const emit = defineEmits(['success'])
const searchForm = ref({
name: '',
source: '',
})
const dataSourceList = ref([])
const switchSource = () => {
searchForm.value.source = sourceName.value
options.api = api.getPageList
crudRef.value.refresh()
}
const loadTable = async (done) => {
if (selecteds.value.length < 1) {
Message.info('至少要选择一条数据')
done(false)
return
}
let names = []
crudRef.value.getTableData().filter((item) => {
if (selecteds.value.includes(item.name)) {
names.push({ name: item.name, comment: item.comment, sourceName: item.name })
}
})
names.map((item) => {
if (newComment.value[item.sourceName]) {
item.comment = newComment.value[item.sourceName]
}
if (newName.value[item.name]) {
item.name = newName.value[item.name]
}
})
const response = await generate.loadTable({ source: sourceName.value, names })
if (response.code === 200) {
Message.success('装载成功')
emit('success')
selecteds.value = []
done(true)
}
}
const handlerSelection = (name) => {
selecteds.value = name
}
const open = async () => {
visible.value = true
const response = await api.getDataSource()
dataSourceList.value = response.data.map((item) => {
return { label: item, value: item }
})
sourceName.value = dataSourceList.value[0] ? dataSourceList.value[0].value : ''
nextTick(() => {
switchSource()
})
}
const options = reactive({
pk: 'name',
api: api.getPageList,
height: 670,
showIndex: true,
showSort: false,
operationColumn: false,
rowSelection: { showCheckedAll: true, key: 'name', onlyCurrent: true },
})
const columns = reactive([
{ title: '表名称', dataIndex: 'name', align: 'left', width: 200 },
{ title: '表注释', dataIndex: 'comment', align: 'left', width: 180 },
{ title: '引擎', dataIndex: 'engine', width: 150 },
{ title: '编码', dataIndex: 'collation', width: 180 },
{ title: '创建时间', dataIndex: 'create_time' },
])
defineExpose({ open })
</script>

View File

@@ -0,0 +1,48 @@
<template>
<a-modal width="1000px" v-model:visible="visible" :footer="false">
<template #title>预览代码</template>
<a-tabs v-model:active-key="activeTab">
<a-tab-pane v-for="item in previewCode" :key="item.name" :title="item.tab_name">
<div class="relative">
<ma-code-editor v-model="item.code" readonly miniMap :language="item.lang" :height="600" />
<a-button class="copy-button" type="primary" @click="copyCode(item.code)"><icon-copy /> 复制</a-button>
</div>
</a-tab-pane>
</a-tabs>
</a-modal>
</template>
<script setup>
import { ref } from 'vue'
import generate from '@/api/tool/generate'
import { copy } from '@/utils/common'
import { Message } from '@arco-design/web-vue'
import MaCodeEditor from '@/components/ma-codeEditor/index.vue'
const activeTab = ref('controller')
const visible = ref(false)
const previewCode = ref([])
const open = async (id) => {
const response = await generate.preview(id)
if (response.code === 200) {
previewCode.value = response.data
visible.value = true
}
}
const copyCode = async (code) => {
await copy(code)
}
defineExpose({ open })
</script>
<style scoped>
.copy-button {
position: absolute;
right: 15px;
top: 0px;
z-index: 999;
}
</style>

View File

@@ -0,0 +1,120 @@
<template>
<a-modal v-model:visible="visible" :on-before-ok="save" width="600px" draggable top="50px" :align-center="false">
<template #title>设置组件 - {{ row?.column_comment }}</template>
<a-form :model="form">
<!-- 编辑器相关 -->
<div v-if="['codeEditor', 'editor', 'wangEditor'].includes(row.view_type)">
<a-form-item label="编辑器高度" field="height" label-col-flex="auto" :label-col-style="{ width: '120px' }">
<a-input-number v-model="form.height" :max="1000" :min="100" />
</a-form-item>
</div>
<!-- 上传资源选择器相关 -->
<div v-if="['uploadImage', 'uploadFile'].includes(row.view_type)">
<a-form-item label="是否可多选" field="multiple" label-col-flex="auto" :label-col-style="{ width: '100px' }">
<a-radio-group v-model="form.multiple">
<a-radio :value="true"></a-radio>
<a-radio :value="false"></a-radio>
</a-radio-group>
</a-form-item>
<a-form-item
v-if="form.multiple"
label="数量限制"
field="limit"
label-col-flex="auto"
:label-col-style="{ width: '100px' }"
:extra="`多选模式下生效,限制上传数量`">
<a-input-number v-model="form.limit" :max="10" :min="1" />
</a-form-item>
</div>
<!-- 省市区联动 -->
<div v-if="row.view_type == 'cityLinkage'">
<a-alert title="提示">
<p>级联选择器返回的数据类型为 String</p>
<p>下拉框联动返回的数据类型为 Array</p>
</a-alert>
<a-form-item class="mt-3" label="组件类型" field="type" label-col-flex="auto" :label-col-style="{ width: '100px' }">
<a-select v-model="form.type" placeholder="默认为下拉框联动" allow-clear>
<a-option value="select">下拉框联动</a-option>
<a-option value="cascader">级联选择器</a-option>
</a-select>
</a-form-item>
<a-form-item class="mt-3" label="返回数据" field="mode" label-col-flex="auto" :label-col-style="{ width: '100px' }">
<a-select v-model="form.mode" placeholder="默认为省市名称" allow-clear>
<a-option value="name">省市名称</a-option>
<a-option value="code">省市编码</a-option>
</a-select>
</a-form-item>
</div>
<!-- 日期时间选择器 -->
<div v-if="['date'].includes(row.view_type)">
<a-form-item
class="mt-3"
label="选择器类型"
field="formType"
label-col-flex="auto"
:label-col-style="{ width: '120px' }"
v-if="row.view_type == 'date'">
<a-select v-model="form.mode" allow-clear>
<a-option value="date">日期选择器</a-option>
<a-option value="week">周选择器</a-option>
<a-option value="month">月选择器</a-option>
<a-option value="quarter">季度选择器</a-option>
<a-option value="year">年选择器</a-option>
</a-select>
</a-form-item>
<a-form-item
class="mt-3"
label="是否显示时间"
field="showTime"
label-col-flex="auto"
:label-col-style="{ width: '120px' }"
v-if="form.mode == 'date'">
<a-radio-group v-model="form.showTime">
<a-radio :value="true"></a-radio>
<a-radio :value="false"></a-radio>
</a-radio-group>
</a-form-item>
</div>
</a-form>
</a-modal>
</template>
<script setup>
import { ref } from 'vue'
const emit = defineEmits(['confrim'])
const visible = ref(false)
const row = ref({})
const form = ref({})
const open = (record) => {
row.value = record
if (record.view_type == 'uploadImage' || record.view_type == 'uploadFile') {
form.value = record.options ? record.options : { multiple: false }
} else if (record.view_type == 'codeEditor' || record.view_type == 'editor' || record.view_type == 'wangEditor') {
form.value = record.options ? record.options : { height: 400 }
} else if (record.view_type == 'date') {
form.value = record.options ? record.options : { mode: 'date', showTime: false }
} else if (record.view_type == 'cityLinkage') {
form.value = record.options ? record.options : { type: 'cascader', mode: 'code' }
} else {
form.value = record.options ? record.options : {}
}
visible.value = true
}
const save = (done) => {
emit('confrim', row.value.column_name, form.value)
done(true)
}
defineExpose({ open })
</script>
<style scoped>
.setdata-button {
right: 15px;
position: absolute;
}
</style>

View File

@@ -0,0 +1,178 @@
<template>
<div class="ma-content-block">
<sa-table
ref="crudRef"
:options="options"
:columns="columns"
:searchForm="searchForm"
@selection-change="selectionChange">
<!-- 搜索表单 start -->
<template #tableSearch>
<a-col :span="8">
<a-form-item field="table_name" label="表名称">
<a-input v-model="searchForm.table_name" placeholder="请输入数据表名称" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item field="source" label="数据源">
<a-input v-model="searchForm.source" placeholder="请输入数据源名称" />
</a-form-item>
</a-col>
</template>
<!-- 搜索表单 end -->
<!-- 表格按钮后置扩展 start -->
<template #tableAfterButtons>
<a-button type="outline" @click="batchGenerate">
<template #icon><icon-code /></template>生成代码
</a-button>
<a-button @click="() => loadTableRef.open()" type="outline" status="success">
<template #icon><icon-export /></template>装载数据表
</a-button>
</template>
<!-- 表格按钮后置扩展 end -->
<template #operationBeforeExtend="{ record }">
<a-link @click="previewRef.open(record.id)"><icon-eye /> 预览</a-link>
<a-popconfirm content="同步会自动同步数据库字段,确定同步吗?" position="bottom" @ok="syncTable(record.id)">
<a-link><icon-sync /> 同步</a-link>
</a-popconfirm>
</template>
<template #operationAfterExtend="{ record }">
<a-dropdown trigger="hover" @select="selectOperation($event, record.id)">
<a-link><icon-double-right /> 生成</a-link>
<template #content>
<a-doption value="generateFile">生成到项目</a-doption>
<a-doption value="generateCode">代码下载</a-doption>
</template>
</a-dropdown>
</template>
<!-- Table 自定义渲染 start -->
<template #tpl_category="{ record }">
<a-tag v-if="record.tpl_category == 'single'" color="green">单表CRUD</a-tag>
<a-tag v-else color="red">树表CRUD</a-tag>
</template>
<template #form_type="{ record }">
<a-tag v-if="record.form_type == 'a-modal'" color="blue">Modal</a-tag>
<a-tag v-else color="orange">Drawer</a-tag>
</template>
<!-- Table 自定义渲染 end -->
</sa-table>
<load-table ref="loadTableRef" @success="refresh" />
<preview ref="previewRef" />
<edit-info ref="editRef" @success="refresh" />
</div>
</template>
<script setup>
import { onMounted, ref, reactive, computed } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import tool from '@/utils/tool'
import api from '@/api/tool/generate'
import LoadTable from './components/loadTable.vue'
import Preview from './components/preview.vue'
import EditInfo from './components/editInfo.vue'
const crudRef = ref()
const editRef = ref()
const previewRef = ref()
const loadTableRef = ref()
const selections = ref([])
const selectionChange = (row) => (selections.value = row)
const selectOperation = async (value, id) => {
if (value === 'generateCode') {
generateCode(id)
return
}
if (value === 'generateFile') {
Modal.info({
title: '提示',
content: '生成到项目将会覆盖原有文件,确定要生成吗?',
simple: false,
onBeforeOk: (done) => {
generateFile(id)
done(true)
},
})
return
}
}
const generateCode = async (ids) => {
Message.info('代码生成下载中,请稍后')
const response = await api.generateCode({
ids: ids.toString().split(','),
})
if (response) {
tool.download(response, 'saiadmin.zip')
Message.success('代码生成成功,开始下载')
} else {
Message.error('文件下载失败')
}
}
const syncTable = async (id) => {
const response = await api.sync(id)
response.code === 200 && Message.success(response.message)
}
const generateFile = async (id) => {
const response = await api.generateFile({ id })
response.code === 200 && Message.success(response.message)
}
const batchGenerate = () => {
if (selections.value.length === 0) {
Message.error('至少要选择一条数据')
return
}
generateCode(selections.value.join(','))
}
const searchForm = ref({
table_name: '',
source: '',
})
const options = reactive({
api: api.getPageList,
rowSelection: { showCheckedAll: true },
operationColumnWidth: 300,
edit: {
show: true,
func: async (record) => {
editRef.value.open(record.id)
},
},
delete: {
show: true,
func: async (params) => {
await api.destroy(params)
Message.success(`删除成功!`)
crudRef.value?.refresh()
},
},
})
const columns = reactive([
{ title: '表名称', dataIndex: 'table_name', width: 180, align: 'left' },
{ title: '表描述', dataIndex: 'table_comment', width: 150, align: 'left' },
{ title: '应用类型', dataIndex: 'template', width: 120 },
{ title: '应用名称', dataIndex: 'namespace', width: 120 },
{ title: '模板类型', dataIndex: 'stub', width: 120 },
{ title: '生成类型', dataIndex: 'tpl_category', width: 120 },
{ title: '创建时间', dataIndex: 'create_time', width: 180 },
])
const refresh = async () => {
crudRef.value?.refresh()
}
onMounted(async () => {
refresh()
})
</script>

View File

@@ -0,0 +1,50 @@
export const realtionsType = [
{ name: '一对一', value: 'hasOne' },
{ name: '一对多', value: 'hasMany' },
{ name: '一对一(反向)', value: 'belongsTo' },
{ name: '多对多', value: 'belongsToMany' }
]
export const queryType = [
{ label: '=', value: 'eq' },
{ label: '!=', value: 'neq' },
{ label: '>', value: 'gt' },
{ label: '>=', value: 'gte' },
{ label: '<', value: 'lt' },
{ label: '<=', value: 'lte' },
{ label: 'LIKE', value: 'like' },
{ label: 'IN', value: 'in' },
{ label: 'NOT IN', value: 'notin' },
{ label: 'BETWEEN', value: 'between' }
]
// 页面控件
export const viewComponent = [
{ label: '输入框', value: 'input' },
{ label: '密码框', value: 'password' },
{ label: '文本域', value: 'textarea' },
{ label: '数字输入框', value: 'inputNumber' },
{ label: '标签输入框', value: 'inputTag' },
// { label: '提及', value: 'mention' },
{ label: '开关', value: 'switch' },
{ label: '滑块', value: 'slider' },
{ label: '数据下拉框', value: 'select' },
{ label: '字典下拉框', value: 'saSelect' },
{ label: '树形下拉框', value: 'treeSelect' },
{ label: '单选框', value: 'radio' },
{ label: '复选框', value: 'checkbox' },
{ label: '日期选择器', value: 'date' },
{ label: '时间选择器', value: 'time' },
{ label: '评分器', value: 'rate' },
{ label: '级联选择器', value: 'cascader' },
// { label: '数据穿梭框', value: 'transfer' },
{ label: '用户选择器', value: 'userSelect' },
// { label: '用户信息', value: 'userinfo' },
{ label: '省市区联动', value: 'cityLinkage' },
// { label: '子表单', value: 'formGroup' },
{ label: '图片上传', value: 'uploadImage' },
{ label: '文件上传', value: 'uploadFile' },
// { label: '资源选择器', value: 'selectResource' },
{ label: '富文本控件', value: 'wangEditor' }
// { label: '代码编辑器', value: 'codeEditor' }
]

View File

@@ -0,0 +1,201 @@
<template>
<component
is="a-modal"
v-model:visible="visible"
:width="tool.getDevice() === 'mobile' ? '100%' : '700px'"
:title="title"
:mask-closable="false"
:ok-loading="loading"
@cancel="close"
@before-ok="submit">
<!-- 表单信息 start -->
<a-form ref="formRef" :model="formData" :rules="rules" :auto-label-width="true">
<a-form-item label="任务名称" field="name">
<a-input v-model="formData.name" placeholder="请输入任务名称" />
</a-form-item>
<a-form-item label="任务类型" field="type">
<a-select v-model="formData.type" :options="types" placeholder="请选择任务类型" />
</a-form-item>
<a-form-item label="定时规则" field="task_style">
<a-space>
<a-select v-model="formData.task_style" :style="{ width: '100px' }">
<a-option :value="1">每天</a-option>
<a-option :value="2">每小时</a-option>
<a-option :value="3">N小时</a-option>
<a-option :value="4">N分钟</a-option>
<a-option :value="5">N秒</a-option>
<a-option :value="6">每周</a-option>
<a-option :value="7">每月</a-option>
<a-option :value="8">每年</a-option>
</a-select>
<template v-if="formData.task_style == 8">
<a-input-number v-model="formData.month" :precision="0" :min="1" :max="12" :style="{ width: '80px' }" />
<span></span>
</template>
<template v-if="formData.task_style > 6">
<a-input-number v-model="formData.day" :precision="0" :min="1" :max="31" :style="{ width: '80px' }" />
<span></span>
</template>
<a-select v-if="formData.task_style == 6" v-model="formData.week" :style="{ width: '80px' }">
<a-option :value="1">周一</a-option>
<a-option :value="2">周二</a-option>
<a-option :value="3">周三</a-option>
<a-option :value="4">周四</a-option>
<a-option :value="5">周五</a-option>
<a-option :value="6">周六</a-option>
<a-option :value="0">周日</a-option>
</a-select>
<template v-if="[1, 3, 6, 7, 8].includes(formData.task_style)">
<a-input-number v-model="formData.hour" :precision="0" :min="0" :max="23" :style="{ width: '80px' }" />
<span></span>
</template>
<template v-if="formData.task_style != 5">
<a-input-number v-model="formData.minute" :precision="0" :min="0" :max="59" :style="{ width: '80px' }" />
<span></span>
</template>
<template v-if="formData.task_style == 5">
<a-input-number v-model="formData.second" :precision="0" :min="0" :max="59" :style="{ width: '80px' }" />
<span></span>
</template>
</a-space>
</a-form-item>
<a-form-item label="调用目标" field="target">
<a-textarea v-model="formData.target" placeholder="请输入调用目标" />
</a-form-item>
<a-form-item label="任务参数" field="parameter">
<a-textarea v-model="formData.parameter" placeholder="请输入任务参数" />
</a-form-item>
<a-form-item label="状态" field="status">
<sa-radio v-model="formData.status" dict="data_status" placeholder="请选择状态" />
</a-form-item>
<a-form-item label="备注" field="remark">
<a-textarea v-model="formData.remark" placeholder="请输入备注" />
</a-form-item>
</a-form>
<!-- 表单信息 end -->
</component>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import tool from '@/utils/tool'
import api from '@/api/tool/crontab'
const emit = defineEmits(['success'])
// 引用定义
const formRef = ref()
const cronRef = ref()
const mode = ref('')
const visible = ref(false)
const loading = ref(false)
let title = computed(() => {
return '定时任务' + (mode.value == 'add' ? '-新增' : '-编辑')
})
const types = [
{ label: 'URL任务GET', value: 1 },
{ label: 'URL任务POST', value: 2 },
{ label: '类任务', value: 3 },
]
// 表单初始值
const initialFormData = {
id: '',
name: '',
type: '',
rule: '',
task_style: 1,
month: 1,
day: 1,
week: 1,
hour: 1,
minute: 1,
second: 1,
target: '',
parameter: '',
status: 1,
remark: '',
}
// 表单信息
const formData = reactive({ ...initialFormData })
// 验证规则
const rules = {
name: [{ required: true, message: '任务名称不能为空' }],
type: [{ required: true, message: '任务类型不能为空' }],
task_style: [{ required: true, message: '定时规则不能为空' }],
target: [{ required: true, message: '调用目标不能为空' }],
}
// 打开弹框
const open = async (type = 'add') => {
mode.value = type
// 重置表单数据
Object.assign(formData, initialFormData)
formRef.value.clearValidate()
visible.value = true
await initPage()
}
// 提取数字
const extractNumber = (str) => {
const match = str.match(/\d+/)
return match ? Number.parseInt(match[0]) : 0
}
// 初始化页面数据
const initPage = async () => {}
// 设置数据
const setFormData = async (data) => {
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
formData[key] = data[key]
}
}
const words = formData['rule'].split(' ')
formData['second'] = extractNumber(words[0])
formData['minute'] = extractNumber(words[1])
formData['hour'] = extractNumber(words[2])
formData['day'] = extractNumber(words[3])
formData['month'] = extractNumber(words[4])
formData['week'] = extractNumber(words[5])
}
// 数据保存
const submit = async (done) => {
const validate = await formRef.value?.validate()
if (!validate) {
loading.value = true
let data = { ...formData }
let result = {}
if (mode.value === 'add') {
// 添加数据
data.id = undefined
result = await api.save(data)
} else {
// 修改数据
result = await api.update(data.id, data)
}
if (result.code === 200) {
Message.success('操作成功')
emit('success')
done(true)
}
// 防止连续点击提交
setTimeout(() => {
loading.value = false
}, 500)
}
done(false)
}
// 关闭弹窗
const close = () => (visible.value = false)
defineExpose({ open, setFormData })
</script>

View File

@@ -0,0 +1,156 @@
<template>
<div class="ma-content-block lg:flex justify-between">
<!-- CRUD 组件 -->
<sa-table ref="crudRef" :options="options" :columns="columns" :searchForm="searchForm">
<!-- 搜索区 tableSearch -->
<template #tableSearch>
<a-col :sm="8" :xs="24">
<a-form-item field="name" label="任务名称">
<a-input v-model="searchForm.name" placeholder="请输入任务名称" />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="type" label="任务类型">
<a-select v-model="searchForm.type" :options="types" allow-clear placeholder="请选择任务类型" />
</a-form-item>
</a-col>
<a-col :sm="8" :xs="24">
<a-form-item field="status" label="状态">
<sa-select v-model="searchForm.status" dict="data_status" allow-clear placeholder="请选择状态" />
</a-form-item>
</a-col>
</template>
<!-- Table 自定义渲染 -->
<!-- 自定义规则 -->
<template #rule="{ record }">
<span>{{ record.rule }}</span>
</template>
<!-- 状态列 -->
<template #status="{ record }">
<sa-switch v-model="record.status" @change="changeStatus($event, record.id)"></sa-switch>
</template>
<!-- 操作前置扩展 -->
<template #operationBeforeExtend="{ record }">
<a-popconfirm content="确定立刻执行一次?" position="bottom" @ok="run(record)">
<a-link v-auth="['/tool/crontab/run']"><icon-caret-right /> 执行一次</a-link>
</a-popconfirm>
<a-link @click="openLogModal(record)"><icon-history /> 日志 </a-link>
</template>
</sa-table>
<!-- 编辑表单 -->
<edit-form ref="editRef" @success="refresh" />
<!-- 日志记录 -->
<log-list ref="logsRef" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import api from '@/api/tool/crontab'
import LogList from './logList.vue'
import EditForm from './edit.vue'
const crudRef = ref()
const editRef = ref()
const logsRef = ref()
// 搜索表单
const searchForm = ref({
name: '',
type: '',
status: '',
})
// 类型字典
const types = [
{ label: 'URL任务GET', value: 1 },
{ label: 'URL任务POST', value: 2 },
{ label: '类任务', value: 3 },
]
// 修改状态
const changeStatus = async (status, id) => {
const response = await api.changeStatus({ id, status })
if (response.code === 200) {
Message.success(response.message)
crudRef.value.refresh()
}
}
// 执行
const run = async (row) => {
const response = await api.run({ id: row.id })
if (response.code === 200) {
Message.success(response.message)
crudRef.value.refresh()
}
}
// 日志窗口
const openLogModal = (row) => {
logsRef.value.open(row.id)
}
// SaTable 基础配置
const options = reactive({
api: api.getPageList,
rowSelection: { showCheckedAll: true },
operationColumnWidth: 280,
add: {
show: true,
auth: ['/tool/crontab/save'],
func: async () => {
editRef.value?.open()
},
},
edit: {
show: true,
auth: ['/tool/crontab/update'],
func: async (record) => {
editRef.value?.open('edit')
editRef.value?.setFormData(record)
},
},
delete: {
show: true,
auth: ['/tool/crontab/destroy'],
func: async (params) => {
const resp = await api.destroy(params)
if (resp.code === 200) {
Message.success(`删除成功!`)
crudRef.value?.refresh()
}
},
},
})
// SaTable 列配置
const columns = reactive([
{ title: '任务名称', dataIndex: 'name', width: 180 },
{ title: '任务类型', dataIndex: 'type', type: 'dict', options: types, width: 140 },
{ title: '定时规则', dataIndex: 'rule', width: 260 },
{ title: '调用目标', dataIndex: 'target', width: 260 },
{ title: '状态', dataIndex: 'status', type: 'dict', dict: 'data_status', width: 120 },
{ title: '创建时间', dataIndex: 'create_time', width: 180 },
])
// 页面数据初始化
const initPage = async () => {}
// SaTable 数据请求
const refresh = async () => {
crudRef.value?.refresh()
}
// 页面加载完成执行
onMounted(async () => {
initPage()
refresh()
})
</script>

View File

@@ -0,0 +1,89 @@
<template>
<a-drawer :footer="false" v-model:visible="visible" :width="tool.getDevice() === 'mobile' ? '100%' : '60%'">
<template #title>执行日志</template>
<!-- CRUD 组件 -->
<sa-table ref="crudRef" :options="options" :columns="columns" :searchForm="searchForm">
<template #status="{ record }">
<a-tag :color="`${record.status == 1 ? 'green' : 'red'}`">
{{ record.status == 1 ? '成功' : '失败' }}
</a-tag>
</template>
<template #exception_info="{ record }">
<a-link @click="lookException(record)"><icon-eye /> 查看</a-link>
</template>
</sa-table>
</a-drawer>
</template>
<script setup>
import { ref, reactive } from 'vue'
import api from '@/api/tool/crontab'
import tool from '@/utils/tool'
import { Modal, Message } from '@arco-design/web-vue'
const crudRef = ref()
const crontabId = ref()
const visible = ref(false)
// 搜索表单
const searchForm = ref({
orderBy: 'create_time',
orderType: 'desc',
crontab_id: '',
})
// 打开窗口
const open = (id) => {
crontabId.value = id
searchForm.value.crontab_id = id
visible.value = true
crudRef.value.refresh()
}
// 查看信息
const lookException = (row) => {
const info = row.exception_info
Modal.info({
simple: false,
width: tool.getDevice() === 'mobile' ? '100%' : '600px',
title: '异常信息',
content: info == '' ? '无异常信息' : info,
})
}
// SaTable 基础配置
const options = reactive({
autoRequest: false,
api: api.getLogPageList,
showSearch: false,
showTools: false,
rowSelection: { showCheckedAll: true },
operationColumnWidth: 140,
view: {
show: true,
func: async (row) => {
lookException(row)
},
},
delete: {
show: true,
auth: ['/tool/crontab/deleteLog'],
func: async (params) => {
const resp = await api.deleteLog(params)
if (resp.code === 200) {
Message.success(`删除成功!`)
crudRef.value?.refresh()
}
},
},
})
// SaTable 列配置
const columns = reactive([
{ title: '执行时间', dataIndex: 'create_time', width: 180 },
{ title: '执行目标', dataIndex: 'target', width: 240 },
{ title: '执行结果', dataIndex: 'status', width: 100 },
])
defineExpose({ open })
</script>

View File

@@ -0,0 +1,57 @@
<template>
<component
is="a-drawer"
v-model:visible="visible"
:width="tool.getDevice() === 'mobile' ? '100%' : '60%'"
title="查看详情"
:footer="false">
<!-- 详情 start -->
<a-spin :loading="loading" class="w-full">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="编号">{{ formData?.id }}</a-descriptions-item>
<a-descriptions-item label="岗位名称">{{ formData?.name }}</a-descriptions-item>
<a-descriptions-item label="岗位标识">{{ formData?.code }}</a-descriptions-item>
<a-descriptions-item label="排序">{{ formData?.sort }}</a-descriptions-item>
<a-descriptions-item label="状态">
<sa-dict :value="formData?.status" dict="data_status" render="span" />
</a-descriptions-item>
<a-descriptions-item label="备注">{{ formData?.remark }}</a-descriptions-item>
</a-descriptions>
</a-spin>
<!-- 详情 end -->
</component>
</template>
<script setup>
import { ref, reactive } from 'vue'
import tool from '@/utils/tool'
import api from '@/api/system/post'
const emit = defineEmits(['success'])
// 引用定义
const rowData = ref()
const formData = ref()
const visible = ref(false)
const loading = ref(false)
// 打开弹框
const open = async (record) => {
rowData.value = record
formData.value = {}
visible.value = true
await initPage()
}
// 初始化页面数据
const initPage = async () => {
loading.value = true
const resp = await api.read(rowData.value?.id)
if (resp.code === 200) {
formData.value = resp.data
}
loading.value = false
}
defineExpose({ open })
</script>

View File

@@ -0,0 +1,1013 @@
<template>
<a-layout-content class="flex flex-col">
<a-alert>
仅支持上传由插件市场下载的zip压缩包进行安装请您务必确认插件包文件来自官方渠道或经由官方认证的插件作者
</a-alert>
<a-alert> 插件安装完成后请在右上角,个人头像下拉框清理缓存 </a-alert>
<a-space class="ma-content-block py-3 px-2">
<a-button type="outline" @click="getList">
<template #icon>
<icon-refresh />
</template>
</a-button>
<a-button type="outline" @click="handleUpload">
<template #icon>
<icon-upload />
</template>
上传插件包
</a-button>
<a-button type="outline" status="danger" @click="handleTerminal">
<template #icon>
<icon-computer />
</template>
</a-button>
<div class="flex">
<div class="version-title">saiadmin版本</div>
<div class="version-value">{{ version?.saiadmin_version?.describe }}</div>
<div class="version-title">说明</div>
<div class="version-value" :class="[version.saiadmin_version?.notes == '正常' ? '' : 'text-red-500']">
{{ version?.saiadmin_version?.notes }}
</div>
<div class="version-title">saipackage安装器</div>
<div class="version-value">{{ version?.saipackage_version?.describe }}</div>
<div class="version-title">说明</div>
<div class="version-value" :class="[version.saipackage_version?.notes == '正常' ? '' : 'text-red-500']">
{{ version?.saipackage_version?.notes }}
</div>
</div>
</a-space>
<div class="ma-content-block p-2">
<a-tabs v-model:active-key="activeTab" type="card">
<!-- 本地安装 Tab -->
<a-tab-pane key="local" title="本地安装">
<a-table
:loading="loading"
:columns="columns"
:data="installList"
class="mt-2"
size="medium"
:pagination="false">
<template #app="{ record }">
<a-link :href="record.website" target="_blank">{{ record.app }}</a-link>
</template>
<template #state="{ record }">
<a-tag v-if="record.state == 0" color="red">已卸载</a-tag>
<a-tag v-if="record.state == 1" color="green">已安装</a-tag>
<a-tag v-if="record.state == 2" color="blue">等待安装</a-tag>
<a-tag v-if="record.state == 4" color="orange">等待安装依赖</a-tag>
</template>
<template #npm="{ record }">
<div>
<a-link
v-if="record.npm_dependent_wait_install && record.npm_dependent_wait_install == 1"
@click="handleExecFront(record)">
<icon-download />点击安装
</a-link>
<a-tag color="blue" v-else-if="record.state == 2">-</a-tag>
<a-tag color="green" v-else>已安装</a-tag>
</div>
</template>
<template #composer="{ record }">
<div>
<a-link
v-if="record.composer_dependent_wait_install && record.composer_dependent_wait_install == 1"
@click="handleExecBackend(record)">
<icon-download />点击安装
</a-link>
<a-tag color="blue" v-else-if="record.state == 2">-</a-tag>
<a-tag color="green" v-else>已安装</a-tag>
</div>
</template>
<template #optional="{ record }">
<a-space size="mini">
<a-popconfirm content="确定要安装当前插件吗?" position="bottom" @ok="handleInstall(record)">
<a-link status="warning"><icon-cloud-download />安装</a-link>
</a-popconfirm>
<a-popconfirm content="确定要卸载当前插件吗?" position="bottom" @ok="handleUninstall(record)">
<a-link status="danger"><icon-delete />卸载</a-link>
</a-popconfirm>
</a-space>
</template>
</a-table>
</a-tab-pane>
<!-- 在线商店 Tab -->
<a-tab-pane key="online" title="在线商店">
<div class="lg:flex justify-between">
<!-- CRUD 组件 -->
<sa-table ref="crudRef" :options="onlineOptions" :columns="onlineColumns" :searchForm="searchForm">
<!-- 搜索区 tableSearch -->
<template #tableSearch>
<a-col :sm="6" :xs="24">
<a-form-item field="keywords" label="关键词">
<a-input v-model="searchForm.keywords" placeholder="请输入关键词" allow-clear />
</a-form-item>
</a-col>
<a-col :sm="5" :xs="24">
<a-form-item field="type" label="类型">
<a-select v-model="searchForm.type" placeholder="请选择类型" allow-clear>
<a-option value="">全部</a-option>
<a-option :value="1">插件</a-option>
<a-option :value="2">系统</a-option>
<a-option :value="3">组件</a-option>
<a-option :value="4">项目</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :sm="5" :xs="24">
<a-form-item field="price" label="价格">
<a-select v-model="searchForm.price" placeholder="请选择价格" allow-clear>
<a-option value="all">全部</a-option>
<a-option value="free">免费</a-option>
<a-option value="paid">付费</a-option>
</a-select>
</a-form-item>
</a-col>
<!-- 商店账号入口 -->
<a-col :sm="8" :xs="24">
<a-form-item label="商店账号">
<div v-if="storeUser" class="store-user-logged">
<a-avatar :size="24">
<img v-if="storeUser.avatar" :src="storeUser.avatar" />
<icon-user v-else />
</a-avatar>
<span class="store-username">{{ storeUser.nickname || storeUser.username }}</span>
<a-button type="outline" @click="showPurchasedApps">已购应用</a-button>
<a-button type="outline" @click="handleLogout">退出</a-button>
</div>
<div v-else class="store-user-guest">
<a-button type="outline" @click="handleLogin">登录</a-button>
<a-button type="outline" @click="handleRegister">注册</a-button>
<span class="store-tip">来管理已购插件</span>
</div>
</a-form-item>
</a-col>
</template>
<!-- 自定义内容区 -->
<template #crudContent="{ data }">
<div class="app-grid">
<div v-for="item in data" :key="item.id" class="app-card" @click="showDetail(item)">
<div class="app-card-header">
<img :src="item.logo" :alt="item.title" class="app-logo" />
<div class="app-info">
<div class="app-title">{{ item.title }}</div>
<div class="app-version">v{{ item.version }}</div>
</div>
<div class="app-price" :class="{ free: item.price === '0.00' }">
{{ item.price === '0.00' ? '免费' : '¥' + item.price }}
</div>
</div>
<div class="app-about">{{ item.about }}</div>
<div class="app-footer">
<div class="app-author">
<img :src="item.avatar || 'https://via.placeholder.com/24'" class="author-avatar" />
<span>{{ item.username }}</span>
</div>
<div class="app-sales"><icon-user /> {{ item.sales_num }} 销量</div>
</div>
</div>
</div>
</template>
</sa-table>
</div>
</a-tab-pane>
</a-tabs>
</div>
<InstallForm ref="installFormRef" @success="getList" />
<TerminalBox ref="terminalRef" @success="getList" />
<!-- 详情抽屉 -->
<a-drawer :visible="detailVisible" :width="600" :footer="false" @cancel="detailVisible = false">
<template #title>
<div class="detail-title">
<div class="flex items-center gap-2">
<img :src="currentApp?.logo" class="detail-logo" />
<div class="detail-name">{{ currentApp?.title }}</div>
<div class="detail-version">v{{ currentApp?.version }} · {{ currentApp?.username }}</div>
</div>
</div>
</template>
<div class="detail-content">
<div class="detail-price" :class="{ free: currentApp?.price === '0.00' }">
{{ currentApp?.price === '0.00' ? '免费' : '¥' + currentApp?.price }}
</div>
<div class="detail-about">{{ currentApp?.about }}</div>
<!-- 截图预览 -->
<div v-if="currentApp?.screenshots?.length" class="detail-screenshots">
<div class="screenshots-title">截图预览</div>
<a-image-preview-group>
<a-space :size="12" wrap>
<a-image
v-for="(img, idx) in currentApp?.screenshots"
:key="idx"
:src="img"
:width="150"
fit="cover"
class="screenshot-thumb" />
</a-space>
</a-image-preview-group>
</div>
<!-- 详情描述 -->
<div class="detail-desc">
<div class="desc-title">详细介绍</div>
<div class="desc-content" v-html="renderMarkdown(currentApp?.content)"></div>
</div>
<!-- 购买按钮 -->
<div class="detail-action">
<a-button type="primary" size="large" long @click="handleBuy">
<template #icon><icon-shopping-cart /></template>
前往购买
</a-button>
</div>
</div>
</a-drawer>
<!-- 登录弹窗 -->
<a-modal v-model:visible="loginVisible" title="登录应用商店" :width="400" :footer="false" :mask-closable="false">
<a-form :model="loginForm" @submit="submitLogin" layout="vertical">
<a-form-item field="username" label="用户名/邮箱" :rules="[{ required: true, message: '请输入用户名或邮箱' }]">
<a-input v-model="loginForm.username" placeholder="请输入用户名或邮箱" allow-clear>
<template #prefix><icon-user /></template>
</a-input>
</a-form-item>
<a-form-item field="password" label="密码" :rules="[{ required: true, message: '请输入密码' }]">
<a-input-password v-model="loginForm.password" placeholder="请输入密码" allow-clear>
<template #prefix><icon-lock /></template>
</a-input-password>
</a-form-item>
<a-form-item field="code" label="验证码" :rules="[{ required: true, message: '请输入验证码' }]">
<a-input v-model="loginForm.code" placeholder="请输入验证码" allow-clear style="width: 60%">
<template #prefix><icon-safe /></template>
</a-input>
<img :src="captchaImage" @click="getCaptcha" class="captcha-img" title="点击刷新" />
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit" long :loading="loginLoading">登录</a-button>
</a-form-item>
<div class="login-footer">还没有账号<a-link @click="handleRegister">立即注册</a-link></div>
</a-form>
</a-modal>
<!-- 已购应用抽屉 -->
<a-drawer v-model:visible="purchasedVisible" title="已购应用" :width="720" :footer="false">
<a-spin :loading="purchasedLoading" style="width: 100%">
<div class="purchased-list">
<div v-for="app in purchasedApps" :key="app.id" class="purchased-card">
<img :src="app.logo" class="purchased-logo" />
<div class="purchased-info">
<div class="purchased-title">{{ app.title }}</div>
<div class="purchased-version">v{{ app.version }} · {{ app.developer }}</div>
<div class="purchased-about">{{ app.about }}</div>
</div>
<div class="purchased-actions">
<a-button size="small" @click="viewDocs(app)"> <icon-book /> 文档 </a-button>
<a-button type="primary" size="small" @click="showVersions(app)"> <icon-download /> 下载 </a-button>
</div>
</div>
<a-empty v-if="!purchasedLoading && purchasedApps.length === 0" description="暂无已购应用" />
</div>
</a-spin>
</a-drawer>
<!-- 版本选择对话框 -->
<a-modal
v-model:visible="versionVisible"
:title="'选择版本 - ' + (currentPurchasedApp?.title || '')"
:width="500"
:footer="false">
<a-spin :loading="versionLoading" style="width: 100%">
<div class="version-list">
<div v-for="ver in versionList" :key="ver.id" class="version-item">
<div class="version-info">
<span class="version-name">v{{ ver.version }}</span>
<span class="version-date">{{ ver.create_time }}</span>
</div>
<div class="version-remark">{{ ver.remark }}</div>
<a-button type="primary" size="small" :loading="downloadingId === ver.id" @click="downloadVersion(ver)">
下载安装
</a-button>
</div>
<a-empty v-if="!versionLoading && versionList.length === 0" description="暂无可用版本" />
</div>
</a-spin>
</a-modal>
</a-layout-content>
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { request } from '@/utils/request'
import saipackage from '@/api/tool/saipackage'
import InstallForm from './install-box.vue'
import TerminalBox from './terminal.vue'
// ========== 基础状态 ==========
const activeTab = ref('local')
const version = ref({})
const loading = ref(false)
const installFormRef = ref()
const terminalRef = ref()
const installList = ref([])
// ========== 本地安装相关方法 ==========
const handleUpload = async () => {
installFormRef.value?.open()
}
const handleInstall = async (record) => {
saipackage.installApp({ appName: record.app }).then((resp) => {
if (resp.code == 200) {
Message.success('安装成功')
getList()
saipackage.reloadBackend()
}
})
}
const handleUninstall = async (record) => {
await saipackage.uninstallApp({ appName: record.app })
getList()
}
const handleExecFront = (record) => {
const extend = 'module-install:' + record.app
terminalRef.value?.open()
setTimeout(() => {
terminalRef.value?.frontInstall(extend)
}, 500)
}
const handleExecBackend = (record) => {
const extend = 'module-install:' + record.app
terminalRef.value?.open()
setTimeout(() => {
terminalRef.value?.backendInstall(extend)
}, 500)
}
const handleTerminal = async () => {
terminalRef.value?.open()
}
const columns = [
{ title: '插件标识', slotName: 'app', width: 120 },
{ title: '插件名称', dataIndex: 'title', width: 150 },
{ title: '插件描述', dataIndex: 'about', ellipsis: true, tooltip: true },
{ title: '作者', dataIndex: 'author', width: 120 },
{ title: '版本', dataIndex: 'version', width: 100 },
{ title: '插件状态', slotName: 'state', width: 100 },
{ title: '前端依赖', slotName: 'npm', width: 120 },
{ title: '后端依赖', slotName: 'composer', width: 120 },
{ title: '操作', slotName: 'optional', width: 150 },
]
const getList = async () => {
loading.value = true
const resp = await saipackage.getAppList()
installList.value = resp.data.data
version.value = resp.data.version
loading.value = false
}
// ========== 在线商店相关 ==========
const crudRef = ref()
const detailVisible = ref(false)
const currentApp = ref(null)
// 商店用户状态null 表示未登录)
const storeUser = ref(null)
const storeToken = ref(localStorage.getItem('storeToken') || '')
// 登录弹窗相关
const loginVisible = ref(false)
const loginLoading = ref(false)
const captchaImage = ref('')
const captchaUuid = ref('')
const loginForm = reactive({
username: '',
password: '',
code: '',
})
// 搜索表单
const searchForm = ref({
keywords: '',
type: '',
price: 'all',
limit: 12,
})
// 打开登录弹窗
const handleLogin = () => {
loginVisible.value = true
getCaptcha()
}
// 商店注册
const handleRegister = () => {
window.open('https://saas.saithink.top/register', '_blank')
}
// 退出登录
const handleLogout = () => {
storeUser.value = null
storeToken.value = ''
localStorage.removeItem('storeToken')
}
// 获取验证码
const getCaptcha = async () => {
const response = await request({ url: '/tool/install/online/storeCaptcha', method: 'get' })
if (response.code === 200) {
captchaImage.value = response.data.image
captchaUuid.value = response.data.uuid
}
}
// 提交登录
const submitLogin = async ({ values, errors }) => {
if (errors) return
loginLoading.value = true
try {
const response = await request({
url: '/tool/install/online/storeLogin',
method: 'post',
data: {
username: loginForm.username,
password: loginForm.password,
code: loginForm.code,
uuid: captchaUuid.value,
},
})
if (response.code === 200) {
storeToken.value = response.data.access_token
localStorage.setItem('storeToken', response.data.access_token)
loginVisible.value = false
// 重置表单
loginForm.username = ''
loginForm.password = ''
loginForm.code = ''
// 获取用户信息
await fetchStoreUser()
Message.success('登录成功')
} else {
getCaptcha()
Message.error(response.msg || '登录失败')
}
} finally {
loginLoading.value = false
}
}
// 获取商店用户信息
const fetchStoreUser = async () => {
if (!storeToken.value) return
const response = await request({
url: '/tool/install/online/storeUserInfo',
method: 'get',
params: { token: storeToken.value },
})
if (response.code === 200) {
storeUser.value = response.data
} else {
// token 无效,清除
handleLogout()
}
}
// ========== 已购应用相关 ==========
const purchasedVisible = ref(false)
const purchasedLoading = ref(false)
const purchasedApps = ref([])
const versionVisible = ref(false)
const versionLoading = ref(false)
const versionList = ref([])
const currentPurchasedApp = ref(null)
const downloadingId = ref(null)
// 显示已购应用列表
const showPurchasedApps = async () => {
purchasedVisible.value = true
purchasedLoading.value = true
const response = await request({
url: '/tool/install/online/storePurchasedApps',
method: 'get',
params: { token: storeToken.value },
})
if (response.code === 200) {
purchasedApps.value = response.data
} else {
Message.error(response.msg || '获取已购应用失败')
}
purchasedLoading.value = false
}
// 查看文档
const viewDocs = (app) => {
window.open(`https://saas.saithink.top/store/docs-${app.app_id}`, '_blank')
}
// 显示版本列表
const showVersions = async (app) => {
currentPurchasedApp.value = app
versionVisible.value = true
versionLoading.value = true
const response = await request({
url: '/tool/install/online/storeAppVersions',
method: 'get',
params: {
token: storeToken.value,
app_id: app.app_id,
},
})
if (response.code === 200) {
versionList.value = response.data
} else {
Message.error(response.msg || '获取版本列表失败')
}
versionLoading.value = false
}
// 下载版本
const downloadVersion = async (ver) => {
downloadingId.value = ver.id
const response = await request({
url: '/tool/install/online/storeDownloadApp',
method: 'post',
data: {
token: storeToken.value,
id: ver.id,
},
})
if (response.code === 200) {
Message.success(`下载成功,即将刷新插件列表...`)
versionVisible.value = false
purchasedVisible.value = false
// 切换到本地安装tab并刷新列表
activeTab.value = 'local'
getList()
} else {
Message.error(response.msg || '下载失败')
}
downloadingId.value = null
}
// 通过后端代理请求应用商店列表
const fetchAppList = async (params) => {
const response = await request({
url: '/tool/install/online/appList',
method: 'get',
params: {
page: params.page || 1,
limit: params.limit || 12,
price: params.price || 'all',
type: params.type || '',
keywords: params.keywords || '',
},
})
if (response.code === 200) {
return {
code: 200,
data: {
data: response.data.data,
total: response.data.total,
},
}
}
return response
}
// SaTable 基础配置
const onlineOptions = reactive({
api: fetchAppList,
showTools: false,
operationColumn: false,
singleLine: true,
pageSizeOption: [12, 24, 48],
})
// SaTable 列配置
const onlineColumns = reactive([])
// 显示详情
const showDetail = (item) => {
currentApp.value = item
detailVisible.value = true
}
// 简单的 Markdown 渲染
const renderMarkdown = (content) => {
if (!content) return ''
return content
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
.replace(/\n/g, '<br/>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
}
// 跳转到商店购买
const handleBuy = () => {
window.open('https://saas.saithink.top/store', '_blank')
}
// 监听 tab 切换,切换到在线商店时刷新数据
watch(activeTab, (val) => {
if (val === 'online') {
crudRef.value?.refresh()
fetchStoreUser()
}
})
onMounted(async () => {
getList()
})
</script>
<style lang="less" scoped>
.version-title {
padding: 5px 10px;
background: var(--color-fill-1);
border: 1px solid #e5e7eb;
}
.version-value {
padding: 5px 10px;
border: 1px solid #e5e7eb;
}
// 商店用户入口样式
.store-user-logged {
display: flex;
align-items: center;
gap: 8px;
.store-username {
font-weight: 500;
color: var(--color-text-1);
}
}
.store-user-guest {
display: flex;
align-items: center;
gap: 4px;
.store-tip {
margin-left: 8px;
font-size: 12px;
color: var(--color-text-3);
}
}
// 验证码图片
.captcha-img {
width: 100px;
height: 32px;
margin-left: 10px;
cursor: pointer;
border-radius: 4px;
vertical-align: middle;
}
// 登录底部
.login-footer {
text-align: center;
color: var(--color-text-3);
font-size: 13px;
}
.app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
max-height: calc(100vh - 280px);
overflow-y: auto;
margin-bottom: 16px;
}
.app-card {
background: var(--color-bg-2);
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
border: 1px solid var(--color-border);
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
}
.app-card-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.app-logo {
width: 48px;
height: 48px;
border-radius: 8px;
object-fit: cover;
}
.app-info {
flex: 1;
}
.app-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text-1);
}
.app-version {
font-size: 12px;
color: var(--color-text-3);
}
.app-price {
font-size: 16px;
font-weight: 600;
color: #f5222d;
&.free {
color: #52c41a;
}
}
.app-about {
font-size: 13px;
color: var(--color-text-2);
line-height: 1.5;
margin-bottom: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.app-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--color-text-3);
}
.app-author {
display: flex;
align-items: center;
gap: 6px;
}
.author-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
}
.app-sales {
display: flex;
align-items: center;
gap: 4px;
}
// 详情抽屉样式
.detail-title {
display: flex;
align-items: center;
gap: 12px;
}
.detail-logo {
width: 36px;
height: 36px;
border-radius: 8px;
}
.detail-name {
font-size: 18px;
font-weight: 600;
}
.detail-version {
font-size: 12px;
color: var(--color-text-3);
}
.detail-content {
padding: 16px 0;
}
.detail-price {
font-size: 24px;
font-weight: 600;
color: #f5222d;
margin-bottom: 16px;
&.free {
color: #52c41a;
}
}
.detail-about {
font-size: 14px;
color: var(--color-text-2);
line-height: 1.6;
margin-bottom: 24px;
}
.detail-screenshots {
margin-bottom: 24px;
}
.screenshot-thumb {
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s;
&:hover {
transform: scale(1.02);
}
}
.screenshots-title,
.desc-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
color: var(--color-text-1);
}
.screenshot-img {
width: 100%;
border-radius: 8px;
}
.desc-content {
font-size: 14px;
color: var(--color-text-2);
line-height: 1.8;
:deep(h1),
:deep(h2),
:deep(h3) {
margin: 16px 0 8px;
color: var(--color-text-1);
}
:deep(code) {
background: var(--color-fill-2);
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
}
:deep(ul) {
padding-left: 20px;
margin: 8px 0;
}
:deep(a) {
color: rgb(var(--primary-6));
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.detail-action {
position: sticky;
bottom: 0;
padding: 16px 0;
background: var(--color-bg-2);
border-top: 1px solid var(--color-border);
margin-top: 24px;
}
// 已购应用列表样式
.purchased-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.purchased-card {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: var(--color-bg-2);
border-radius: 8px;
border: 1px solid var(--color-border);
}
.purchased-logo {
width: 56px;
height: 56px;
border-radius: 8px;
object-fit: cover;
}
.purchased-info {
flex: 1;
min-width: 0;
}
.purchased-title {
font-size: 15px;
font-weight: 600;
color: var(--color-text-1);
margin-bottom: 4px;
}
.purchased-version {
font-size: 12px;
color: var(--color-text-3);
margin-bottom: 6px;
}
.purchased-about {
font-size: 13px;
color: var(--color-text-2);
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
display: -webkit-box;
overflow: hidden;
}
.purchased-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
// 版本列表样式
.version-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.version-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--color-fill-2);
border-radius: 6px;
}
.version-info {
display: flex;
align-items: center;
gap: 12px;
}
.version-name {
font-weight: 600;
color: var(--color-text-1);
}
.version-date {
font-size: 12px;
color: var(--color-text-3);
}
.version-remark {
flex: 1;
font-size: 13px;
color: var(--color-text-2);
}
</style>

View File

@@ -0,0 +1,105 @@
<template>
<component
is="a-modal"
v-model:visible="visible"
:width="800"
title="上传插件包-安装插件"
:mask-closable="false"
:footer="false">
<div class="flex flex-col items-center mb-24">
<div class="w-[400px]">
<div class="text-lg text-red-500 font-bold">
请您务必确认模块包文件来自官方渠道或经由官方认证的模块作者否则系统可能被破坏因为
</div>
<div class="text-red-500">1. 模块可以修改和新增系统文件</div>
<div class="text-red-500">2. 模块可以执行sql命令和代码</div>
<div class="text-red-500">3. 模块可以安装新的前后端依赖</div>
</div>
<div class="mt-10 w-[600px]" v-if="appInfo && appInfo.app">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="应用标识"> {{ appInfo?.app }}</a-descriptions-item>
<a-descriptions-item label="应用名称"> {{ appInfo?.title }}</a-descriptions-item>
<a-descriptions-item label="应用描述"> {{ appInfo?.about }}</a-descriptions-item>
<a-descriptions-item label="作者"> {{ appInfo?.author }}</a-descriptions-item>
<a-descriptions-item label="版本"> {{ appInfo?.version }}</a-descriptions-item>
</a-descriptions>
</div>
<div class="mt-10 w-[600px]" v-else>
<a-upload :custom-request="uploadFileHandler" :show-file-list="false" accept=".zip,.rar" :draggable="true">
<template #upload-button>
<slot name="customer">
<div
style="background-color: var(--color-fill-2); border: 1px dashed var(--color-fill-4)"
class="rounded text-center p-7 w-full">
<div>
<icon-upload class="text-3xl text-gray-400" />
<div>将插件包文件拖到此处<span style="color: #3370ff; margin-left: 10px">点击上传</span></div>
</div>
</div>
</slot>
</template>
</a-upload>
</div>
</div>
</component>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import file2md5 from 'file2md5'
import { Message, Modal } from '@arco-design/web-vue'
import saipackage from '@/api/tool/saipackage'
const emit = defineEmits(['success'])
// 引用定义
const visible = ref(false)
const loading = ref(false)
const uploadSize = 8 * 1024 * 1024
const initialApp = {
app: '',
title: '',
about: '',
author: '',
version: '',
state: 0,
update: 0,
}
const appInfo = reactive({ ...initialApp })
const uploadFileHandler = async (options) => {
if (!options.fileItem) return
let isCheck = true
const file = options.fileItem.file
if (file.size > uploadSize) {
Message.warning(file.name + '超出文件大小限制')
isCheck = false
}
if (isCheck) {
const hash = await file2md5(file)
const dataForm = new FormData()
dataForm.append('file', file)
dataForm.append('hash', hash)
const res = await saipackage.uploadApp(dataForm)
if (res.code == 200) {
Object.assign(appInfo, res.data)
Message.success('上传成功')
emit('success')
}
}
}
// 打开弹框
const open = async () => {
visible.value = true
Object.assign(appInfo, initialApp)
await initPage()
}
// 初始化页面数据
const initPage = async () => {}
defineExpose({ open })
</script>

View File

@@ -0,0 +1,302 @@
<template>
<div>
<a-modal v-model:visible="visible" :width="800" :footer="false">
<template #title> 终端执行面板 </template>
<div>
<a-empty description="暂无任务" v-if="terminal.taskList.length == 0" />
<div v-else>
<a-timeline labelPosition="relative">
<a-timeline-item v-for="(item, idx) in terminal.taskList" :label="item.createTime">
<a-collapse :default-active-key="['1']">
<a-collapse-item key="1">
<template #header>
<div class="flex items-center">
<span class="font-bold text-lg mr-4">{{ item.command }}</span>
<a-tag :color="getTagColor(item.status)">{{ getTagText(item.status) }}</a-tag>
</div>
</template>
<template #extra>
<a-button type="text" status="warning" shape="round" @click="terminal.retryTask(idx)">
<template #icon>
<icon-refresh />
</template>
</a-button>
<a-button type="text" status="danger" shape="round" @click="terminal.delTask(idx)">
<template #icon>
<icon-delete />
</template>
</a-button>
</template>
<div
v-if="item.status == 2 || item.status == 3 || (item.status > 3 && item.showMessage)"
class="exec-message">
<pre v-for="(msg, index) in item.message" :key="index" v-html="ansiToHtml(msg)"></pre>
</div>
</a-collapse-item>
</a-collapse>
</a-timeline-item>
</a-timeline>
</div>
<a-divider />
<div class="flex justify-center gap-2">
<a-button type="outline" status="success" @click="testTerminal">
<template #icon><icon-play-arrow /></template>测试命令
</a-button>
<a-button type="outline" @click="handleFronted">
<template #icon><icon-sync /></template>前端依赖更新
</a-button>
<a-button type="outline" @click="handleBackend">
<template #icon><icon-sync /></template>后端依赖更新
</a-button>
<a-button type="outline" status="warning" @click="webBuild">
<template #icon><icon-share-external /></template>一键发布
</a-button>
<a-button type="outline" @click="openConfig">
<template #icon><icon-settings /></template>终端设置
</a-button>
<a-button type="outline" status="danger" @click="terminal.cleanTaskList()">
<template #icon><icon-delete /></template>清理任务
</a-button>
</div>
</div>
</a-modal>
<a-modal v-model:visible="configVisible" :footer="false">
<template #title> 终端设置 </template>
<div class="pb-4">
<a-space>
<div class="w-24">NPM源</div>
<a-select :style="{ width: '320px' }" v-model="terminal.npmRegistry" @change="npmRegistryChange">
<a-option value="npm">npm官源</a-option>
<a-option value="taobao">taobao</a-option>
<a-option value="tencent">tencent</a-option>
</a-select>
</a-space>
<a-space class="mt-4">
<label class="w-24">NPM包管理器</label>
<a-select :style="{ width: '320px' }" v-model="terminal.packageManager">
<a-option value="npm">npm</a-option>
<a-option value="yarn">yarn</a-option>
<a-option value="pnpm">pnpm</a-option>
</a-select>
</a-space>
<a-space class="mt-4">
<label class="w-24">Composer源</label>
<a-select :style="{ width: '320px' }" v-model="terminal.composerRegistry" @change="composerRegistryChange">
<a-option value="composer">composer官源</a-option>
<a-option value="tencent">tencent</a-option>
<a-option value="huawei">huawei</a-option>
<a-option value="kkame">kkame</a-option>
</a-select>
</a-space>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useTerminalStore } from '@/store'
import { Modal, Message } from '@arco-design/web-vue'
const emit = defineEmits(['success'])
const terminal = useTerminalStore()
const visible = ref(false)
const configVisible = ref(false)
const testTerminal = () => {
terminal.addNodeTask('test', true, () => {})
}
const webBuild = () => {
Modal.confirm({
title: '前端打包发布',
content: '确认重新打包前端并发布项目吗?',
onOk: () => {
terminal.addNodeTask('web-build', '', () => {
Message.success('前端打包发布成功')
})
},
})
}
const handleFronted = () => {
Modal.confirm({
title: '前端依赖更新',
content: '确认更新前端Node依赖吗',
onOk: () => {
terminal.addNodeTask('web-install', '', () => {
Message.success('前端依赖更新成功')
})
},
})
}
const handleBackend = () => {
Modal.confirm({
title: 'composer包更新',
content: '确认更新后端composer包吗',
onOk: () => {
terminal.addTask('composer.update', '', () => {
Message.success('composer包更新成功')
})
},
})
}
const frontInstall = (extend = '') => {
terminal.addNodeTask('web-install', extend, () => {
Message.success('前端依赖更新成功')
emit('success')
})
}
const backendInstall = (extend = '') => {
terminal.addTask('composer.update', extend, () => {
Message.success('composer包更新成功')
setTimeout(() => {
emit('success')
}, 500)
})
}
const npmRegistryChange = (val) => {
const command = 'set-npm-registry' + '.' + val
configVisible.value = false
terminal.addTask(command, '', () => {
Message.success('NPM源设置成功')
})
}
const composerRegistryChange = (val) => {
const command = 'set-composer-registry' + '.' + val
configVisible.value = false
terminal.addTask(command, '', () => {
Message.success('Composer源设置成功')
})
}
const getTagColor = (status) => {
switch (status) {
case 1:
return '#a2afb9'
case 2:
return '#2196f3'
case 3:
return '#ffc107'
case 4:
return '#00b42a'
case 5:
return '#ff0000'
case 6:
return '#ff4d4f'
}
}
const getTagText = (status) => {
switch (status) {
case 1:
return '等待执行'
case 2:
return '连接中'
case 3:
return '执行中'
case 4:
return '执行成功'
case 5:
return '执行失败'
case 6:
return '未知'
}
}
const ansiToHtml = (text) => {
return text.replace(/\x1b\[([0-9;]+)m/g, function (match, codes) {
const styles = []
codes.split(';').forEach((code) => {
code = parseInt(code, 10)
switch (code) {
case 0:
styles.push('color:inherit;font-weight:normal;text-decoration:none')
break
case 1:
styles.push('font-weight:bold')
break
case 3:
styles.push('font-style:italic')
break
case 4:
styles.push('text-decoration:underline')
break
case 30:
styles.push('color:black')
break
case 31:
styles.push('color:red')
break
case 32:
styles.push('color:green')
break
case 33:
styles.push('color:yellow')
break
case 34:
styles.push('color:blue')
break
case 35:
styles.push('color:magenta')
break
case 36:
styles.push('color:cyan')
break
case 37:
styles.push('color:white')
break
// 背景色等更多代码可以继续添加
}
})
return styles.length ? `<span style="${styles.join(';')}">` : '</span>'
})
}
// 打开配置弹框
const openConfig = async () => {
configVisible.value = true
}
// 打开弹框
const open = async () => {
visible.value = true
}
// 关闭弹框
const close = () => {
visible.value = false
}
defineExpose({ open, close, frontInstall, backendInstall })
</script>
<style lang="less" scoped>
.exec-message {
font-size: 12px;
line-height: 1.5em;
min-height: 30px;
max-height: 200px;
overflow: auto;
&::-webkit-scrollbar {
width: 5px;
height: 5px;
}
&::-webkit-scrollbar-thumb {
background: #c8c9cc;
border-radius: 4px;
box-shadow: none;
-webkit-box-shadow: none;
}
}
:deep(.arco-collapse-item-content) {
background-color: #000;
color: #c0c0c0;
}
</style>