62 Commits

Author SHA1 Message Date
meilin.huang
2118acf244 release: v1.9.0 2024-10-23 17:30:05 +08:00
meilin.huang
44a1bd626e feat: 数据库迁移至文件支持文件保存天数等 2024-10-22 20:39:44 +08:00
meilin.huang
ea3c70a8a8 feat: 新增统一文件模块,统一文件操作 2024-10-21 22:27:42 +08:00
zongyangleo
6343173cf8 !124 一些更新和bug
* fix: 代码合并
* feat:支持数据库版本兼容,目前兼容了oracle11g部分特性
* fix: 修改数据同步bug,数据sql里指定修改字段别,导致未正确记录修改字段值
* feat: 数据库迁移支持定时迁移和迁移到sql文件
2024-10-20 03:52:23 +00:00
meilin.huang
6837a9c867 fix: sql切割转义等问题处理 2024-10-18 17:15:58 +08:00
meilin.huang
a726927a28 feat: sql脚本执行调整 2024-10-18 12:32:53 +08:00
meilin.huang
e135e4ce64 feat: sql解析器替换、工单统一由‘我的流程’发起、流程定义支持自定义条件触发审批、资源隐藏编号、model支持物理删除等 2024-10-16 17:24:50 +08:00
zongyangleo
43edef412c !123 一些bug修复
* fix: 数据同步、数据迁移体验优化
* fix: des加密传输sql
* fix: 修复达梦字段注释显示问题
* fix: mysql timestamp 字段类型导出ddl错误修复
2024-08-22 00:43:39 +00:00
meilin.huang
2deb3109c2 feat: dbms表数据新增表单视图 2024-07-19 17:06:11 +08:00
meilin.huang
a80221a950 fix: 数据库实例删除等问题修复 2024-07-05 13:14:31 +08:00
meilin.huang
10630847df fix: 工单流程信息展示问题修复 2024-06-24 17:17:57 +08:00
meilin.huang
f43851698e fix: 资源关联多标签删除、数据库实例删除等问题修复与数据库等名称过滤优化 2024-06-07 12:31:40 +08:00
Coder慌
73884bb693 !122 fix: mysql导出修复
Merge pull request !122 from zongyangleo/dev_1.8.6_fix
2024-06-05 04:17:03 +00:00
zongyangleo
1b5bb1de8b fix: mysql导出修复 2024-06-01 13:35:31 +08:00
meilin.huang
4814793546 fix: 修复数据库表数据横向滚动后切换tab导致表头错位&数据取消居中显示 2024-05-31 12:12:40 +08:00
meilin.huang
d85bbff270 release v1.8.6 2024-05-23 17:18:22 +08:00
meilin.huang
bb1522f4dc refactor: 数据库管理迁移至数据库实例-库管理、机器管理-文件支持用户和组信息查看 2024-05-21 12:34:26 +08:00
zongyangleo
a7632fbf58 !121 fix: rdp ssh
* fix: rdp ssh
2024-05-21 04:06:13 +00:00
meilin.huang
c4cb4234fd fix: 问题修复 2024-05-16 17:26:32 +08:00
meilin.huang
89e12678eb refactor: 引入dayjs、新增refreshToken无感刷新、团队新增有效期、数据库等问题修复 2024-05-13 19:55:43 +08:00
meilin.huang
137ebb8e9e fix: 数据库表新增数据表单全必填问题修复等 2024-05-11 12:09:55 +08:00
meilin.huang
05625bd8c1 feat: 1.8.3 2024-05-10 19:59:49 +08:00
meilin.huang
4afeac5fdd refactor: 代码优化与数据库表列显示配置优化 2024-05-09 21:29:34 +08:00
meilin.huang
1d0e91f1af refactor: 机器计划任务与流程定义关联至标签 2024-05-08 21:04:25 +08:00
Coder慌
cf5111a325 !118 修复空数组分隔异常
Merge pull request !118 from 蒋小小/N/A
2024-05-07 11:59:12 +00:00
meilin.huang
78957a8ebd refactor: base.repo与app重构优化 2024-05-05 14:53:30 +08:00
meilin.huang
4ed892a656 feat: 文档更新与sqlite文件更新 2024-04-29 17:09:41 +08:00
meilin.huang
3486b07003 fix: 修复机器列表查询与放开vnc 2024-04-29 12:50:49 +08:00
meilin.huang
a5cd7caf19 refactor: base.repo与base.app精简优化 2024-04-29 12:29:56 +08:00
meilin.huang
f2c7ef78c0 refactor: 精简base.repo与base.app等 2024-04-28 23:45:57 +08:00
meilin.huang
653953ee76 feat: 机器新增命令过滤配置、首页功能完善(操作记录与快捷操作) 2024-04-27 01:35:21 +08:00
meilin.huang
a831614d5a fix: sql脚本问题修复等 2024-04-23 11:35:45 +08:00
meilin.huang
ebe73e2f19 feat: 标签支持拖拽移动与机器支持执行命令查看 2024-04-21 19:35:58 +08:00
蒋小小
29fd5a25d2 修复空数组分隔异常
Signed-off-by: 蒋小小 <bwcx_jzy@163.com>
2024-04-20 17:33:19 +00:00
zongyangleo
44805ce580 !116 fix: 新版本问题修复
* fix: 新版本问题修复
2024-04-19 11:27:29 +00:00
meilin.huang
2a6d620830 fix: 问题修复 2024-04-18 20:50:14 +08:00
meilin.huang
01d3e1ad28 refactor: 数据库实例与凭证关联至标签&其他问题修复重构等 2024-04-17 21:28:28 +08:00
meilin.huang
f4162c38db fix: 问题修复与redis密码迁移至凭证 2024-04-13 17:01:12 +08:00
zongyangleo
1a4626c24d !115 fix: 新版本问题修复
* fix: 新版本问题修复
2024-04-12 14:39:08 +00:00
meilin.huang
d6eb9683d1 fix: 新版本问题修复 2024-04-12 20:30:28 +08:00
meilin.huang
e2b524dadb feat: release1.8.0 2024-04-12 17:07:28 +08:00
zongyangleo
8998a21626 !114 feat:rdp优化,mssql迁移优化,term支持trzsz
* fix: 合并代码
* refactor: rdp优化,mssql迁移优化,term支持trzsz
2024-04-12 07:53:42 +00:00
meilin.huang
abc015aec0 refactor: 数据库授权凭证迁移 2024-04-12 13:24:20 +08:00
meilin.huang
4ef8d27b1e refactor: 授权凭证优化 2024-04-10 23:17:20 +08:00
meilin.huang
40b6e603fc reafctor: 团队管理与授权凭证优化 2024-04-10 13:04:31 +08:00
meilin.huang
21498584b1 refactor: 初步提交全局授权凭证-资源多账号改造 2024-04-09 12:55:51 +08:00
meilin.huang
408bac09a1 refactor: 标签资源重构 2024-04-06 18:19:17 +08:00
zongyangleo
582d879a77 !112 feat: 机器管理支持ssh+rdp连接win服务器
* feat: rdp 文件管理
* feat: 机器管理支持ssh+rdp连接win服务器
2024-04-06 04:03:38 +00:00
meilin.huang
38ff5152e0 refactor: dbms优化 2024-03-29 21:40:26 +08:00
meilin.huang
d1d372e1bf feat: 数据迁移新增实时日志&数据库游标遍历查询问题修复 2024-03-28 22:20:39 +08:00
Coder慌
5e4793433b !111 refactor:获取表索引,默认过滤主键索引
Merge pull request !111 from zongyangleo/dev_0327_fix
2024-03-27 13:06:45 +00:00
zongyangleo
54ad19f97e refactor:获取表索引,默认过滤主键索引 2024-03-27 08:26:12 +08:00
meilin.huang
fc166650b3 refactor: dbm重构等 2024-03-26 21:46:03 +08:00
zongyangleo
2acc295259 !110 feat: 支持各源数据库导出sql,数据库迁移部分bug修复
* feat: 各源数据库导出
* fix: 数据库迁移 bug修复
2024-03-26 09:05:28 +00:00
meilin.huang
4b3ed1310d refactor: dbms 2024-03-21 20:28:24 +08:00
meilin.huang
b2cfd1517c refactor: dbms与标签管理优化 2024-03-21 17:15:52 +08:00
zongyangleo
b13d27ccd6 !109 refactor:ddl生成方式重构,数据类型和长度重构,所有数据库迁移调试
* feat:同步sqlite全量sql
* refactor:ddl生成方式重构,数据类型和长度重构,所有数据库迁移调试
2024-03-21 03:35:18 +00:00
meilin.huang
68e0088016 refactor: dbms优化 2024-03-18 12:25:40 +08:00
zongyangleo
bd1e83989d !108 feat:支持不同源数据库迁移
* feat:支持不同源数据库迁移
2024-03-15 09:01:51 +00:00
meilin.huang
263dfa6be7 refactor: dbm包重构 2024-03-15 13:31:53 +08:00
meilin.huang
eb55f93864 refactor: dbm包重构 2024-03-11 20:04:20 +08:00
meilin.huang
8589105e44 feat: oracle支持服务名、数据库执行超时时间配置等 2024-03-07 17:26:11 +08:00
589 changed files with 434986 additions and 11566 deletions

View File

@@ -10,7 +10,7 @@ RUN yarn config set registry 'https://registry.npmmirror.com' && \
yarn build
# 构建后端资源
FROM golang:1.22 as be-builder
FROM golang:1.23 as be-builder
ENV GOPROXY https://goproxy.cn
WORKDIR /mayfly

View File

@@ -22,7 +22,7 @@
### 介绍
web 版 **linux(终端[终端回放] 文件 脚本 进程 计划任务)、数据库mysql postgres oracle sqlserver 达梦 高斯 sqlite、redis(单机 哨兵 集群)、mongo 等集工单流程审批于一体的统一管理操作平台**
web 版 **linux(终端[终端回放、命令过滤] 文件 脚本 进程 计划任务)、数据库mysql postgres oracle sqlserver 达梦 高斯 sqlite数据操作 数据同步 数据迁移、redis(单机 哨兵 集群)、mongo 等集工单流程审批于一体的统一管理操作平台**
### 开发语言与主要框架
@@ -45,56 +45,61 @@ http://go.mayfly.run
### 系统核心功能截图
##### 记录操作记录
#### 首页
![记录操作记录](https://objs.gitee.io/mayfly-go-docs/home/log.jpg "屏幕截图.png")
![首页](https://foruda.gitee.com/images/1714378104294194769/149fd257_1240250.png "屏幕截图")
#### 机器操作
##### 状态查看
![状态查看](https://objs.gitee.io/mayfly-go-docs/home/machine-status.jpg "屏幕截图.png")
![机器状态查看](https://foruda.gitee.com/images/1714378556642584686/93c46ec0_1240250.png "屏幕截图")
##### ssh 终端
![ssh终端](https://objs.gitee.io/mayfly-go-docs/home/machine-ssh.jpg "屏幕截图.png")
![终端操作](https://foruda.gitee.com/images/1714378353790214943/2864ba66_1240250.png "屏幕截图")
##### 文件操作
![文件操作](https://objs.gitee.io/mayfly-go-docs/home/file-dir.jpg "屏幕截图.png")
![文件操作](https://objs.gitee.io/mayfly-go-docs/home/file-content-update.jpg "屏幕截图.png")
![文件操作](https://foruda.gitee.com/images/1714378417206086701/74a188d8_1240250.png "屏幕截图")
![文件查看](https://foruda.gitee.com/images/1714378482611638688/7753faf6_1240250.png "屏幕截图")
#### 数据库操作
##### sql 编辑器
![sql编辑器](https://objs.gitee.io/mayfly-go-docs/home/dbms-sql-editor.jpg "屏幕截图.png")
![sql编辑器](https://foruda.gitee.com/images/1714378747473077515/3c9387c0_1240250.png "屏幕截图")
##### 在线增删改查数据
![选表查数据](https://objs.gitee.io/mayfly-go-docs/home/dbms-show-table-data.jpg "屏幕截图.png")
![选表查数据](https://foruda.gitee.com/images/1714378625059063750/3951e5a8_1240250.png "屏幕截图")
#### Redis 操作
![数据](https://objs.gitee.io/mayfly-go-docs/home/redis-data-list.jpg "屏幕截图.png")
![redis操作](https://foruda.gitee.com/images/1714378855845451114/4c3f0097_1240250.png "屏幕截图")
#### Mongo 操作
![数据](https://objs.gitee.io/mayfly-go-docs/home/mongo-op.jpg "屏幕截图.png")
![mongo操作](https://foruda.gitee.com/images/1714378916425714642/77fc0ed9_1240250.png "屏幕截图")
##### 系统管理
#### 工单流程审批
![流程审批](https://foruda.gitee.com/images/1714379057627690037/ad136862_1240250.png "屏幕截图")
#### 系统管理
##### 账号管理
![账号管理](https://images.gitee.com/uploads/images/2021/0607/173919_a8d7dc18_1240250.png "屏幕截图.png")
![账号管理](https://foruda.gitee.com/images/1714379179491881231/c6d802ae_1240250.png "屏幕截图")
##### 角色管理
![角色管理](https://images.gitee.com/uploads/images/2021/0607/174028_3654fb28_1240250.png "屏幕截图.png")
![角色管理](https://foruda.gitee.com/images/1714379269408676381/6ac1e85c_1240250.png "屏幕截图")
##### 资源管理
##### 菜单资源管理
![资源管理](https://images.gitee.com/uploads/images/2021/0607/174436_e9e1535c_1240250.png "屏幕截图.png")
![菜单资源管理](https://foruda.gitee.com/images/1714379321338009940/a00d6a02_1240250.png "屏幕截图")
**其他更多功能&操作指南可查看在线文档**: https://www.yuque.com/may-fly/mayfly-go

View File

@@ -14,7 +14,7 @@ services:
restart: always
server:
image: mayfly-go:v1.3.1
image: ccr.ccs.tencentyun.com/mayfly/mayfly-go:v1.8.5
build:
context: .
dockerfile: Dockerfile

View File

@@ -5,4 +5,6 @@ VITE_PORT = 8889
VITE_OPEN = false
# public path 配置线上环境路径(打包)
VITE_PUBLIC_PATH = ''
VITE_PUBLIC_PATH = ''
VITE_EDITOR=idea

View File

@@ -1,67 +1,72 @@
{
"name": "mayfly",
"version": "1.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"build-preview": "npm run build && npm run preview",
"lint-fix": "eslint --fix --ext .js --ext .jsx --ext .vue src/"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@vueuse/core": "^10.8.0",
"asciinema-player": "^3.7.0",
"axios": "^1.6.2",
"clipboard": "^2.0.11",
"countup.js": "^2.8.0",
"cropperjs": "^1.6.1",
"echarts": "^5.5.0",
"element-plus": "^2.6.0",
"js-base64": "^3.7.5",
"jsencrypt": "^3.3.2",
"lodash": "^4.17.21",
"mitt": "^3.0.1",
"monaco-editor": "^0.46.0",
"monaco-sql-languages": "^0.11.0",
"monaco-themes": "^0.4.4",
"nprogress": "^0.2.0",
"pinia": "^2.1.7",
"qrcode.vue": "^3.4.1",
"screenfull": "^6.0.2",
"sortablejs": "^1.15.2",
"splitpanes": "^3.1.5",
"sql-formatter": "^15.0.2",
"uuid": "^9.0.1",
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-search": "^0.13.0",
"xterm-addon-web-links": "^0.9.0"
},
"devDependencies": {
"@types/lodash": "^4.14.178",
"@types/node": "^18.14.0",
"@types/nprogress": "^0.2.0",
"@types/sortablejs": "^1.15.8",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/compiler-sfc": "^3.4.21",
"code-inspector-plugin": "^0.4.5",
"dotenv": "^16.3.1",
"eslint": "^8.35.0",
"eslint-plugin-vue": "^9.21.1",
"prettier": "^3.2.5",
"sass": "^1.69.0",
"typescript": "^5.3.2",
"vite": "^5.1.4",
"vue-eslint-parser": "^9.4.2"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}
"name": "mayfly",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"build-preview": "npm run build && npm run preview",
"lint-fix": "eslint --fix --ext .js --ext .jsx --ext .vue src/"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@vueuse/core": "^11.1.0",
"asciinema-player": "^3.8.1",
"axios": "^1.6.2",
"clipboard": "^2.0.11",
"cropperjs": "^1.6.1",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.13",
"echarts": "^5.5.1",
"element-plus": "^2.8.6",
"js-base64": "^3.7.7",
"jsencrypt": "^3.3.2",
"lodash": "^4.17.21",
"mitt": "^3.0.1",
"monaco-editor": "^0.52.0",
"monaco-sql-languages": "^0.12.2",
"monaco-themes": "^0.4.4",
"nprogress": "^0.2.0",
"pinia": "^2.2.4",
"qrcode.vue": "^3.5.0",
"screenfull": "^6.0.2",
"sortablejs": "^1.15.3",
"splitpanes": "^3.1.5",
"sql-formatter": "^15.4.5",
"trzsz": "^1.1.5",
"uuid": "^9.0.1",
"vue": "^3.5.12",
"vue-router": "^4.4.5",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-search": "^0.13.0",
"xterm-addon-web-links": "^0.9.0"
},
"devDependencies": {
"@types/crypto-js": "^4.2.2",
"@types/lodash": "^4.14.178",
"@types/node": "^18.14.0",
"@types/nprogress": "^0.2.0",
"@types/sortablejs": "^1.15.8",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"@vitejs/plugin-vue": "^5.1.4",
"@vue/compiler-sfc": "^3.5.12",
"code-inspector-plugin": "^0.4.5",
"dotenv": "^16.3.1",
"eslint": "^8.35.0",
"eslint-plugin-vue": "^9.28.0",
"prettier": "^3.2.5",
"sass": "^1.80.3",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vue-eslint-parser": "^9.4.3"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}

View File

@@ -4,7 +4,7 @@
:zIndex="10000000"
:width="210"
v-if="themeConfig.isWatermark"
:font="{ color: 'rgba(180, 180, 180, 0.5)' }"
:font="{ color: 'rgba(180, 180, 180, 0.3)' }"
:content="themeConfig.watermarkText"
class="h100"
>

File diff suppressed because one or more lines are too long

View File

@@ -69,7 +69,7 @@ class Api {
*/
async xhrReq(param: any = null, options: any = {}): Promise<any> {
if (this.beforeHandler) {
this.beforeHandler(param);
await this.beforeHandler(param);
}
return request.xhrReq(this.method, this.url, param, options);
}

View File

@@ -22,6 +22,8 @@ export class EnumValue {
*/
tag: EnumValueTag;
extra: any;
constructor(value: any, label: string) {
this.value = value;
this.label = label;
@@ -53,6 +55,11 @@ export class EnumValue {
return this;
}
setExtra(extra: any): EnumValue {
this.extra = extra;
return this;
}
public static of(value: any, label: string): EnumValue {
return new EnumValue(value, label);
}
@@ -60,11 +67,12 @@ export class EnumValue {
/**
* 根据枚举值获取指定枚举值对象
*
* @param enumValues 所有枚举值
* @param enums 枚举对象
* @param value 需要匹配的枚举值
* @returns 枚举值对象
*/
static getEnumByValue(enumValues: EnumValue[], value: any): EnumValue | null {
static getEnumByValue(enums: any, value: any): EnumValue | null {
const enumValues = Object.values(enums) as any;
for (let enumValue of enumValues) {
if (enumValue.value == value) {
return enumValue;

View File

@@ -1,9 +1,24 @@
import EnumValue from './Enum';
// 资源类型
export const ResourceTypeEnum = {
Machine: EnumValue.of(1, '机器').setExtra({ icon: 'Monitor', iconColor: 'var(--el-color-primary)' }).tagTypeSuccess(),
Db: EnumValue.of(2, '数据库实例').setExtra({ icon: 'Coin', iconColor: 'var(--el-color-warning)' }).tagTypeWarning(),
Redis: EnumValue.of(3, 'redis').setExtra({ icon: 'iconfont icon-redis', iconColor: 'var(--el-color-danger)' }).tagTypeInfo(),
Mongo: EnumValue.of(4, 'mongo').setExtra({ icon: 'iconfont icon-mongo', iconColor: 'var(--el-color-success)' }).tagTypeDanger(),
};
// 标签关联的资源类型
export const TagResourceTypeEnum = {
Machine: EnumValue.of(1, '机器'),
Db: EnumValue.of(2, '数据库'),
Redis: EnumValue.of(3, 'redis'),
Mongo: EnumValue.of(4, 'mongo'),
AuthCert: EnumValue.of(-2, '公共凭证').setExtra({ icon: 'Ticket' }),
Tag: EnumValue.of(-1, '标签').setExtra({ icon: 'CollectionTag' }),
Machine: ResourceTypeEnum.Machine,
Db: ResourceTypeEnum.Db,
Redis: ResourceTypeEnum.Redis,
Mongo: ResourceTypeEnum.Mongo,
MachineAuthCert: EnumValue.of(11, '机器-授权凭证').setExtra({ icon: 'Ticket', iconColor: 'var(--el-color-success)' }),
DbAuthCert: EnumValue.of(21, '数据库-授权凭证').setExtra({ icon: 'Ticket', iconColor: 'var(--el-color-success)' }),
DbName: EnumValue.of(22, '数据库').setExtra({ icon: 'Coin' }),
};

View File

@@ -15,7 +15,7 @@ const config = {
baseWsUrl: `${(window as any).globalConfig.BaseWsUrl || `${location.protocol == 'https:' ? 'wss:' : 'ws:'}//${getBaseApiUrl()}`}/api`,
// 系统版本
version: 'v1.7.4',
version: 'v1.9.0',
};
export default config;

View File

@@ -0,0 +1,38 @@
import CryptoJS from 'crypto-js';
import { getToken } from '@/common/utils/storage';
/**
* AES 加密数据
* @param word
* @param key
*/
export function AesEncrypt(word: string, key?: string) {
if (!key) {
key = getToken().substring(0, 24);
}
const sKey = CryptoJS.enc.Utf8.parse(key);
const encrypted = CryptoJS.AES.encrypt(word, sKey, {
iv: sKey,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
return encrypted.ciphertext.toString(CryptoJS.enc.Base64);
}
export function AesDecrypt(word: string, key?: string): string {
if (!key) {
key = getToken().substring(0, 24);
}
const sKey = CryptoJS.enc.Utf8.parse(key);
// key 和 iv 使用同一个值
const decrypted = CryptoJS.AES.decrypt(word, sKey, {
iv: sKey,
mode: CryptoJS.mode.CBC, // CBC算法
padding: CryptoJS.pad.Pkcs7, //使用pkcs7 进行padding 后端需要注意
});
return decrypted.toString(CryptoJS.enc.Base64);
}

View File

@@ -2,6 +2,7 @@ import request from './request';
export default {
login: (param: any) => request.post('/auth/accounts/login', param),
refreshToken: (param: any) => request.get('/auth/accounts/refreshToken', param),
otpVerify: (param: any) => request.post('/auth/accounts/otp-verify', param),
getPublicKey: () => request.get('/common/public-key'),
getConfigValue: (params: any) => request.get('/sys/configs/value', params),
@@ -13,4 +14,5 @@ export default {
oauth2Callback: (params: any) => request.get('/auth/oauth2/callback', params),
getLdapEnabled: () => request.get('/auth/ldap/enabled'),
ldapLogin: (param: any) => request.post('/auth/ldap/login', param),
getFileDetail: (keys: string[]) => request.get(`/sys/files/detail/${keys.join(',')}`),
};

View File

@@ -1,4 +1,9 @@
export const AccountUsernamePattern = {
pattern: /^[a-zA-Z0-9_]{5,20}$/g,
message: '只允许输入5-20位大小写字母、数字、下划线',
message: '只允许输入5-20位大小写字母、数字、_-.:',
};
export const ResourceCodePattern = {
pattern: /^[a-zA-Z0-9_\-.:]{1,32}$/g,
message: '只允许输入1-32位大小写字母、数字、_-.:',
};

View File

@@ -38,6 +38,7 @@ export enum ResultEnum {
PARAM_ERROR = 405,
SERVER_ERROR = 500,
NO_PERMISSION = 501,
ACCESS_TOKEN_INVALID = 502, // accessToken失效
}
export const baseUrl: string = config.baseApiUrl;
@@ -208,6 +209,36 @@ export function joinClientParams(): string {
return `token=${getToken()}&clientId=${getClientId()}`;
}
/**
* 获取文件url地址
* @param key 文件key
* @returns 文件url
*/
export function getFileUrl(key: string) {
return `${baseUrl}/sys/files/${key}`;
}
/**
* 获取系统文件上传url
* @param key 文件key
* @returns 文件上传url
*/
export function getUploadFileUrl(key: string = '') {
return `${baseUrl}/sys/files/upload?token=${getToken()}&fileKey=${key}`;
}
/**
* 下载文件
* @param key 文件key
*/
export function downloadFile(key: string) {
const a = document.createElement('a');
a.setAttribute('href', `${getFileUrl(key)}`);
a.setAttribute('target', '_blank');
a.click();
a.remove();
}
function parseResult(result: Result) {
if (result.code === ResultEnum.SUCCESS) {
return result.data;

View File

@@ -1,9 +1,9 @@
import Config from './config';
import { ElNotification } from 'element-plus';
import {ElNotification} from 'element-plus';
import SocketBuilder from './SocketBuilder';
import { getToken } from '@/common/utils/storage';
import {getToken} from '@/common/utils/storage';
import { joinClientParams } from './request';
import {joinClientParams} from './request';
class SysSocket {
/**
@@ -19,10 +19,11 @@ class SysSocket {
/**
* 消息类型
*/
messageTypes = {
messageTypes: any = {
0: 'error',
1: 'success',
2: 'info',
22: 'info',
};
/**
@@ -57,12 +58,20 @@ class SysSocket {
}
const type = this.getMsgType(message.type);
let msg = message.msg
let duration = 0
if (message.type == 22) {
let obj = JSON.parse(msg);
msg = `文件:${obj['title']} 执行成功: ${obj['executedStatements']}`
duration = 2000
}
ElNotification({
duration: 0,
duration: duration,
title: message.title,
message: message.msg,
message: msg,
type: type,
});
console.log(message)
})
.open((event: any) => console.log(event))
.close(() => {

View File

@@ -1,31 +0,0 @@
export function dateFormat2(fmt: string, date: Date) {
let ret;
const opt = {
'y+': date.getFullYear().toString(), // 年
'M+': (date.getMonth() + 1).toString(), // 月
'd+': date.getDate().toString(), // 日
'H+': date.getHours().toString(), // 时
'm+': date.getMinutes().toString(), // 分
's+': date.getSeconds().toString(), // 秒
'S+': date.getMilliseconds() ? date.getMilliseconds().toString() : '', // 毫秒
// 有其他格式化字符需求可以继续添加,必须转化成字符串
};
for (const k in opt) {
ret = new RegExp('(' + k + ')').exec(fmt);
if (ret) {
fmt = fmt.replace(ret[1], ret[1].length == 1 ? opt[k] : opt[k].padStart(ret[1].length, '0'));
}
}
return fmt;
}
export function dateStrFormat(fmt: string, dateStr: string) {
return dateFormat2(fmt, new Date(dateStr));
}
export function dateFormat(dateStr: string) {
if (!dateStr) {
return '';
}
return dateFormat2('yyyy-MM-dd HH:mm:ss', new Date(dateStr));
}

View File

@@ -1,3 +1,18 @@
import dayjs from 'dayjs';
/**
* 格式化日期
* @param date 日期 字符串 Date 时间戳等
* @param format 格式化格式 默认 YYYY-MM-DD HH:mm:ss
* @returns 格式化后内容
*/
export function formatDate(date: any, format: string = 'YYYY-MM-DD HH:mm:ss') {
if (!date) {
return '';
}
return dayjs(date).format(format);
}
/**
* 格式化字节单位
* @param size byte size
@@ -46,110 +61,6 @@ export function convertToBytes(sizeStr: string) {
return bytes;
}
/*
* 年(Y) 可用1-4个占位符
* 月(m)、日(d)、小时(H)、分(M)、秒(S) 可用1-2个占位符
* 星期(W) 可用1-3个占位符
* 季度(q为阿拉伯数字Q为中文数字)可用1或4个占位符
*
* let date = new Date()
* formatDate(date, "YYYY-mm-dd HH:MM:SS") // 2020-02-09 14:04:23
* formatDate(date, "YYYY-mm-dd HH:MM:SS Q") // 2020-02-09 14:09:03 一
* formatDate(date, "YYYY-mm-dd HH:MM:SS WWW") // 2020-02-09 14:45:12 星期日
* formatDate(date, "YYYY-mm-dd HH:MM:SS QQQQ") // 2020-02-09 14:09:36 第一季度
* formatDate(date, "YYYY-mm-dd HH:MM:SS WWW QQQQ") // 2020-02-09 14:46:12 星期日 第一季度
*/
export function formatDate(date: Date, format: string) {
let we = date.getDay(); // 星期
let qut = Math.floor((date.getMonth() + 3) / 3).toString(); // 季度
const opt: any = {
'Y+': date.getFullYear().toString(), // 年
'm+': (date.getMonth() + 1).toString(), // 月(月份从0开始要+1)
'd+': date.getDate().toString(), // 日
'H+': date.getHours().toString(), // 时
'M+': date.getMinutes().toString(), // 分
'S+': date.getSeconds().toString(), // 秒
'q+': qut, // 季度
};
// 中文数字 (星期)
const week: any = {
'0': '日',
'1': '一',
'2': '二',
'3': '三',
'4': '四',
'5': '五',
'6': '六',
};
// 中文数字(季度)
const quarter: any = {
'1': '一',
'2': '二',
'3': '三',
'4': '四',
};
if (/(W+)/.test(format)) format = format.replace(RegExp.$1, RegExp.$1.length > 1 ? (RegExp.$1.length > 2 ? '星期' + week[we] : '周' + week[we]) : week[we]);
if (/(Q+)/.test(format)) format = format.replace(RegExp.$1, RegExp.$1.length == 4 ? '第' + quarter[qut] + '季度' : quarter[qut]);
for (let k in opt) {
let r = new RegExp('(' + k + ')').exec(format);
// 若输入的长度不为1则前面补零
if (r) format = format.replace(r[1], RegExp.$1.length == 1 ? opt[k] : opt[k].padStart(RegExp.$1.length, '0'));
}
return format;
}
/**
* 10秒 10 * 1000
* 1分 60 * 1000
* 1小时 60 * 60 * 1000
* 24小时60 * 60 * 24 * 1000
* 3天 60 * 60* 24 * 1000 * 3
*
* let data = new Date()
* formatPast(data) // 刚刚
* formatPast(data - 11 * 1000) // 11秒前
* formatPast(data - 2 * 60 * 1000) // 2分钟前
* formatPast(data - 60 * 60 * 2 * 1000) // 2小时前
* formatPast(data - 60 * 60 * 2 * 1000) // 2小时前
* formatPast(data - 60 * 60 * 71 * 1000) // 2天前
* formatPast("2020-06-01") // 2020-06-01
* formatPast("2020-06-01", "YYYY-mm-dd HH:MM:SS WWW QQQQ") // 2020-06-01 08:00:00 星期一 第二季度
*/
export function formatPast(param: any, format: string = 'YYYY-mm-dd') {
// 传入格式处理、存储转换值
let t: any, s: any;
// 获取js 时间戳
let time: any = new Date().getTime();
// 是否是对象
typeof param === 'string' || 'object' ? (t = new Date(param).getTime()) : (t = param);
// 当前时间戳 - 传入时间戳
time = Number.parseInt(`${time - t}`);
if (time < 10000) {
// 10秒内
return '刚刚';
} else if (time < 60000 && time >= 10000) {
// 超过10秒少于1分钟内
s = Math.floor(time / 1000);
return `${s}秒前`;
} else if (time < 3600000 && time >= 60000) {
// 超过1分钟少于1小时
s = Math.floor(time / 60000);
return `${s}分钟前`;
} else if (time < 86400000 && time >= 3600000) {
// 超过1小时少于24小时
s = Math.floor(time / 3600000);
return `${s}小时前`;
} else if (time < 259200000 && time >= 86400000) {
// 超过1天少于3天内
s = Math.floor(time / 86400000);
return `${s}天前`;
} else {
// 超过3天
let date = typeof param === 'string' || 'object' ? new Date(param) : param;
return formatDate(date, format);
}
}
/**
* 格式化指定时间数为人性化可阅读的内容(默认time为秒单位)
*

View File

@@ -1,9 +1,27 @@
// 根据对象访问路径,获取对应的值
/**
* 根据对象访问路径,获取对应的值
*
* @param obj 对象,如 {user: {name: 'xxx'}, orderNo: 1212211, products: [{id: 12}]}
* @param path 访问路径,如 orderNo 或者 user.name 或者product[0].id
* @returns 路径对应的值
*/
export function getValueByPath(obj: any, path: string) {
const keys = path.split('.');
let result = obj;
for (let key of keys) {
if (!result || typeof result !== 'object') {
if (!result) {
return undefined;
}
// 如果是字符串则尝试使用json解析
if (typeof result == 'string') {
try {
result = JSON.parse(result);
} catch (e) {
console.error(e);
return undefined;
}
}
if (typeof result !== 'object') {
return undefined;
}
@@ -17,7 +35,18 @@ export function getValueByPath(obj: any, path: string) {
}
const index = parseInt(matchIndex[1]);
result = Array.isArray(result[arrayKey]) ? result[arrayKey][index] : undefined;
let arrValue = result[arrayKey];
if (typeof arrValue == 'string') {
try {
arrValue = JSON.parse(arrValue);
} catch (e) {
result = undefined;
break;
}
}
result = Array.isArray(arrValue) ? arrValue[index] : undefined;
} else {
result = result[key];
}

View File

@@ -1,6 +1,7 @@
import { randomUuid } from './string';
const TokenKey = 'm-token';
const RefreshTokenKey = 'm-refresh-token';
const UserKey = 'm-user';
const TagViewsKey = 'm-tagViews';
const ClientIdKey = 'm-clientId';
@@ -15,6 +16,14 @@ export function saveToken(token: string) {
setLocal(TokenKey, token);
}
export function getRefreshToken(): string {
return getLocal(RefreshTokenKey);
}
export function saveRefreshToken(refreshToken: string) {
return setLocal(RefreshTokenKey, refreshToken);
}
// 获取登录用户基础信息
export function getUser() {
return getLocal(UserKey);
@@ -39,6 +48,7 @@ export function getThemeConfig() {
export function clearUser() {
removeLocal(TokenKey);
removeLocal(UserKey);
removeLocal(RefreshTokenKey);
}
export function getTagViews() {

View File

@@ -97,43 +97,6 @@ export function getTextWidth(str: string) {
return width;
}
/**
* 获取内容所需要占用的宽度
*/
export function getContentWidth(content: any): number {
if (!content) {
return 50;
}
// 以下分配的单位长度可根据实际需求进行调整
let flexWidth = 0;
for (const char of content) {
if (flexWidth > 500) {
break;
}
if ((char >= '0' && char <= '9') || (char >= 'a' && char <= 'z')) {
// 小写字母、数字字符
flexWidth += 9.3;
continue;
}
if (char >= 'A' && char <= 'Z') {
flexWidth += 9;
continue;
}
if (char >= '\u4e00' && char <= '\u9fa5') {
// 如果是中文字符为字符分配16个单位宽度
flexWidth += 20;
} else {
// 其他种类字符
flexWidth += 8;
}
}
// if (flexWidth > 450) {
// // 设置最大宽度
// flexWidth = 450;
// }
return flexWidth;
}
/**
*
* @returns uuid
@@ -179,3 +142,38 @@ export async function copyToClipboard(txt: string, selector: string = '#copyValu
clipboard.destroy();
});
}
export function fuzzyMatchField(keyword: string, fields: any[], ...valueExtractFuncs: Function[]) {
keyword = keyword?.toLowerCase();
return fields.filter((field) => {
for (let valueExtractFunc of valueExtractFuncs) {
const value = valueExtractFunc(field)?.toLowerCase();
if (isPrefixSubsequence(keyword, value)) {
return true;
}
}
return false;
});
}
/**
* 匹配是否为前缀子序列 targetTemplate=username prefix=uname -> trueprefix=uname2 -> false
* @param prefix 字符串前缀(不连续也可以,但不改变字符的相对顺序)
* @param targetTemplate 目标模板
* @returns 是否匹配
*/
export function isPrefixSubsequence(prefix: string, targetTemplate: string) {
let i = 0; // 指向prefix的索引
let j = 0; // 指向targetTemplate的索引
while (i < prefix.length && j < targetTemplate.length) {
if (prefix[i] === targetTemplate[j]) {
// 字符匹配,两个指针都向前移动
i++;
}
j++; // 目标字符串指针始终向前移动
}
// 如果prefix的所有字符都被找到返回true
return i === prefix.length;
}

View File

@@ -4,6 +4,7 @@ export interface ViteEnv {
VITE_PORT: number;
VITE_OPEN: boolean;
VITE_PUBLIC_PATH: string;
VITE_EDITOR: string;
}
export function loadEnv(): ViteEnv {

View File

@@ -83,7 +83,7 @@ const breakPoint = computed<BreakPoint>(() => gridRef.value?.breakPoint);
// 判断是否显示 展开/合并 按钮
const showCollapse = computed(() => {
let show = false;
props.items.reduce((prev, current) => {
props.items.reduce((prev, current: any) => {
prev += (current![breakPoint.value]?.span ?? current?.span ?? 1) + (current![breakPoint.value]?.offset ?? current?.offset ?? 0);
if (typeof props.searchCol !== 'number') {
if (prev >= props.searchCol[breakPoint.value]) show = true;

View File

@@ -14,11 +14,11 @@ export function hasPerm(code: string) {
/**
* 判断用户是否拥有权限对象里对应的code
* @param perms { save: "xxx:save"}
* @returns {"xxx:save": true} key->permission code
* @param permCodes
*/
export function hasPerms(permCodes: any[]) {
const res = {};
const res = {} as { [key: string]: boolean };
for (let permCode of permCodes) {
if (hasPerm(permCode)) {
res[permCode] = true;

View File

@@ -35,38 +35,42 @@
<p class="title">时间表达式</p>
<table>
<thead>
<th v-for="item of tabTitles" width="40" :key="item">{{ item }}</th>
<th>crontab完整表达式</th>
<tr>
<th v-for="item of tabTitles" width="40" :key="item">{{ item }}</th>
<th>crontab完整表达式</th>
</tr>
</thead>
<tbody>
<td>
<span>{{ crontabValueObj.second }}</span>
</td>
<td>
<span>{{ crontabValueObj.min }}</span>
</td>
<td>
<span>{{ crontabValueObj.hour }}</span>
</td>
<td>
<span>{{ crontabValueObj.day }}</span>
</td>
<td>
<span>{{ crontabValueObj.mouth }}</span>
</td>
<td>
<span>{{ crontabValueObj.week }}</span>
</td>
<td>
<span>{{ crontabValueObj.year }}</span>
</td>
<td>
<span>{{ contabValueString }}</span>
</td>
<tr>
<td>
<span>{{ crontabValueObj.second }}</span>
</td>
<td>
<span>{{ crontabValueObj.min }}</span>
</td>
<td>
<span>{{ crontabValueObj.hour }}</span>
</td>
<td>
<span>{{ crontabValueObj.day }}</span>
</td>
<td>
<span>{{ crontabValueObj.mouth }}</span>
</td>
<td>
<span>{{ crontabValueObj.week }}</span>
</td>
<td>
<span>{{ crontabValueObj.year }}</span>
</td>
<td>
<span>{{ crontabValueString }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<CrontabResult :ex="contabValueString"></CrontabResult>
<CrontabResult :ex="crontabValueString"></CrontabResult>
<div class="pop_btn">
<el-button size="small" @click="hidePopup">取消</el-button>
@@ -202,7 +206,7 @@ function hidePopup() {
// 填充表达式
const submitFill = () => {
emit('fill', contabValueString.value);
emit('fill', crontabValueString.value);
hidePopup();
};
@@ -220,7 +224,7 @@ const clearCron = () => {
changeTab(state.activeName);
};
const contabValueString = computed(() => {
const crontabValueString = computed(() => {
let obj = state.crontabValueObj;
let str = obj.second + ' ' + obj.min + ' ' + obj.hour + ' ' + obj.day + ' ' + obj.mouth + ' ' + obj.week + (obj.year == '' ? '' : ' ' + obj.year);
return str;

View File

@@ -0,0 +1,17 @@
<template>
<el-select v-bind="$attrs" v-model="modelValue">
<el-option v-for="item in props.enums" :key="item.value" :label="item.label" :value="item.value"> </el-option>
</el-select>
</template>
<script lang="ts" setup>
const props = defineProps({
enums: {
type: Object, // 需要为EnumValue类型
required: true,
},
});
const modelValue: any = defineModel('modelValue');
</script>
<style scoped lang="scss"></style>

View File

@@ -1,5 +1,5 @@
<template>
<el-tag v-bind="$attrs" :type="type" :color="color" effect="plain">{{ enumLabel }}</el-tag>
<el-tag :disable-transitions="true" v-bind="$attrs" :type="type" :color="color" effect="plain">{{ enumLabel }}</el-tag>
</template>
<script lang="ts" setup>
@@ -40,7 +40,7 @@ onMounted(() => {
});
const convert = (value: any) => {
const enumValue = EnumValue.getEnumByValue(Object.values(props.enums as any) as any, value) as any;
const enumValue = EnumValue.getEnumByValue(props.enums, value) as any;
if (!enumValue) {
state.enumLabel = '-';
state.type = 'danger';

View File

@@ -0,0 +1,60 @@
<template>
<el-tooltip :content="formatByteSize(fileDetail?.size)" placement="left">
<el-link v-if="props.canDownload" target="_blank" rel="noopener noreferrer" icon="Download" type="primary" :href="getFileUrl(props.fileKey)"></el-link>
</el-tooltip>
{{ fileDetail?.filename }}
</template>
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue';
import openApi from '@/common/openApi';
import { getFileUrl } from '@/common/request';
import { formatByteSize } from '@/common/utils/format';
const props = defineProps({
fileKey: {
type: String,
required: true,
},
files: {
type: [Array],
},
canDownload: {
type: Boolean,
default: true,
},
});
onMounted(async () => {
setFileInfo();
});
watch(
() => props.fileKey,
async (val) => {
if (val) {
setFileInfo();
}
}
);
const fileDetail: any = ref({});
const setFileInfo = async () => {
if (!props.fileKey) {
return;
}
if (props.files && props.files.length > 0) {
const file: any = props.files.find((file: any) => {
return file.fileKey === props.fileKey;
});
fileDetail.value = file;
return;
}
const files = await openApi.getFileDetail([props.fileKey]);
fileDetail.value = files?.[0];
};
</script>
<style lang="scss"></style>

View File

@@ -252,7 +252,9 @@ const changeLanguage = (value: any) => {
};
const setEditorValue = (value: any) => {
monacoEditorIns.getModel()?.setValue(value);
if (value) {
monacoEditorIns.getModel()?.setValue(value);
}
};
/**

View File

@@ -156,10 +156,10 @@
<el-row v-if="props.pageable" class="mt20" type="flex" justify="end">
<el-pagination
:small="props.size == 'small'"
@current-change="handlePageNumChange"
@size-change="handlePageSizeChange"
@current-change="pageNumChange"
@size-change="pageSizeChange"
style="text-align: right"
layout="prev, pager, next, total, sizes, jumper"
layout="prev, pager, next, total, sizes"
:total="total"
v-model:current-page="queryForm.pageNum"
v-model:page-size="queryForm.pageSize"
@@ -185,7 +185,7 @@ import SvgIcon from '@/components/svgIcon/index.vue';
import { usePageTable } from '@/hooks/usePageTable';
import { ElTable } from 'element-plus';
const emit = defineEmits(['update:queryForm', 'update:selectionData', 'pageChange']);
const emit = defineEmits(['update:selectionData', 'pageSizeChange', 'pageNumChange']);
export interface PageTableProps {
size?: string;
@@ -257,6 +257,15 @@ const changeSimpleFormItem = (searchItem: SearchItem) => {
nowSearchItem.value = searchItem;
};
const pageSizeChange = (val: number) => {
emit('pageSizeChange', val);
handlePageSizeChange(val);
};
const pageNumChange = (val: number) => {
emit('pageNumChange', val);
handlePageNumChange(val);
};
let { tableData, total, loading, search, reset, getTableData, handlePageNumChange, handlePageSizeChange } = usePageTable(
props.pageable,
props.pageApi,
@@ -353,6 +362,7 @@ defineExpose({
tableRef: tableRef,
search: getTableData,
getData,
total,
});
</script>
<style scoped lang="scss">

View File

@@ -1,5 +1,5 @@
import EnumValue from '@/common/Enum';
import { dateFormat } from '@/common/utils/date';
import { formatDate } from '@/common/utils/format';
import { getValueByPath } from '@/common/utils/object';
import { getTextWidth } from '@/common/utils/string';
@@ -172,7 +172,7 @@ export class TableColumn {
*/
isTime(): TableColumn {
this.setFormatFunc((data: any, prop: string) => {
return dateFormat(getValueByPath(data, prop));
return formatDate(getValueByPath(data, prop));
});
return this;
}

View File

@@ -0,0 +1,505 @@
<template>
<div>
<div ref="viewportRef" class="viewport" :style="{ width: state.size.width + 'px', height: state.size.height + 'px' }">
<div ref="displayRef" class="display" tabindex="0" />
<div class="btn-box">
<SvgIcon name="DocumentCopy" @click="openPaste" :size="20" class="pointer-icon mr10" title="剪贴板" />
<SvgIcon name="FolderOpened" @click="openFilesystem" :size="20" class="pointer-icon mr10" title="文件管理" />
<SvgIcon name="FullScreen" @click="state.fullscreen ? closeFullScreen() : openFullScreen()" :size="20" class="pointer-icon mr10" title="全屏" />
<el-dropdown>
<SvgIcon name="Monitor" :size="20" class="pointer-icon mr10" title="发送快捷键" style="color: #fff" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="openSendKeyboard(['65507', '65513', '65535'])"> Ctrl + Alt + Delete </el-dropdown-item>
<el-dropdown-item @click="openSendKeyboard(['65507', '65513', '65288'])"> Ctrl + Alt + Backspace </el-dropdown-item>
<el-dropdown-item @click="openSendKeyboard(['65515', '100'])"> Windows + D </el-dropdown-item>
<el-dropdown-item @click="openSendKeyboard(['65515', '101'])"> Windows + E </el-dropdown-item>
<el-dropdown-item @click="openSendKeyboard(['65515', '114'])"> Windows + R </el-dropdown-item>
<el-dropdown-item @click="openSendKeyboard(['65515'])"> Windows </el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<SvgIcon name="Refresh" @click="connect(0, 0)" :size="20" class="pointer-icon mr10" title="重新连接" />
</div>
<clipboard-dialog ref="clipboardRef" v-model:visible="state.clipboardDialog.visible" @close="closePaste" @submit="onsubmitClipboard" />
<el-dialog
v-if="!state.fullscreen"
destroy-on-close
:title="state.filesystemDialog.title"
v-model="state.filesystemDialog.visible"
:close-on-click-modal="false"
width="70%"
>
<machine-file
:machine-id="state.filesystemDialog.machineId"
:auth-cert-name="state.filesystemDialog.authCertName"
:protocol="state.filesystemDialog.protocol"
:file-id="state.filesystemDialog.fileId"
:path="state.filesystemDialog.path"
/>
</el-dialog>
</div>
<el-dialog
v-if="!state.fullscreen"
destroy-on-close
:title="state.filesystemDialog.title"
v-model="state.filesystemDialog.visible"
:close-on-click-modal="false"
width="70%"
>
<machine-file
:machine-id="state.filesystemDialog.machineId"
:auth-cert-name="state.filesystemDialog.authCertName"
:protocol="state.filesystemDialog.protocol"
:file-id="state.filesystemDialog.fileId"
:path="state.filesystemDialog.path"
/>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import Guacamole from './guac/guacamole-common';
import { getMachineRdpSocketUrl } from '@/views/ops/machine/api';
import clipboard from './guac/clipboard';
import { reactive, ref } from 'vue';
import { TerminalStatus } from '@/components/terminal/common';
import ClipboardDialog from '@/components/terminal-rdp/guac/ClipboardDialog.vue';
import { TerminalExpose } from '@/components/terminal-rdp/index';
import SvgIcon from '@/components/svgIcon/index.vue';
import MachineFile from '@/views/ops/machine/file/MachineFile.vue';
import { exitFullscreen, launchIntoFullscreen, unWatchFullscreenChange, watchFullscreenChange } from '@/components/terminal-rdp/guac/screen';
import { useEventListener } from '@vueuse/core';
import { debounce } from 'lodash';
import { ClientState, TunnelState } from '@/components/terminal-rdp/guac/states';
import { ElMessage } from 'element-plus';
import { joinClientParams } from '@/common/request';
const viewportRef = ref({} as any);
const displayRef = ref({} as any);
const clipboardRef = ref({} as any);
const props = defineProps({
machineId: {
type: Number,
required: true,
},
authCert: {
type: String,
required: true,
},
clipboardList: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['statusChange']);
const state = reactive({
client: null as any,
display: null as any,
displayElm: {} as any,
clipboard: {} as any,
keyboard: {} as any,
mouse: null as any,
touchpad: null as any,
errorMessage: '',
arguments: {} as any,
status: TerminalStatus.NoConnected,
size: {
height: 710,
width: 1024,
force: false,
},
enableClipboard: true,
clipboardDialog: {
visible: false,
},
filesystemDialog: {
visible: false,
authCertName: '',
machineId: 0,
protocol: 1,
title: '',
fileId: 0,
path: '',
},
fullscreen: false,
beforeFullSize: {
height: 710,
width: 1024,
},
});
const installKeyboard = () => {
state.keyboard = new Guacamole.Keyboard(state.displayElm);
uninstallKeyboard();
state.keyboard.onkeydown = (keysym: any) => {
state.client.sendKeyEvent(1, keysym);
};
state.keyboard.onkeyup = (keysym: any) => {
state.client.sendKeyEvent(0, keysym);
};
};
const uninstallKeyboard = () => {
state.keyboard!.onkeydown = state.keyboard!.onkeyup = () => {};
};
const installMouse = () => {
state.mouse = new Guacamole.Mouse(state.displayElm);
// Hide software cursor when mouse leaves display
state.mouse.onmouseout = () => {
if (!state.display) return;
state.display.showCursor(false);
};
state.mouse.onmousedown = state.mouse.onmouseup = state.mouse.onmousemove = handleMouseState;
};
const installTouchpad = () => {
state.touchpad = new Guacamole.Mouse.Touchpad(state.displayElm);
state.touchpad.onmousedown =
state.touchpad.onmouseup =
state.touchpad.onmousemove =
(st: any) => {
// 记录按下时,光标所在位置
console.log(st);
handleMouseState(st, true);
};
// 记录单指按压时候手在屏幕的位置
state.displayElm.ontouchend = (event: TouchEvent) => {
console.log('end', event);
state.displayElm.ontouchend = () => {};
};
};
const setClipboard = (data: string) => {
clipboardRef.value.setValue(data);
};
const installClipboard = () => {
state.enableClipboard = clipboard.install(state.client) as any;
clipboard.installWatcher(props.clipboardList, setClipboard);
state.client.onclipboard = clipboard.onClipboard;
};
const installResize = () => {
// 在resize事件结束后300毫秒执行
useEventListener('resize', debounce(resize, 300));
};
const installDisplay = () => {
let { width, height, force } = state.size;
state.display = state.client.getDisplay();
const displayElm = displayRef.value;
displayElm.appendChild(state.display.getElement());
displayElm.addEventListener('contextmenu', (e: any) => {
e.stopPropagation();
if (e.preventDefault) {
e.preventDefault();
}
e.returnValue = false;
});
state.client.connect('width=' + width + '&height=' + height + '&force=' + force + '&' + joinClientParams());
window.onunload = () => state.client.disconnect();
// allows focusing on the display div so that keyboard doesn't always go to session
displayElm.onclick = () => {
displayElm.focus();
};
displayElm.onfocus = () => {
displayElm.className = 'focus';
};
displayElm.onblur = () => {
displayElm.className = '';
};
state.displayElm = displayElm;
};
const installClient = () => {
let tunnel = new Guacamole.WebSocketTunnel(getMachineRdpSocketUrl(props.authCert)) as any;
if (state.client) {
state.display?.scale(0);
uninstallKeyboard();
state.client.disconnect();
}
state.client = new Guacamole.Client(tunnel);
tunnel.onerror = (status: any) => {
// eslint-disable-next-line no-console
console.error(`Tunnel failed ${JSON.stringify(status)}`);
// state.connectionState = states.TUNNEL_ERROR;
};
tunnel.onstatechange = (st: any) => {
console.log('statechange', st);
state.status = st;
switch (st) {
case TunnelState.CONNECTING: // 'CONNECTING'
break;
case TunnelState.OPEN: // 'OPEN'
state.status = TerminalStatus.Connected;
emit('statusChange', TerminalStatus.Connected);
break;
case TunnelState.CLOSED: // 'CLOSED'
state.status = TerminalStatus.Disconnected;
emit('statusChange', TerminalStatus.Disconnected);
break;
case TunnelState.UNSTABLE: // 'UNSTABLE'
state.status = TerminalStatus.Error;
emit('statusChange', TerminalStatus.Error);
break;
}
};
state.client.onstatechange = (clientState: any) => {
console.log('clientState', clientState);
switch (clientState) {
case ClientState.IDLE:
console.log('连接空闲');
break;
case ClientState.CONNECTING:
console.log('连接中...');
break;
case ClientState.WAITING:
console.log('等待服务器响应...');
break;
case ClientState.CONNECTED:
console.log('连接成功...');
break;
// eslint-disable-next-line no-fallthrough
case ClientState.DISCONNECTING:
console.log('断开连接中...');
break;
case ClientState.DISCONNECTED:
console.log('已断开连接...');
break;
}
};
state.client.onerror = (error: any) => {
state.client.disconnect();
console.error(`Client error ${JSON.stringify(error)}`);
state.errorMessage = error.message;
// state.connectionState = states.CLIENT_ERROR;
};
state.client.onsync = () => {};
state.client.onargv = (stream: any, mimetype: any, name: any) => {
if (mimetype !== 'text/plain') return;
const reader = new Guacamole.StringReader(stream);
// Assemble received data into a single string
let value = '';
reader.ontext = (text: any) => {
value += text;
};
// Test mutability once stream is finished, storing the current value for the argument only if it is mutable
reader.onend = () => {
const stream = state.client.createArgumentValueStream('text/plain', name);
stream.onack = (status: any) => {
if (status.isError()) {
// ignore reject
return;
}
state.arguments[name] = value;
};
};
};
};
const resize = () => {
const elm = viewportRef.value;
if (!elm || !elm.offsetWidth) {
// resize is being called on the hidden window
return;
}
let box = elm.parentElement;
state.size.width = box.clientWidth;
state.size.height = box.clientHeight;
const width = parseInt(String(box.clientWidth));
const height = parseInt(String(box.clientHeight));
if (state.display.getWidth() !== width || state.display.getHeight() !== height) {
if (state.status !== TerminalStatus.Connected) {
connect(width, height);
} else {
state.client.sendSize(width, height);
}
}
// setting timeout so display has time to get the correct size
// setTimeout(() => {
// const scale = Math.min(box.clientWidth / Math.max(state.display.getWidth(), 1), box.clientHeight / Math.max(state.display.getHeight(), 1));
// state.display.scale(scale);
// console.log(state.size, scale);
// }, 100);
};
const handleMouseState = (mouseState: any, showCursor = false) => {
state.client.getDisplay().showCursor(showCursor);
const scaledMouseState = Object.assign({}, mouseState, {
x: mouseState.x / state.display.getScale(),
y: mouseState.y / state.display.getScale(),
});
state.client.sendMouseState(scaledMouseState);
};
const connect = (width: number, height: number, force = false) => {
if (!width && !height) {
if (state.size && state.size.width && state.size.height) {
width = state.size.width;
height = state.size.height;
} else {
// 获取当前viewportRef宽高
width = viewportRef.value.clientWidth;
height = viewportRef.value.clientHeight;
}
}
state.size = { width, height, force };
installClient();
installDisplay();
installKeyboard();
installMouse();
installTouchpad();
installClipboard();
installResize();
};
const disconnect = () => {
uninstallKeyboard();
state.client?.disconnect();
};
const blur = () => {
uninstallKeyboard();
};
const focus = () => {};
const openPaste = async () => {
state.clipboardDialog.visible = true;
};
const closePaste = async () => {
installKeyboard();
};
const onsubmitClipboard = (val: string) => {
state.clipboardDialog.visible = false;
installKeyboard();
clipboard.sendRemoteClipboard(state.client, val);
};
const openFilesystem = async () => {
state.filesystemDialog.protocol = 2;
state.filesystemDialog.machineId = props.machineId;
state.filesystemDialog.authCertName = props.authCert;
state.filesystemDialog.fileId = props.machineId;
state.filesystemDialog.path = '/';
state.filesystemDialog.title = `远程桌面文件管理`;
state.filesystemDialog.visible = true;
};
const openFullScreen = function () {
launchIntoFullscreen(viewportRef.value);
state.fullscreen = true;
// 记录原始尺寸
state.beforeFullSize = {
width: state.size.width,
height: state.size.height,
};
// 使用新的宽高重新连接
setTimeout(() => {
connect(viewportRef.value.clientWidth, viewportRef.value.clientHeight, false);
}, 500);
watchFullscreenChange(watchFullscreen);
};
function watchFullscreen(event: Event, isFull: boolean) {
if (!isFull) {
closeFullScreen();
}
}
const closeFullScreen = function () {
exitFullscreen();
state.fullscreen = false;
// 使用新的宽高重新连接
setTimeout(() => {
connect(state.beforeFullSize.width, state.beforeFullSize.height, false);
}, 500);
// 取消注册esc事件退出全屏
unWatchFullscreenChange(watchFullscreen);
};
const openSendKeyboard = (keys: string[]) => {
if (!state.client) {
return;
}
for (let i = 0; i < keys.length; i++) {
state.client.sendKeyEvent(1, keys[i]);
}
for (let j = 0; j < keys.length; j++) {
state.client.sendKeyEvent(0, keys[j]);
}
ElMessage.success('发送组合键成功');
};
const exposes = {
connect,
disconnect,
init: connect,
close: disconnect,
fitTerminal: resize,
focus,
blur,
setRemoteClipboard: onsubmitClipboard,
} as TerminalExpose;
defineExpose(exposes);
</script>
<style lang="scss">
.viewport {
position: relative;
width: 1024px;
min-height: 710px;
z-index: 1;
}
.display {
overflow: hidden;
width: 100%;
height: 100%;
}
.btn-box {
position: absolute;
top: 20px;
right: 30px;
padding: 5px 0 5px 10px;
background: #dddddd4a;
color: #fff;
border-radius: 3px;
}
</style>

View File

@@ -0,0 +1,137 @@
<template>
<div class="rdpDialog" ref="dialogRef">
<el-dialog
v-model="dialogVisible"
:before-close="handleClose"
:close-on-click-modal="false"
:destroy-on-close="true"
:close-on-press-escape="false"
:show-close="false"
width="1024"
@open="connect()"
>
<template #header>
<div class="terminal-title-wrapper">
<!-- 左侧 -->
<div class="title-left-fixed">
<!-- title信息 -->
<div>
{{ title }}
</div>
</div>
<!-- 右侧 -->
<div class="title-right-fixed">
<el-popconfirm @confirm="connect(true)" title="确认重新连接?">
<template #reference>
<div class="mr10 pointer">
<el-tag v-if="state.status == TerminalStatus.Connected" type="success" effect="light" round> 已连接 </el-tag>
<el-tag v-else type="danger" effect="light" round> 未连接点击重连 </el-tag>
</div>
</template>
</el-popconfirm>
<el-popconfirm @confirm="handleClose" title="确认关闭?">
<template #reference>
<SvgIcon name="Close" class="pointer-icon" title="关闭" :size="20" />
</template>
</el-popconfirm>
</div>
</div>
</template>
<machine-rdp ref="rdpRef" :machine-id="machineId" :auth-cert="authCert" @status-change="handleStatusChange" />
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref, toRefs, watch } from 'vue';
import MachineRdp from '@/components/terminal-rdp/MachineRdp.vue';
import { TerminalStatus } from '@/components/terminal/common';
import SvgIcon from '@/components/svgIcon/index.vue';
const rdpRef = ref({} as any);
const dialogRef = ref({} as any);
const props = defineProps({
visible: { type: Boolean },
machineId: {
type: Number,
required: true,
},
authCert: {
type: String,
required: true,
},
title: { type: String },
});
const emit = defineEmits(['update:visible', 'cancel', 'update:machineId']);
const state = reactive({
dialogVisible: false,
title: '',
status: TerminalStatus.NoConnected,
});
const { dialogVisible } = toRefs(state);
watch(props, async (newValue: any) => {
const visible = newValue.visible;
state.dialogVisible = visible;
if (visible) {
state.title = newValue.title;
}
});
const connect = (force = false) => {
rdpRef.value?.disconnect();
let width = 1024;
let height = 710;
rdpRef.value?.connect(width, height, force);
};
const handleStatusChange = (status: TerminalStatus) => {
state.status = status;
};
/**
* 关闭取消按钮触发的事件
*/
const handleClose = () => {
emit('update:visible', false);
emit('update:machineId', null);
emit('cancel');
rdpRef.value?.disconnect();
};
</script>
<style lang="scss">
.rdpDialog {
.el-dialog {
padding: 0;
.el-dialog__header {
padding: 10px;
}
}
.el-overlay .el-overlay-dialog .el-dialog .el-dialog__body {
max-height: 100% !important;
padding: 0 !important;
}
.terminal-title-wrapper {
display: flex;
justify-content: space-between;
font-size: 16px;
.title-right-fixed {
display: flex;
align-items: center;
font-size: 20px;
text-align: end;
}
}
}
</style>

View File

@@ -0,0 +1,66 @@
<template>
<div class="clipboard-dialog">
<el-dialog
v-model="dialogVisible"
title="请输入需要粘贴的文本"
:before-close="onclose"
:close-on-click-modal="false"
:close-on-press-escape="false"
width="600"
>
<el-input v-model="state.modelValue" type="textarea" :rows="20" />
<template #footer>
<el-button type="primary" @click="onsubmit"> </el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, toRefs, watch } from 'vue';
import { ElMessage } from 'element-plus';
const props = defineProps({
visible: { type: Boolean },
});
const emits = defineEmits(['submit', 'close', 'update:visible']);
const state = reactive({
dialogVisible: false,
modelValue: '',
});
const { dialogVisible } = toRefs(state);
watch(props, async (newValue: any) => {
state.dialogVisible = newValue.visible;
});
const onclose = () => {
emits('update:visible', false);
emits('close');
};
const onsubmit = () => {
state.dialogVisible = false;
if (state.modelValue) {
ElMessage.success('发送剪贴板数据成功');
emits('submit', state.modelValue);
} else {
ElMessage.warning('请输入需要粘贴的文本');
}
};
const setValue = (val: string) => {
state.modelValue = val;
};
defineExpose({ setValue });
</script>
<style lang="scss">
.clipboard-dialog {
}
</style>

View File

@@ -0,0 +1,147 @@
import Guacamole from './guacamole-common';
import { ElMessage } from 'element-plus';
const clipboard = {};
clipboard.install = (client) => {
if (!navigator.clipboard) {
return false;
}
clipboard.getLocalClipboard().then((data) => (clipboard.cache = data));
window.addEventListener('load', clipboard.update(client), true);
window.addEventListener('copy', clipboard.update(client));
window.addEventListener('cut', clipboard.update(client));
window.addEventListener(
'focus',
(e) => {
if (e.target === window) {
clipboard.update(client)();
}
},
true
);
return true;
};
clipboard.update = (client) => {
return () => {
clipboard.getLocalClipboard().then((data) => {
clipboard.cache = data;
clipboard.setRemoteClipboard(client);
});
};
};
clipboard.sendRemoteClipboard = (client, text) => {
clipboard.cache = {
type: 'text/plain',
data: text,
};
clipboard.setRemoteClipboard(client);
};
clipboard.setRemoteClipboard = (client) => {
if (!clipboard.cache) {
return;
}
let writer;
const stream = client.createClipboardStream(clipboard.cache.type);
if (typeof clipboard.cache.data === 'string') {
writer = new Guacamole.StringWriter(stream);
writer.sendText(clipboard.cache.data);
writer.sendEnd();
clipboard.appendClipboardList('up', clipboard.cache.data);
} else {
writer = new Guacamole.BlobWriter(stream);
writer.oncomplete = function clipboardSent() {
writer.sendEnd();
};
writer.sendBlob(clipboard.cache.data);
}
};
clipboard.getLocalClipboard = async () => {
// 获取本地剪贴板数据
if (navigator.clipboard && navigator.clipboard.readText) {
const text = await navigator.clipboard.readText();
return {
type: 'text/plain',
data: text,
};
} else {
ElMessage.warning('只有https才可以访问剪贴板');
}
};
clipboard.setLocalClipboard = async (data) => {
if (data.type === 'text/plain') {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(data.data);
}
}
};
// 获取到远程服务器剪贴板变动
clipboard.onClipboard = (stream, mimetype) => {
let reader;
if (/^text\//.exec(mimetype)) {
reader = new Guacamole.StringReader(stream);
// Assemble received data into a single string
let data = '';
reader.ontext = (text) => {
data += text;
};
// Set clipboard contents once stream is finished
reader.onend = () => {
clipboard.setLocalClipboard({
type: mimetype,
data: data,
});
clipboard.setClipboardFn && typeof clipboard.setClipboardFn === 'function' && clipboard.setClipboardFn(data);
clipboard.appendClipboardList('down', data);
};
} else {
reader = new Guacamole.BlobReader(stream, mimetype);
reader.onend = () => {
clipboard.setLocalClipboard({
type: mimetype,
data: reader.getBlob(),
});
};
}
};
/***
* 注册剪贴板监听器,如果有本地或远程剪贴板变动,则会更新剪贴板列表
*/
clipboard.installWatcher = (clipboardList, setClipboardFn) => {
clipboard.clipboardList = clipboardList;
clipboard.setClipboardFn = setClipboardFn;
};
clipboard.appendClipboardList = (src, data) => {
clipboard.clipboardList = clipboard.clipboardList || [];
// 循环判断是否重复
for (let i = 0; i < clipboard.clipboardList.length; i++) {
if (clipboard.clipboardList[i].data === data) {
return;
}
}
clipboard.clipboardList.push({ type: 'text/plain', data, src });
};
export default clipboard;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
export function launchIntoFullscreen(element) {
if (element.requestFullscreen) {
element.requestFullscreen();
} else if (element.mozRequestFullScreen) {
element.mozRequestFullScreen();
} else if (element.webkitRequestFullscreen) {
element.webkitRequestFullscreen();
} else if (element.msRequestFullscreen) {
element.msRequestFullscreen();
}
}
export function exitFullscreen() {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
}
}
export function watchFullscreenChange(callback) {
function onFullscreenChange(e) {
let isFull = (document.fullscreenElement || document.mozFullScreenElement || document.webkitFullscreenElement || document.msFullscreenElement) != null;
callback(e, isFull);
}
document.addEventListener('fullscreenchange', onFullscreenChange);
document.addEventListener('mozfullscreenchange', onFullscreenChange);
document.addEventListener('webkitfullscreenchange', onFullscreenChange);
document.addEventListener('msfullscreenchange', onFullscreenChange);
}
export function unWatchFullscreenChange(callback) {
document.removeEventListener('fullscreenchange', callback);
document.removeEventListener('mozfullscreenchange', callback);
document.removeEventListener('webkitfullscreenchange', callback);
document.removeEventListener('msfullscreenchange', callback);
}

View File

@@ -0,0 +1,78 @@
export const ClientState = {
/**
* The client is idle, with no active connection.
*
* @type number
*/
IDLE: 0,
/**
* The client is in the process of establishing a connection.
*
* @type {!number}
*/
CONNECTING: 1,
/**
* The client is waiting on further information or a remote server to
* establish the connection.
*
* @type {!number}
*/
WAITING: 2,
/**
* The client is actively connected to a remote server.
*
* @type {!number}
*/
CONNECTED: 3,
/**
* The client is in the process of disconnecting from the remote server.
*
* @type {!number}
*/
DISCONNECTING: 4,
/**
* The client has completed the connection and is no longer connected.
*
* @type {!number}
*/
DISCONNECTED: 5,
};
export const TunnelState = {
/**
* A connection is in pending. It is not yet known whether connection was
* successful.
*
* @type {!number}
*/
CONNECTING: 0,
/**
* Connection was successful, and data is being received.
*
* @type {!number}
*/
OPEN: 1,
/**
* The connection is closed. Connection may not have been successful, the
* tunnel may have been explicitly closed by either side, or an error may
* have occurred.
*
* @type {!number}
*/
CLOSED: 2,
/**
* The connection is open, but communication through the tunnel appears to
* be disrupted, and the connection may close as a result.
*
* @type {!number}
*/
UNSTABLE: 3,
};

View File

@@ -0,0 +1,11 @@
export interface TerminalExpose {
/** 连接 */
init(width: number, height: number, force: boolean): void;
/** 短开连接 */
close(): void;
blur(): void;
focus(): void;
}

View File

@@ -21,6 +21,7 @@ import { debounce } from 'lodash';
import { TerminalStatus } from './common';
import { useEventListener } from '@vueuse/core';
import themes from './themes';
import { TrzszFilter } from 'trzsz';
const props = defineProps({
// mounted时是否执行init方法
@@ -66,7 +67,7 @@ const state = reactive({
search: null as any,
weblinks: null as any,
},
status: TerminalStatus.NoConnected,
status: -11,
});
onMounted(() => {
@@ -95,17 +96,17 @@ onBeforeUnmount(() => {
});
function init() {
state.status = TerminalStatus.NoConnected;
if (term) {
console.log('重新连接...');
close();
}
nextTick(() => {
initTerm();
initSocket();
});
}
function initTerm() {
async function initTerm() {
term = new Terminal({
fontSize: themeConfig.value.terminalFontSize || 15,
fontWeight: themeConfig.value.terminalFontWeight || 'normal',
@@ -124,16 +125,12 @@ function initTerm() {
state.addon.fit = fitAddon;
term.loadAddon(fitAddon);
fitTerminal();
// 注册窗口大小监听器
useEventListener('resize', debounce(fitTerminal, 400));
// 注册搜索组件
const searchAddon = new SearchAddon();
state.addon.search = searchAddon;
term.loadAddon(searchAddon);
// 注册 url link组件
const weblinks = new WebLinksAddon();
state.addon.weblinks = weblinks;
term.loadAddon(weblinks);
initSocket();
// 注册其他插件
loadAddon();
// 注册自定义快捷键
term.attachCustomKeyEventHandler((event: KeyboardEvent) => {
@@ -148,23 +145,18 @@ function initTerm() {
}
function initSocket() {
if (props.socketUrl) {
socket = new WebSocket(`${props.socketUrl}&rows=${term?.rows}&cols=${term?.cols}`);
if (!props.socketUrl) {
return;
}
socket = new WebSocket(`${props.socketUrl}&rows=${term?.rows}&cols=${term?.cols}`);
// 监听socket连接
socket.onopen = () => {
// 注册心跳
pingInterval = setInterval(sendPing, 15000);
state.status = TerminalStatus.Connected;
// 注册 terminal 事件
term.onResize((event) => sendResize(event.cols, event.rows));
term.onData((event) => sendCmd(event));
// // 注册窗口大小监听器
useEventListener('resize', debounce(fitTerminal, 400));
focus();
fitTerminal();
// 如果有初始要执行的命令,则发送执行命令
if (props.cmd) {
@@ -183,14 +175,62 @@ function initSocket() {
console.log('terminal socket close...', e.reason);
state.status = TerminalStatus.Disconnected;
};
// 监听socket消息
socket.onmessage = (msg: any) => {
// msg.data是真正后端返回的数据
term.write(msg.data);
};
}
function loadAddon() {
// 注册搜索组件
const searchAddon = new SearchAddon();
state.addon.search = searchAddon;
term.loadAddon(searchAddon);
// 注册 url link组件
const weblinks = new WebLinksAddon();
state.addon.weblinks = weblinks;
term.loadAddon(weblinks);
// 注册 trzsz
// initialize trzsz filter
const trzsz = new TrzszFilter({
// write the server output to the terminal
writeToTerminal: (data: any) => term.write(typeof data === 'string' ? data : new Uint8Array(data)),
// send the user input to the server
sendToServer: sendCmd,
// the terminal columns
terminalColumns: term.cols,
// there is a windows shell
isWindowsShell: false,
});
// let trzsz process the server output
socket?.addEventListener('message', (e) => trzsz.processServerOutput(e.data));
// let trzsz process the user input
term.onData((data) => trzsz.processTerminalInput(data));
term.onBinary((data) => trzsz.processBinaryInput(data));
term.onResize((size) => {
sendResize(size.cols, size.rows);
// tell trzsz the terminal columns has been changed
trzsz.setTerminalColumns(size.cols);
});
// enable drag files or directories to upload
terminalRef.value.addEventListener('dragover', (event: Event) => event.preventDefault());
terminalRef.value.addEventListener('drop', (event: any) => {
event.preventDefault();
trzsz
.uploadFiles(event.dataTransfer.items)
.then(() => console.log('upload success'))
.catch((err: any) => console.log(err));
});
}
// 写入内容至终端
const write2Term = (data: any) => {
term.write(data);
};
const writeln2Term = (data: any) => {
term.writeln(data);
};
const getTerminalTheme = () => {
const terminalTheme = themeConfig.value.terminalTheme;
// 如果不是自定义主题,则返回内置主题
@@ -229,7 +269,7 @@ enum MsgType {
}
const send = (msg: any) => {
state.status == TerminalStatus.Connected && socket.send(msg);
state.status == TerminalStatus.Connected && socket?.send(msg);
};
const sendResize = (cols: number, rows: number) => {
@@ -266,7 +306,7 @@ const getStatus = (): TerminalStatus => {
return state.status;
};
defineExpose({ init, fitTerminal, focus, clear, close, getStatus, sendResize });
defineExpose({ init, fitTerminal, focus, clear, close, getStatus, sendResize, write2Term, writeln2Term });
</script>
<style lang="scss">
#terminal-body {
@@ -276,9 +316,9 @@ defineExpose({ init, fitTerminal, focus, clear, close, getStatus, sendResize });
width: 100%;
height: 100%;
.xterm .xterm-viewport {
overflow-y: hidden;
}
// .xterm .xterm-viewport {
// overflow-y: hidden;
// }
}
}
</style>

View File

@@ -2,7 +2,7 @@
<div>
<div class="terminal-dialog-container" v-for="openTerminal of terminals" :key="openTerminal.terminalId">
<el-dialog
title="终端"
title="SSH终端"
v-model="openTerminal.visible"
top="32px"
class="terminal-dialog"
@@ -92,7 +92,7 @@
</template>
<script lang="ts" setup>
import { toRefs, reactive } from 'vue';
import { reactive, toRefs } from 'vue';
import TerminalBody from '@/components/terminal/TerminalBody.vue';
import SvgIcon from '@/components/svgIcon/index.vue';
import { TerminalStatus } from './common';

View File

@@ -0,0 +1,113 @@
<template>
<div>
<el-drawer v-model="visible" :before-close="cancel" size="50%">
<template #header>
<DrawerHeader :header="props.title" :back="cancel">
<template #extra>
<EnumTag :enums="LogTypeEnum" :value="log?.type" class="mr20" />
</template>
</DrawerHeader>
</template>
<el-descriptions class="mb10" :column="1" border v-if="extra">
<el-descriptions-item v-for="(value, key) in extra" :key="key" :span="1" :label="key">{{ value }}</el-descriptions-item>
</el-descriptions>
<TerminalBody class="mb10" ref="terminalRef" height="calc(100vh - 220px)" />
</el-drawer>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue';
import TerminalBody from './TerminalBody.vue';
import { logApi } from '../../views/system/api';
import { LogTypeEnum } from '@/views/system/enums';
import { useIntervalFn } from '@vueuse/core';
import EnumTag from '@/components/enumtag/EnumTag.vue';
const props = defineProps({
title: {
type: String,
default: '日志',
},
});
const visible = defineModel<boolean>('visible', { default: false });
const logId = defineModel<number>('logId', { default: 0 });
const terminalRef: any = ref(null);
const nowLine = ref(0);
const log = ref({}) as any;
const extra = computed(() => {
if (log.value?.extra) {
return JSON.parse(log.value.extra);
}
return null;
});
// 定时获取最新日志
const { pause, resume } = useIntervalFn(() => {
writeLog();
}, 500);
watch(
() => logId.value,
(logId: number) => {
terminalRef.value?.clear();
if (!logId) {
return;
}
writeLog();
}
);
const cancel = () => {
visible.value = false;
logId.value = 0;
nowLine.value = 0;
pause();
};
const writeLog = async () => {
const log = await getLog();
if (!log) {
return;
}
writeLog2Term(log);
// 如果不是还在执行中的日志,则暂停轮询
if (log.type != LogTypeEnum.Running.value) {
pause();
return;
}
resume();
};
const writeLog2Term = (log: any) => {
if (!log) {
return;
}
const lines = log.resp.split('\n');
for (let line of lines.slice(nowLine.value)) {
nowLine.value += 1;
terminalRef.value?.writeln2Term(line);
}
terminalRef.value?.focus();
};
const getLog = async () => {
if (!logId.value) {
return;
}
const logRes = await logApi.detail.request({
id: logId.value,
});
log.value = logRes;
return logRes;
};
</script>
<style lang="scss"></style>

View File

@@ -1,6 +1,15 @@
import EnumValue from '@/common/Enum';
export enum TerminalStatus {
Error = -1,
NoConnected = 0,
Connected = 1,
Disconnected = 2,
}
export const TerminalStatusEnum = {
Error: EnumValue.of(TerminalStatus.Error, '连接出错').setExtra({ iconColor: 'var(--el-color-error)' }),
NoConnected: EnumValue.of(TerminalStatus.NoConnected, '未连接').setExtra({ iconColor: 'var(--el-color-primary)' }),
Connected: EnumValue.of(TerminalStatus.Connected, '连接成功').setExtra({ iconColor: 'var(--el-color-success)' }),
Disconnected: EnumValue.of(TerminalStatus.Disconnected, '连接失败').setExtra({ iconColor: 'var(--el-color-error)' }),
};

View File

@@ -44,6 +44,7 @@ export const usePageTable = (
}
let res = await api.request(sp);
res.list = res.list || [];
dataCallBack && (res = await dataCallBack(res));
if (pageable) {

View File

@@ -1,5 +1,5 @@
import router from '@/router';
import { getClientId, getToken } from '@/common/utils/storage';
import { clearUser, getClientId, getRefreshToken, getToken, saveRefreshToken, saveToken } from '@/common/utils/storage';
import { templateResolve } from '@/common/utils/string';
import { ElMessage } from 'element-plus';
import { createFetch } from '@vueuse/core';
@@ -8,6 +8,7 @@ import { Result, ResultEnum } from '@/common/request';
import config from '@/common/config';
import { unref } from 'vue';
import { URL_401 } from '@/router/staticRouter';
import openApi from '@/common/openApi';
const baseUrl: string = config.baseApiUrl;
@@ -16,7 +17,7 @@ const useCustomFetch = createFetch({
combination: 'chain',
options: {
immediate: false,
timeout: 60000,
timeout: 600000,
// beforeFetch in pre-configured instance will only run when the newly spawned instance do not pass beforeFetch
async beforeFetch({ options }) {
const token = getToken();
@@ -41,7 +42,7 @@ const useCustomFetch = createFetch({
export function useApiFetch<T>(api: Api, params: any = null, reqOptions: RequestInit = {}) {
const uaf = useCustomFetch<T>(api.url, {
beforeFetch({ url, options }) {
async beforeFetch({ url, options }) {
options.method = api.method;
if (!params) {
return;
@@ -56,7 +57,7 @@ export function useApiFetch<T>(api: Api, params: any = null, reqOptions: Request
}
if (api.beforeHandler) {
paramsValue = api.beforeHandler(paramsValue);
paramsValue = await api.beforeHandler(paramsValue);
}
if (paramsValue) {
@@ -88,61 +89,104 @@ export function useApiFetch<T>(api: Api, params: any = null, reqOptions: Request
return {
execute: async function () {
try {
await uaf.execute(true);
} catch (e: any) {
const rejectPromise = Promise.reject(e);
if (e?.name == 'AbortError') {
console.log('请求已取消');
return rejectPromise;
}
const respStatus = uaf.response.value?.status;
if (respStatus == 404) {
ElMessage.error('请求接口不存在');
return rejectPromise;
}
if (respStatus == 500) {
ElMessage.error('服务器响应异常');
return rejectPromise;
}
console.error(e);
ElMessage.error('网络请求错误');
return rejectPromise;
}
const result: Result = uaf.data.value as any;
if (!result) {
ElMessage.error('网络请求失败');
return Promise.reject(result);
}
// 如果返回为成功结果则将结果的data赋值给响应式data
if (result.code === ResultEnum.SUCCESS) {
uaf.data.value = result.data;
return;
}
// 如果提示没有权限,则跳转至无权限页面
if (result.code === ResultEnum.NO_PERMISSION) {
router.push({
path: URL_401,
});
return Promise.reject(result);
}
// 如果返回的code不为成功则会返回对应的错误msg则直接统一通知即可。忽略登录超时或没有权限的提示直接跳转至401页面
if (result.msg && result?.code != ResultEnum.NO_PERMISSION) {
ElMessage.error(result.msg);
uaf.error.value = new Error(result.msg);
}
return Promise.reject(result);
return execUaf(uaf);
},
isFetching: uaf.isFetching,
data: uaf.data,
abort: uaf.abort,
};
}
let refreshingToken = false;
let queue: any[] = [];
async function execUaf(uaf: any) {
try {
await uaf.execute(true);
} catch (e: any) {
const rejectPromise = Promise.reject(e);
if (e?.name == 'AbortError') {
console.log('请求已取消');
return rejectPromise;
}
const respStatus = uaf.response.value?.status;
if (respStatus == 404) {
ElMessage.error('请求接口不存在');
return rejectPromise;
}
if (respStatus == 500) {
ElMessage.error('服务器响应异常');
return rejectPromise;
}
console.error(e);
ElMessage.error('网络请求错误');
return rejectPromise;
}
const result: Result = uaf.data.value as any;
if (!result) {
ElMessage.error('网络请求失败');
return Promise.reject(result);
}
const resultCode = result.code;
// 如果返回为成功结果则将结果的data赋值给响应式data
if (resultCode === ResultEnum.SUCCESS) {
uaf.data.value = result.data;
return;
}
// 如果是accessToken失效则使用refreshToken刷新token
if (resultCode == ResultEnum.ACCESS_TOKEN_INVALID) {
if (refreshingToken) {
// 请求加入队列等待, 防止并发多次请求refreshToken
return new Promise((resolve) => {
queue.push(() => {
resolve(execUaf(uaf));
});
});
}
try {
refreshingToken = true;
const res = await openApi.refreshToken({ refresh_token: getRefreshToken() });
saveToken(res.token);
saveRefreshToken(res.refresh_token);
// 重新缓存后端用户权限code
await openApi.getPermissions();
// 执行accessToken失效的请求
queue.forEach((resolve: any) => {
resolve();
});
} catch (e: any) {
clearUser();
} finally {
refreshingToken = false;
queue = [];
}
await execUaf(uaf);
return;
}
// 如果提示没有权限,则跳转至无权限页面
if (resultCode === ResultEnum.NO_PERMISSION) {
router.push({
path: URL_401,
});
return Promise.reject(result);
}
// 如果返回的code不为成功则会返回对应的错误msg则直接统一通知即可。忽略登录超时或没有权限的提示直接跳转至401页面
if (result.msg && resultCode != ResultEnum.NO_PERMISSION) {
ElMessage.error(result.msg);
uaf.error.value = new Error(result.msg);
}
return Promise.reject(result);
}

View File

@@ -1,20 +0,0 @@
import { saveThemeConfig } from '@/common/utils/storage';
import { isDark } from './user.vue';
export const switchDark = () => {
themeConfig.value.isDark = isDark.value;
if (isDark.value) {
themeConfig.value.editorTheme = 'vs-dark';
} else {
themeConfig.value.editorTheme = 'vs';
}
// 如果终端主题不是自定义主题,则切换主题
if (themeConfig.value.terminalTheme != 'custom') {
if (isDark.value) {
themeConfig.value.terminalTheme = 'dark';
} else {
themeConfig.value.terminalTheme = 'solarizedLight';
}
}
saveThemeConfig(themeConfig.value);
};

View File

@@ -89,13 +89,18 @@ type RouterConvCallbackFunc = (router: any) => void;
* @param meta.link ==> 外链地址
* */
export function backEndRouterConverter(routes: any, callbackFunc: RouterConvCallbackFunc = null as any, parentPath: string = '/') {
if (!routes) return [];
return routes.map((item: any) => {
if (!routes) {
return [];
}
const routeItems = [];
for (let item of routes) {
if (!item.meta) {
return item;
}
// 将json字符串的meta转为对象
item.meta = JSON.parse(item.meta);
// 将meta.comoponet 解析为route.component
if (item.meta.component) {
item.component = dynamicImport(dynamicViewsModules, item.meta.component);
@@ -126,8 +131,10 @@ export function backEndRouterConverter(routes: any, callbackFunc: RouterConvCall
// 存在回调,则执行回调
callbackFunc && callbackFunc(item);
item.children && backEndRouterConverter(item.children, callbackFunc, item.path);
return item;
});
routeItems.push(item);
}
return routeItems;
}
/**
@@ -152,6 +159,6 @@ export function dynamicImport(dynamicViewsModules: Record<string, Function>, com
return null;
}
console.error(`未匹配到[${component}]组件名对应的组件文件`);
console.warn(`未匹配到[${component}]组件名对应的组件文件`);
return null;
}

View File

@@ -92,7 +92,7 @@ router.beforeEach(async (to, from, next) => {
}
// 终端不需要连接系统websocket消息
if (to.path != '/machine/terminal') {
if (to.path != '/machine/terminal' && to.path != '/machine/terminal-rdp') {
syssocket.init();
}

View File

@@ -54,6 +54,17 @@ export const staticRoutes: Array<RouteRecordRaw> = [
titleRename: true,
},
},
{
path: '/machine/terminal-rdp',
name: 'machineTerminalRdp',
component: () => import('@/views/ops/machine/RdpTerminalPage.vue'),
meta: {
// 将路径 'xxx?name=名字' 里的name字段值替换到title里
title: '终端 | {name}',
// 是否根据query对标题名进行参数替换即最终显示为终端_机器名
titleRename: true,
},
},
];
// 错误页面路由

View File

@@ -0,0 +1,29 @@
import { defineStore } from 'pinia';
/**
* 自动打开资源
*/
export const useAutoOpenResource = defineStore('autoOpenResource', {
state: () => ({
autoOpenResource: {
machineCodePath: '',
dbCodePath: '',
redisCodePath: '',
mongoCodePath: '',
},
}),
actions: {
setMachineCodePath(codePath: string) {
this.autoOpenResource.machineCodePath = codePath;
},
setDbCodePath(codePath: string) {
this.autoOpenResource.dbCodePath = codePath;
},
setRedisCodePath(codePath: string) {
this.autoOpenResource.redisCodePath = codePath;
},
setMongoCodePath(codePath: string) {
this.autoOpenResource.mongoCodePath = codePath;
},
},
});

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia';
import { dateFormat2 } from '@/common/utils/date';
import { formatDate } from '@/common/utils/format';
import { useUserInfo } from '@/store/userInfo';
import { getSysStyleConfig } from '@/common/sysconfig';
import { getLocal, getThemeConfig } from '@/common/utils/storage';
@@ -114,7 +114,7 @@ export const useThemeConfig = defineStore('themeConfig', {
// 默认布局,可选 1、默认 defaults 2、经典 classic 3、横向 transverse 4、分栏 columns
layout: 'classic',
terminalTheme: 'solarizedLight',
terminalTheme: 'light',
// ssh终端字体颜色
terminalForeground: '#C5C8C6',
// ssh终端背景色
@@ -191,7 +191,7 @@ export const useThemeConfig = defineStore('themeConfig', {
},
// 设置水印时间为当前时间
setWatermarkNowTime() {
this.themeConfig.watermarkText[1] = dateFormat2('yyyy-MM-dd HH:mm:ss', new Date());
this.themeConfig.watermarkText[1] = formatDate(new Date());
},
// 切换暗黑模式
switchDark(isDark: boolean) {
@@ -207,7 +207,7 @@ export const useThemeConfig = defineStore('themeConfig', {
if (isDark) {
this.themeConfig.terminalTheme = 'dark';
} else {
this.themeConfig.terminalTheme = 'solarizedLight';
this.themeConfig.terminalTheme = 'light';
}
}
},

View File

@@ -1 +1 @@
@import 'common/transition.scss';
@use 'common/transition.scss';

View File

@@ -1,4 +1,4 @@
@import 'mixins/index.scss';
@use 'mixins/index' as mixins;
/* Button 按钮
------------------------------- */
@@ -97,7 +97,7 @@
.el-sub-menu .iconfont,
.el-menu-item .fa,
.el-sub-menu .fa {
@include generalIcon;
@include mixins.generalIcon;
}
// 水平菜单、横向菜单高亮 背景色,鼠标 hover 时,有子级菜单的背景色

View File

@@ -1,8 +1,8 @@
@import './app.scss';
@import './base.scss';
@import './other.scss';
@import './element.scss';
@import './media/media.scss';
@import './waves.scss';
@import './dark.scss';
@import './iconSelector.scss';
@use './app.scss';
@use './base.scss';
@use './other.scss';
@use './element.scss';
@use './media/media.scss';
@use './waves.scss';
@use './dark.scss';
@use './iconSelector.scss';

View File

@@ -1,94 +1,109 @@
@import './index.scss';
@use './index.scss' as index;
/* 页面宽度小于768px
------------------------------- */
@media screen and (max-width: $sm) {
.big-data-down-left {
width: 100% !important;
flex-direction: unset !important;
flex-wrap: wrap;
.flex-warp-item {
min-height: 196.24px;
padding: 0 7.5px 15px 15px !important;
.flex-warp-item-box {
border: none !important;
border-bottom: 1px solid #ebeef5 !important;
}
}
}
.big-data-down-center {
width: 100% !important;
.big-data-down-center-one,
.big-data-down-center-two {
min-height: 196.24px;
padding-left: 15px !important;
.big-data-down-center-one-content {
border: none !important;
border-bottom: 1px solid #ebeef5 !important;
}
.flex-warp-item-box {
@extend .big-data-down-center-one-content;
}
}
}
.big-data-down-right {
.flex-warp-item {
.flex-warp-item-box {
border: none !important;
border-bottom: 1px solid #ebeef5 !important;
}
&:nth-of-type(2) {
padding-left: 15px !important;
}
&:last-of-type {
.flex-warp-item-box {
border: none !important;
}
}
}
}
@media screen and (max-width: index.$sm) {
.big-data-down-left {
width: 100% !important;
flex-direction: unset !important;
flex-wrap: wrap;
.flex-warp-item {
min-height: 196.24px;
padding: 0 7.5px 15px 15px !important;
.flex-warp-item-box {
border: none !important;
border-bottom: 1px solid #ebeef5 !important;
}
}
}
.big-data-down-center {
width: 100% !important;
.big-data-down-center-one,
.big-data-down-center-two {
min-height: 196.24px;
padding-left: 15px !important;
.big-data-down-center-one-content {
border: none !important;
border-bottom: 1px solid #ebeef5 !important;
}
.flex-warp-item-box {
@extend .big-data-down-center-one-content;
}
}
}
.big-data-down-right {
.flex-warp-item {
.flex-warp-item-box {
border: none !important;
border-bottom: 1px solid #ebeef5 !important;
}
&:nth-of-type(2) {
padding-left: 15px !important;
}
&:last-of-type {
.flex-warp-item-box {
border: none !important;
}
}
}
}
}
/* 页面宽度大于768px小于1200px
------------------------------- */
@media screen and (min-width: $sm) and (max-width: $lg) {
.chart-warp-bottom {
.big-data-down-left {
width: 50% !important;
}
.big-data-down-center {
width: 50% !important;
}
.big-data-down-right {
.flex-warp-item {
width: 50% !important;
&:nth-of-type(2) {
padding-left: 7.5px !important;
}
}
}
}
@media screen and (min-width: index.$sm) and (max-width: index.$lg) {
.chart-warp-bottom {
.big-data-down-left {
width: 50% !important;
}
.big-data-down-center {
width: 50% !important;
}
.big-data-down-right {
.flex-warp-item {
width: 50% !important;
&:nth-of-type(2) {
padding-left: 7.5px !important;
}
}
}
}
}
/* 页面宽度小于1200px
------------------------------- */
@media screen and (max-width: $lg) {
.chart-warp-top {
.up-left {
display: none;
}
}
.chart-warp-bottom {
overflow-y: auto !important;
flex-wrap: wrap;
.big-data-down-right {
width: 100% !important;
flex-direction: unset !important;
flex-wrap: wrap;
.flex-warp-item {
min-height: 196.24px;
padding: 0 7.5px 15px 15px !important;
}
}
}
}
@media screen and (max-width: index.$lg) {
.chart-warp-top {
.up-left {
display: none;
}
}
.chart-warp-bottom {
overflow-y: auto !important;
flex-wrap: wrap;
.big-data-down-right {
width: 100% !important;
flex-direction: unset !important;
flex-wrap: wrap;
.flex-warp-item {
min-height: 196.24px;
padding: 0 7.5px 15px 15px !important;
}
}
}
}

View File

@@ -1,10 +1,10 @@
@import './index.scss';
@use './index.scss' as index;
/* 页面宽度小于576px
------------------------------- */
@media screen and (max-width: $xs) {
.el-cascader__dropdown.el-popper {
overflow: auto;
max-width: 100%;
}
}
@media screen and (max-width: index.$xs) {
.el-cascader__dropdown.el-popper {
overflow: auto;
max-width: 100%;
}
}

View File

@@ -1,12 +1,13 @@
@import './index.scss';
@use './index.scss';
/* 页面宽度小于800px
------------------------------- */
@media screen and (max-width: 800px) {
.el-dialog {
width: 90% !important;
}
.el-dialog.is-fullscreen {
width: 100% !important;
}
}
.el-dialog {
width: 90% !important;
}
.el-dialog.is-fullscreen {
width: 100% !important;
}
}

View File

@@ -1,35 +1,38 @@
@import './index.scss';
@use './index.scss' as index;
/* 页面宽度小于768px
------------------------------- */
@media screen and (max-width: $sm) {
.error {
.error-flex {
flex-direction: column-reverse !important;
height: auto !important;
width: 100% !important;
}
.right,
.left {
flex: unset !important;
display: flex !important;
}
.left-item {
margin: auto !important;
}
.right img {
max-width: 450px !important;
@extend .left-item;
}
}
@media screen and (max-width: index.$sm) {
.error {
.error-flex {
flex-direction: column-reverse !important;
height: auto !important;
width: 100% !important;
}
.right,
.left {
flex: unset !important;
display: flex !important;
}
.left-item {
margin: auto !important;
}
.right img {
max-width: 450px !important;
@extend .left-item;
}
}
}
/* 页面宽度大于768px小于992px
------------------------------- */
@media screen and (min-width: $sm) and (max-width: $md) {
.error {
.error-flex {
padding-left: 30px !important;
}
}
}
@media screen and (min-width: index.$sm) and (max-width: index.$md) {
.error {
.error-flex {
padding-left: 30px !important;
}
}
}

View File

@@ -1,13 +1,14 @@
@import './index.scss';
@use './index.scss' as index;
/* 页面宽度小于576px
------------------------------- */
@media screen and (max-width: $xs) {
.el-form-item__label {
width: 100% !important;
text-align: left !important;
}
.el-form-item__content {
margin-left: 0 !important;
}
}
@media screen and (max-width: index.$xs) {
.el-form-item__label {
width: 100% !important;
text-align: left !important;
}
.el-form-item__content {
margin-left: 0 !important;
}
}

View File

@@ -1,10 +1,11 @@
@import './index.scss';
@use './index.scss' as index;
/* 页面宽度小于768px
------------------------------- */
@media screen and (max-width: $sm) {
.home-warning-media,
.home-dynamic-media {
margin-top: 15px;
}
}
@media screen and (max-width: index.$sm) {
.home-warning-media,
.home-dynamic-media {
margin-top: 15px;
}
}

View File

@@ -1,55 +1,61 @@
@import './index.scss';
@use './index.scss' as index;
/* 页面宽度小于576px
------------------------------- */
@media screen and (max-width: $xs) {
// MessageBox 弹框
.el-message-box {
width: 80% !important;
}
@media screen and (max-width: index.$xs) {
// MessageBox 弹框
.el-message-box {
width: 80% !important;
}
}
/* 页面宽度小于768px
------------------------------- */
@media screen and (max-width: $sm) {
// Breadcrumb 面包屑
.layout-navbars-breadcrumb-hide {
display: none;
}
// 外链视图
.layout-view-link {
a {
max-width: 80%;
text-align: center;
}
}
// 菜单搜索
.layout-search-dialog {
.el-autocomplete {
width: 80% !important;
}
}
@media screen and (max-width: index.$sm) {
// Breadcrumb 面包屑
.layout-navbars-breadcrumb-hide {
display: none;
}
// 外链视图
.layout-view-link {
a {
max-width: 80%;
text-align: center;
}
}
// 菜单搜索
.layout-search-dialog {
.el-autocomplete {
width: 80% !important;
}
}
}
/* 页面宽度小于1000px
------------------------------- */
@media screen and (max-width: 1000px) {
// 布局配置
.layout-drawer-content-flex {
position: relative;
&::after {
content: '手机版不支持切换布局';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
text-align: center;
height: 140px;
line-height: 140px;
background: rgba(255, 255, 255, 0.9);
color: #666666;
}
}
}
// 布局配置
.layout-drawer-content-flex {
position: relative;
&::after {
content: '手机版不支持切换布局';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
text-align: center;
height: 140px;
line-height: 140px;
background: rgba(255, 255, 255, 0.9);
color: #666666;
}
}
}

View File

@@ -1,21 +1,23 @@
@import './index.scss';
@use './index.scss' as index;
/* 页面宽度小于576px
------------------------------- */
@media screen and (max-width: $xs) {
.login-container {
.login-content {
width: 90% !important;
padding: 20px 0 !important;
}
.login-content-form-btn {
width: 100% !important;
padding: 12px 0 !important;
}
.login-copyright {
.login-copyright-msg {
white-space: unset !important;
}
}
}
}
@media screen and (max-width: index.$xs) {
.login-container {
.login-content {
width: 90% !important;
padding: 20px 0 !important;
}
.login-content-form-btn {
width: 100% !important;
padding: 12px 0 !important;
}
.login-copyright {
.login-copyright-msg {
white-space: unset !important;
}
}
}
}

View File

@@ -1,12 +1,12 @@
@import './login.scss';
@import './error.scss';
@import './layout.scss';
@import './personal.scss';
@import './tagsView.scss';
@import './home.scss';
@import './chart.scss';
@import './form.scss';
@import './scrollbar.scss';
@import './pagination.scss';
@import './dialog.scss';
@import './cityLinkage.scss';
@use './login.scss';
@use './error.scss';
@use './layout.scss';
@use './personal.scss';
@use './tagsView.scss';
@use './home.scss';
@use './chart.scss';
@use './form.scss';
@use './scrollbar.scss';
@use './pagination.scss';
@use './dialog.scss';
@use './cityLinkage.scss';

View File

@@ -1,15 +1,16 @@
@import './index.scss';
@use './index.scss' as index;
/* 页面宽度小于576px
------------------------------- */
@media screen and (max-width: $xs) {
.el-pager,
.el-pagination__jump {
display: none !important;
}
@media screen and (max-width: index.$xs) {
.el-pager,
.el-pagination__jump {
display: none !important;
}
}
// 默认居中对齐
.el-pagination {
text-align: center !important;
}
text-align: center !important;
}

View File

@@ -1,16 +1,18 @@
@import './index.scss';
@use './index.scss' as index;
/* 页面宽度小于768px
------------------------------- */
@media screen and (max-width: $sm) {
.personal-info {
padding-left: 0 !important;
margin-top: 15px;
}
.personal-recommend-col {
margin-bottom: 15px;
&:last-of-type {
margin-bottom: 0;
}
}
}
@media screen and (max-width: index.$sm) {
.personal-info {
padding-left: 0 !important;
margin-top: 15px;
}
.personal-recommend-col {
margin-bottom: 15px;
&:last-of-type {
margin-bottom: 0;
}
}
}

View File

@@ -1,56 +1,66 @@
@import './index.scss';
@use './index.scss' as index;
/* 页面宽度小于768px
------------------------------- */
@media screen and (max-width: $sm) {
// 滚动条的宽度
::-webkit-scrollbar {
width: 3px !important;
height: 3px !important;
}
::-webkit-scrollbar-track-piece {
background-color: var(--bg-main-color);
}
// 滚动条的设置
::-webkit-scrollbar-thumb {
background-color: rgba(144, 147, 153, 0.3);
background-clip: padding-box;
min-height: 28px;
border-radius: 5px;
transition: 0.3s background-color;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(144, 147, 153, 0.5);
}
// element plus scrollbar
.el-scrollbar__bar.is-vertical {
width: 2px !important;
}
.el-scrollbar__bar.is-horizontal {
height: 2px !important;
}
@media screen and (max-width: index.$sm) {
// 滚动条的宽度
::-webkit-scrollbar {
width: 3px !important;
height: 3px !important;
}
::-webkit-scrollbar-track-piece {
background-color: var(--bg-main-color);
}
// 滚动条的设置
::-webkit-scrollbar-thumb {
background-color: rgba(144, 147, 153, 0.3);
background-clip: padding-box;
min-height: 28px;
border-radius: 5px;
transition: 0.3s background-color;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(144, 147, 153, 0.5);
}
// element plus scrollbar
.el-scrollbar__bar.is-vertical {
width: 2px !important;
}
.el-scrollbar__bar.is-horizontal {
height: 2px !important;
}
}
/* 页面宽度大于768px
------------------------------- */
@media screen and (min-width: 769px) {
// 滚动条的宽度
::-webkit-scrollbar {
width: 7px;
height: 7px;
}
::-webkit-scrollbar-track-piece {
background-color: var(--bg-main-color);
}
// 滚动条的设置
::-webkit-scrollbar-thumb {
background-color: rgba(144, 147, 153, 0.3);
background-clip: padding-box;
min-height: 28px;
border-radius: 5px;
transition: 0.3s background-color;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(144, 147, 153, 0.5);
}
// 滚动条的宽度
::-webkit-scrollbar {
width: 7px;
height: 7px;
}
::-webkit-scrollbar-track-piece {
background-color: var(--bg-main-color);
}
// 滚动条的设置
::-webkit-scrollbar-thumb {
background-color: rgba(144, 147, 153, 0.3);
background-clip: padding-box;
min-height: 28px;
border-radius: 5px;
transition: 0.3s background-color;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(144, 147, 153, 0.5);
}
}

View File

@@ -1,11 +1,11 @@
@import './index.scss';
@use './index.scss' as index;
/* 页面宽度小于768px
------------------------------- */
@media screen and (max-width: $sm) {
.tags-view-form {
.tags-view-form-col {
margin-bottom: 20px;
}
}
}
@media screen and (max-width: index.$sm) {
.tags-view-form {
.tags-view-form-col {
margin-bottom: 20px;
}
}
}

View File

@@ -1,32 +1,32 @@
/* 第三方图标字体间距/大小设置
------------------------------- */
@mixin generalIcon {
font-size: 14px !important;
display: inline-block;
vertical-align: middle;
margin-right: 5px;
width: 24px;
text-align: center;
justify-content: center;
font-size: 14px !important;
display: inline-block;
vertical-align: middle;
margin-right: 5px;
width: 24px;
text-align: center;
justify-content: center;
}
/* 文本不换行
------------------------------- */
@mixin text-no-wrap() {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
/* 多行文本溢出
------------------------------- */
@mixin text-ellipsis($line: 2) {
overflow: hidden;
word-break: break-all;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: $line;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: $line;
-webkit-box-orient: vertical;
}
/* 滚动条(页面未使用) div 中使用:
@@ -35,22 +35,26 @@
// @include scrollBar;
// }
@mixin scrollBar {
// 滚动条凹槽的颜色,还可以设置边框属性
&::-webkit-scrollbar-track-piece {
background-color: #f8f8f8;
}
// 滚动条的宽度
&::-webkit-scrollbar {
width: 9px;
height: 9px;
}
// 滚动条的设置
&::-webkit-scrollbar-thumb {
background-color: #dddddd;
background-clip: padding-box;
min-height: 28px;
}
&::-webkit-scrollbar-thumb:hover {
background-color: #bbb;
}
// 滚动条凹槽的颜色,还可以设置边框属性
&::-webkit-scrollbar-track-piece {
background-color: #f8f8f8;
}
// 滚动条的宽度
&::-webkit-scrollbar {
width: 9px;
height: 9px;
}
// 滚动条的设置
&::-webkit-scrollbar-thumb {
background-color: #dddddd;
background-clip: padding-box;
min-height: 28px;
}
&::-webkit-scrollbar-thumb:hover {
background-color: #bbb;
}
}

View File

@@ -1,28 +1,31 @@
/* wangeditor富文本编辑器
------------------------------- */
.w-e-toolbar {
border: 1px solid #ebeef5 !important;
border-bottom: 1px solid #ebeef5 !important;
border-top-left-radius: 3px;
border-top-right-radius: 3px;
z-index: 2 !important;
border: 1px solid #ebeef5 !important;
border-bottom: 1px solid #ebeef5 !important;
border-top-left-radius: 3px;
border-top-right-radius: 3px;
z-index: 2 !important;
}
.w-e-text-container {
border: 1px solid #ebeef5 !important;
border-top: none !important;
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
z-index: 1 !important;
border: 1px solid #ebeef5 !important;
border-top: none !important;
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
z-index: 1 !important;
}
/* web端自定义截屏
------------------------------- */
#screenShotContainer {
z-index: 9998 !important;
z-index: 9998 !important;
}
#toolPanel {
height: 42px !important;
height: 42px !important;
}
#optionPanel {
height: 37px !important;
}
height: 37px !important;
}

View File

@@ -1,6 +1,6 @@
// 申明外部 npm 插件模块
declare module 'sql-formatter';
declare module 'jsoneditor';
declare module 'asciinema-player';
declare module 'vue-grid-layout';
declare module 'splitpanes';
declare module 'uuid';

View File

@@ -0,0 +1,149 @@
<template>
<div>
<el-drawer :title="props.title" v-model="visible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="50%">
<template #header>
<DrawerHeader :header="title" :back="cancel" />
</template>
<el-form :model="form" ref="formRef" :rules="rules" label-width="auto">
<el-form-item prop="bizType" label="业务类型">
<EnumSelect v-model="form.bizType" :enums="FlowBizType" placeholder="请选择业务类型" />
</el-form-item>
<el-form-item prop="remark" label="备注">
<el-input v-model.trim="form.remark" type="textarea" placeholder="备注" auto-complete="off" clearable></el-input>
</el-form-item>
<el-divider content-position="left">业务信息</el-divider>
<component
ref="bizFormRef"
v-if="form.bizType"
:is="bizComponents[form.bizType]"
v-model:bizForm="form.bizForm"
@changeResourceCode="changeResourceCode"
>
</component>
</el-form>
<span v-if="flowProcdef || !state.form.procdefId">
<el-divider content-position="left">审批节点</el-divider>
<ProcdefTasks v-if="flowProcdef" :procdef="flowProcdef" />
<el-result v-if="!state.form.procdefId" icon="error" title="不存在审批节点" sub-title="该资源无需审批操作"> </el-result>
</span>
<template #footer>
<div>
<el-button @click="cancel()"> </el-button>
<el-button type="primary" :loading="saveBtnLoading" @click="btnOk" :disabled="!state.form.procdefId"> </el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script lang="ts" setup>
import { toRefs, reactive, defineAsyncComponent, shallowReactive, useTemplateRef } from 'vue';
import { procdefApi, procinstApi } from './api';
import { ElMessage } from 'element-plus';
import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue';
import { FlowBizType } from './enums';
import EnumSelect from '@/components/enumselect/EnumSelect.vue';
import ProcdefTasks from './components/ProcdefTasks.vue';
import RedisRunCmdFlowBizForm from './flowbiz/redis/RedisRunCmdFlowBizForm.vue';
const DbSqlExecFlowBizForm = defineAsyncComponent(() => import('./flowbiz/dbms/DbSqlExecFlowBizForm.vue'));
const props = defineProps({
title: {
type: String,
},
});
const visible = defineModel<boolean>('visible', { default: false });
//定义事件
const emit = defineEmits(['cancel', 'val-change']);
const formRef: any = useTemplateRef('formRef');
const bizFormRef: any = useTemplateRef('bizFormRef');
// 业务组件
const bizComponents: any = shallowReactive({
db_sql_exec_flow: DbSqlExecFlowBizForm,
redis_run_cmd_flow: RedisRunCmdFlowBizForm,
});
const rules = {
bizType: [
{
required: true,
message: '请选择流程业务类型',
trigger: ['change', 'blur'],
},
],
remark: [
{
required: true,
message: '请输入申请备注',
trigger: ['change', 'blur'],
},
],
};
const defaultForm = {
bizType: FlowBizType.DbSqlExec.value,
procdefId: -1,
status: null,
remark: '',
bizForm: {},
};
const state = reactive({
tasks: [] as any,
form: { ...defaultForm },
flowProcdef: null as any,
sortable: '' as any,
});
const { form, flowProcdef } = toRefs(state);
const { isFetching: saveBtnLoading, execute: procinstStart } = procinstApi.start.useApi(form);
const changeResourceCode = async (resourceType: any, code: string) => {
state.flowProcdef = await procdefApi.getByResource.request({ resourceType, resourceCode: code });
if (!state.flowProcdef) {
state.form.procdefId = 0;
} else {
state.form.procdefId = state.flowProcdef.id;
}
};
const btnOk = async () => {
try {
await formRef.value.validate();
await bizFormRef.value.validateBizForm();
} catch (e: any) {
ElMessage.error('请正确填写信息');
return false;
}
await procinstStart();
ElMessage.success('流程发起成功');
emit('val-change', state.form);
//重置表单域
cancel();
};
const cancel = () => {
visible.value = false;
emit('cancel');
state.flowProcdef = null;
formRef.value.resetFields();
bizFormRef.value.resetBizForm();
state.form = { ...defaultForm };
};
</script>
<style lang="scss"></style>

View File

@@ -1,6 +1,6 @@
<template>
<div>
<el-drawer @open="initSort" :title="title" v-model="visible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false">
<el-drawer @open="initSort" :title="title" v-model="visible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<template #header>
<DrawerHeader :header="title" :back="cancel" />
</template>
@@ -17,10 +17,33 @@
<el-option v-for="item in ProcdefStatus" :key="item.value" :label="item.label" :value="item.value"> </el-option>
</el-select>
</el-form-item>
<el-form-item prop="condition" label="触发条件">
<template #label>
触发条件
<el-tooltip content="go template语法。若输出结果为1则表示触发该审批流程" placement="top">
<el-icon>
<question-filled />
</el-icon>
</el-tooltip>
</template>
<el-input
v-model="form.condition"
:rows="10"
type="textarea"
placeholder="触发条件, 返回值=1, 则表示触发该审批流程"
auto-complete="off"
clearable
></el-input>
</el-form-item>
<el-form-item prop="remark" label="备注">
<el-input v-model.trim="form.remark" placeholder="备注" auto-complete="off" clearable></el-input>
</el-form-item>
<el-form-item ref="tagSelectRef" prop="codePaths" label="关联资源">
<tag-tree-check height="300px" v-model="form.codePaths" :tag-type="[TagResourceTypeEnum.DbName.value, TagResourceTypeEnum.Redis.value]" />
</el-form-item>
<el-divider content-position="left">审批节点</el-divider>
<el-table ref="taskTableRef" :data="tasks" row-key="taskKey" stripe style="width: 100%">
@@ -70,6 +93,8 @@ import AccountSelectFormItem from '@/views/system/account/components/AccountSele
import Sortable from 'sortablejs';
import { randomUuid } from '../../common/utils/string';
import { ProcdefStatus } from './enums';
import TagTreeCheck from '../ops/component/TagTreeCheck.vue';
import { TagResourceTypeEnum } from '@/common/commonEnum';
const props = defineProps({
data: {
@@ -112,9 +137,11 @@ const state = reactive({
name: null,
defKey: null,
status: null,
condition: '',
remark: null,
// 流程的审批节点任务
tasks: '',
codePaths: [],
},
sortable: '' as any,
});
@@ -126,6 +153,7 @@ const { isFetching: saveBtnLoading, execute: saveFlowDefExec } = procdefApi.save
watch(props, (newValue: any) => {
if (newValue.data) {
state.form = { ...newValue.data };
state.form.codePaths = newValue.data.tags?.map((tag: any) => tag.codePath);
const tasks = JSON.parse(state.form.tasks);
tasks.forEach((t: any) => {
t.userId = Number.parseInt(t.userId);
@@ -133,6 +161,23 @@ watch(props, (newValue: any) => {
state.tasks = tasks;
} else {
state.form = { status: ProcdefStatus.Enable.value } as any;
state.form.condition = `{{/* DBMS-执行sql规则; param参数描述如下 */}}
{{/* stmtType: select / read / insert / update / delete / ddl ; */}}
{{ if eq .bizType "db_sql_exec_flow"}}
{{/* 不是select和read语句时开启流程审批 */}}
{{ if and (ne .param.stmtType "select") (ne .param.stmtType "read") }}
1
{{ end }}
{{ end }}
{{/* Redis-执行命令规则; param参数描述如下 */}}
{{/* cmdType: read(读命令) / write(写命令); */}}
{{/* cmd: get/set/hset...等 */}}
{{ if eq .bizType "redis_run_cmd_flow"}}
{{ if eq .param.cmdType "write" }}
1
{{ end }}
{{ end }}`;
state.tasks = [];
}
});
@@ -160,25 +205,26 @@ const deleteTask = (idx: any) => {
};
const btnOk = async () => {
formRef.value.validate(async (valid: boolean) => {
if (!valid) {
ElMessage.error('表单填写有误');
return false;
}
const checkRes = checkTasks();
if (checkRes.err) {
ElMessage.error(checkRes.err);
return false;
}
try {
await formRef.value.validate();
} catch (e: any) {
ElMessage.error('请正确填写信息');
return false;
}
state.form.tasks = JSON.stringify(checkRes.tasks);
await saveFlowDefExec();
ElMessage.success('操作成功');
emit('val-change', state.form);
//重置表单域
formRef.value.resetFields();
state.form = {} as any;
});
const checkRes = checkTasks();
if (checkRes.err) {
ElMessage.error(checkRes.err);
return false;
}
state.form.tasks = JSON.stringify(checkRes.tasks);
await saveFlowDefExec();
ElMessage.success('操作成功');
emit('val-change', state.form);
//重置表单域
formRef.value.resetFields();
state.form = {} as any;
};
const checkTasks = () => {

View File

@@ -18,6 +18,10 @@
<el-link @click="showProcdefTasks(data)" icon="view" type="primary" :underline="false"> </el-link>
</template>
<template #codePaths="{ data }">
<TagCodePath :path="data.tags?.map((tag: any) => tag.codePath)" />
</template>
<template #action="{ data }">
<el-button link v-if="actionBtns[perms.save]" @click="editFlowDef(data)" type="primary">编辑</el-button>
</template>
@@ -42,6 +46,7 @@ import { SearchItem } from '@/components/SearchForm';
import ProcdefEdit from './ProcdefEdit.vue';
import ProcdefTasks from './components/ProcdefTasks.vue';
import { ProcdefStatus } from './enums';
import TagCodePath from '../ops/component/TagCodePath.vue';
const perms = {
save: 'flow:procdef:save',
@@ -55,12 +60,13 @@ const columns = [
TableColumn.new('status', '状态').typeTag(ProcdefStatus),
TableColumn.new('remark', '备注'),
TableColumn.new('tasks', '审批节点').isSlot().alignCenter().setMinWidth(60),
TableColumn.new('codePaths', '关联资源').isSlot().setMinWidth('250px'),
TableColumn.new('creator', '创建账号'),
TableColumn.new('createTime', '创建时间').isTime(),
];
// 该用户拥有的的操作列按钮权限
const actionBtns = hasPerms([perms.save, perms.del]);
const actionBtns: any = hasPerms([perms.save, perms.del]);
const actionColumn = TableColumn.new('action', '操作').isSlot().fixedRight().setMinWidth(160).noShowOverflowTooltip().alignCenter();
const pageTableRef: Ref<any> = ref(null);

View File

@@ -1,28 +1,20 @@
<template>
<div>
<el-drawer :title="props.title" v-model="visible" :before-close="cancel" size="40%" :close-on-click-modal="!props.instTaskId">
<el-drawer :title="props.title" v-model="visible" :before-close="cancel" size="50%" :close-on-click-modal="!props.instTaskId">
<template #header>
<DrawerHeader :header="title" :back="cancel" />
</template>
<div>
<el-divider content-position="left">流程信息</el-divider>
<el-descriptions :column="2" border>
<el-descriptions :column="3" border>
<el-descriptions-item label="流程名">{{ procinst.procdefName }}</el-descriptions-item>
<el-descriptions-item label="业务">
<enum-tag :enums="FlowBizType" :value="procinst.bizType"></enum-tag>
</el-descriptions-item>
<el-descriptions-item label="发起人">
<AccountInfo :account-id="procinst.creatorId" :username="procinst.creator" />
<!-- {{ procinst.creator }} -->
</el-descriptions-item>
<el-descriptions-item label="发起时间">{{ dateFormat(procinst.createTime) }}</el-descriptions-item>
<div v-if="procinst.duration">
<el-descriptions-item label="持续时间">{{ formatTime(procinst.duration) }}</el-descriptions-item>
<el-descriptions-item label="结束时间">{{ dateFormat(procinst.endTime) }}</el-descriptions-item>
</div>
<el-descriptions-item label="流程状态">
<enum-tag :enums="ProcinstStatus" :value="procinst.status"></enum-tag>
@@ -31,6 +23,13 @@
<enum-tag :enums="ProcinstBizStatus" :value="procinst.bizStatus"></enum-tag>
</el-descriptions-item>
<el-descriptions-item label="发起时间">{{ formatDate(procinst.createTime) }}</el-descriptions-item>
<div v-if="procinst.duration">
<el-descriptions-item label="结束时间">{{ formatDate(procinst.endTime) }}</el-descriptions-item>
<el-descriptions-item label="持续时间">{{ formatTime(procinst.duration) }}</el-descriptions-item>
</div>
<el-descriptions-item label="备注">
{{ procinst.remark }}
</el-descriptions-item>
@@ -44,14 +43,7 @@
<div>
<el-divider content-position="left">业务信息</el-divider>
<component
v-if="procinst.bizType"
ref="keyValueRef"
:is="bizComponents[procinst.bizType]"
:biz-key="procinst.bizKey"
:biz-form="procinst.bizForm"
>
</component>
<component v-if="procinst.bizType" ref="keyValueRef" :is="bizComponents[procinst.bizType]" :procinst="procinst"> </component>
</div>
<div v-if="props.instTaskId">
@@ -86,14 +78,14 @@ import { procinstApi } from './api';
import { ElMessage } from 'element-plus';
import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue';
import { FlowBizType, ProcinstBizStatus, ProcinstTaskStatus, ProcinstStatus } from './enums';
import { dateFormat } from '@/common/utils/date';
import ProcdefTasks from './components/ProcdefTasks.vue';
import { formatTime } from '@/common/utils/format';
import EnumTag from '@/components/enumtag/EnumTag.vue';
import AccountInfo from '@/views/system/account/components/AccountInfo.vue';
import { formatDate } from '@/common/utils/format';
const DbSqlExecBiz = defineAsyncComponent(() => import('./flowbiz/DbSqlExecBiz.vue'));
const RedisRunWriteCmdBiz = defineAsyncComponent(() => import('./flowbiz/RedisRunWriteCmdBiz.vue'));
const DbSqlExecBiz = defineAsyncComponent(() => import('./flowbiz/dbms/DbSqlExecBiz.vue'));
const RedisRunCmdBiz = defineAsyncComponent(() => import('./flowbiz/redis/RedisRunCmdBiz.vue'));
const props = defineProps({
procinstId: {
@@ -114,9 +106,9 @@ const visible = defineModel<boolean>('visible', { default: false });
const emit = defineEmits(['cancel', 'val-change']);
// 业务组件
const bizComponents = shallowReactive({
const bizComponents: any = shallowReactive({
db_sql_exec_flow: DbSqlExecBiz,
redis_run_write_cmd_flow: RedisRunWriteCmdBiz,
redis_run_cmd_flow: RedisRunCmdBiz,
});
const state = reactive({

View File

@@ -9,7 +9,7 @@
:columns="columns"
>
<template #tableHeader>
<!-- <el-button v-auth="perms.addAccount" type="primary" icon="plus" @click="editFlowDef(false)">添加</el-button> -->
<el-button type="primary" icon="plus" @click="startProcInst()">发起流程</el-button>
</template>
<template #action="{ data }">
@@ -36,6 +36,8 @@
@val-change="valChange()"
@cancel="procinstDetail.procinstId = 0"
/>
<ProcInstEdit v-model:visible="procinstEdit.visible" :title="procinstEdit.title" @val-change="search" />
</div>
</template>
@@ -49,12 +51,19 @@ import ProcinstDetail from './ProcinstDetail.vue';
import { FlowBizType, ProcinstBizStatus, ProcinstStatus } from './enums';
import { ElMessage } from 'element-plus';
import { formatTime } from '@/common/utils/format';
import ProcInstEdit from './ProcInstEdit.vue';
const searchItems = [
SearchItem.select('status', '流程状态').withEnum(ProcinstStatus),
SearchItem.select('bizType', '业务类型').withEnum(FlowBizType),
SearchItem.input('bizKey', '业务key'),
];
const searchItems = [SearchItem.select('status', '流程状态').withEnum(ProcinstStatus), SearchItem.select('bizType', '业务类型').withEnum(FlowBizType)];
const columns = [
TableColumn.new('bizType', '业务').typeTag(FlowBizType),
TableColumn.new('remark', '备注'),
TableColumn.new('creator', '发起人'),
TableColumn.new('bizKey', '业务key'),
TableColumn.new('procdefName', '流程名'),
TableColumn.new('status', '流程状态').typeTag(ProcinstStatus),
TableColumn.new('bizStatus', '业务状态').typeTag(ProcinstBizStatus),
@@ -67,7 +76,7 @@ const columns = [
}
return formatTime(duration);
}),
TableColumn.new('bizHandleRes', '业务处理结果'),
// TableColumn.new('bizHandleRes', '业务处理结果'),
TableColumn.new('action', '操作').isSlot().fixedRight().setMinWidth(160).noShowOverflowTooltip().alignCenter(),
];
@@ -92,9 +101,13 @@ const state = reactive({
procinstId: 0,
instTaskId: 0,
},
procinstEdit: {
title: '发起流程',
visible: false,
},
});
const { selectionData, query, procinstDetail } = toRefs(state);
const { selectionData, query, procinstDetail, procinstEdit } = toRefs(state);
const search = async () => {
pageTableRef.value.search();
@@ -112,6 +125,10 @@ const showProcinst = (data: any) => {
state.procinstDetail.visible = true;
};
const startProcInst = () => {
state.procinstEdit.visible = true;
};
const valChange = () => {
state.procinstDetail.visible = false;
search();

View File

@@ -46,6 +46,7 @@ const columns = [
TableColumn.new('procinst.creator', '发起人'),
TableColumn.new('procinst.status', '流程状态').typeTag(ProcinstStatus),
TableColumn.new('status', '任务状态').typeTag(ProcinstTaskStatus),
TableColumn.new('procinst.bizKey', '业务key'),
TableColumn.new('procinst.procdefName', '流程名'),
TableColumn.new('taskName', '当前节点'),
TableColumn.new('procinst.createTime', '发起时间').isTime(),

View File

@@ -2,13 +2,14 @@ import Api from '@/common/Api';
export const procdefApi = {
list: Api.newGet('/flow/procdefs'),
getByKey: Api.newGet('/flow/procdefs/{key}'),
getByResource: Api.newGet('/flow/procdefs/{resourceType}/{resourceCode}'),
save: Api.newPost('/flow/procdefs'),
del: Api.newDelete('/flow/procdefs/{id}'),
};
export const procinstApi = {
list: Api.newGet('/flow/procinsts'),
start: Api.newPost('/flow/procinsts/start'),
detail: Api.newGet('/flow/procinsts/{id}'),
cancel: Api.newPost('/flow/procinsts/{id}/cancel'),
tasks: Api.newGet('/flow/procinsts/tasks'),

View File

@@ -3,7 +3,7 @@
<el-step v-for="task in tasksArr" :status="getStepStatus(task)" :title="task.name" :key="task.taskKey">
<template #description>
<div>{{ `${task.accountUsername}(${task.accountName})` }}</div>
<div v-if="task.completeTime">{{ `${dateFormat(task.completeTime)}` }}</div>
<div v-if="task.completeTime">{{ `${formatDate(task.completeTime)}` }}</div>
<div v-if="task.remark">{{ task.remark }}</div>
</template>
</el-step>
@@ -14,8 +14,7 @@
import { toRefs, reactive, watch, onMounted } from 'vue';
import { accountApi } from '../../system/api';
import { ProcinstTaskStatus } from '../enums';
import { dateFormat } from '@/common/utils/date';
import { procdefApi } from '../api';
import { formatDate } from '@/common/utils/format';
import { ElSteps, ElStep } from 'element-plus';
const props = defineProps({
@@ -23,8 +22,8 @@ const props = defineProps({
tasks: {
type: [String, Object],
},
procdefKey: {
type: String,
procdef: {
type: [Object],
},
// 流程实例任务列表
procinstTasks: {
@@ -54,7 +53,7 @@ watch(
);
watch(
() => props.procdefKey,
() => props.procdef,
async (newValue: any) => {
if (newValue) {
parseTasksByKey(newValue);
@@ -63,15 +62,14 @@ watch(
);
onMounted(() => {
if (props.procdefKey) {
parseTasksByKey(props.procdefKey);
if (props.procdef) {
parseTasksByKey(props.procdef);
return;
}
parseTasks(props.tasks);
});
const parseTasksByKey = async (key: string) => {
const procdef = await procdefApi.getByKey.request({ key });
const parseTasksByKey = async (procdef: any) => {
parseTasks(procdef.tasks);
};

View File

@@ -29,6 +29,6 @@ export const ProcinstTaskStatus = {
};
export const FlowBizType = {
DbSqlExec: EnumValue.of('db_sql_exec_flow', 'DBMS-执行SQL'),
RedisRunWriteCmd: EnumValue.of('redis_run_write_cmd_flow', 'Redis-执行write命令'),
DbSqlExec: EnumValue.of('db_sql_exec_flow', 'DBMS-执行SQL').setTagType('warning'),
RedisRunWriteCmd: EnumValue.of('redis_run_cmd_flow', 'Redis-执行命令').setTagType('danger'),
};

View File

@@ -1,79 +0,0 @@
<template>
<div>
<el-descriptions :column="3" border>
<el-descriptions-item :span="2" label="名称">{{ db?.name }}</el-descriptions-item>
<el-descriptions-item :span="1" label="id">{{ db?.id }}</el-descriptions-item>
<el-descriptions-item :span="3" label="关联标签"><ResourceTags :tags="db.tags" /></el-descriptions-item>
<el-descriptions-item :span="1" label="主机">{{ `${db?.host}:${db?.port}` }}</el-descriptions-item>
<el-descriptions-item :span="1" label="类型">
<SvgIcon :name="getDbDialect(db?.type).getInfo().icon" :size="20" />{{ db?.type }}
</el-descriptions-item>
<el-descriptions-item :span="1" label="用户名">{{ db?.username }}</el-descriptions-item>
<el-descriptions-item label="数据库">{{ sqlExec.db }}</el-descriptions-item>
<el-descriptions-item label="表">
{{ sqlExec.table }}
</el-descriptions-item>
<el-descriptions-item label="类型">
<el-tag size="small">{{ EnumValue.getLabelByValue(DbSqlExecTypeEnum, sqlExec.type) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="执行SQL">
<monaco-editor height="300px" language="sql" v-model="sqlExec.sql" :options="{ readOnly: true }" />
</el-descriptions-item>
</el-descriptions>
</div>
</template>
<script lang="ts" setup>
import { toRefs, reactive, watch, onMounted } from 'vue';
import EnumValue from '@/common/Enum';
import { dbApi } from '@/views/ops/db/api';
import { DbSqlExecTypeEnum } from '@/views/ops/db/enums';
import MonacoEditor from '@/components/monaco/MonacoEditor.vue';
import { getDbDialect } from '@/views/ops/db/dialect';
import ResourceTags from '@/views/ops/component/ResourceTags.vue';
const props = defineProps({
// 业务key
bizKey: {
type: [String],
default: '',
},
});
const state = reactive({
sqlExec: {
sql: '',
} as any,
db: {} as any,
});
const { sqlExec, db } = toRefs(state);
onMounted(() => {
getDbSqlExec(props.bizKey);
});
watch(
() => props.bizKey,
(newValue: any) => {
getDbSqlExec(newValue);
}
);
const getDbSqlExec = async (bizKey: string) => {
if (!bizKey) {
return;
}
const res = await dbApi.getSqlExecs.request({ flowBizKey: bizKey });
if (!res.list) {
return;
}
state.sqlExec = res.list?.[0];
const dbRes = await dbApi.dbs.request({ id: state.sqlExec.dbId });
state.db = dbRes.list?.[0];
};
</script>
<style lang="scss"></style>

View File

@@ -1,80 +0,0 @@
<template>
<div>
<el-descriptions :column="3" border>
<el-descriptions-item :span="1" label="名称">{{ redis?.name }}</el-descriptions-item>
<el-descriptions-item :span="1" label="id">{{ redis?.id }}</el-descriptions-item>
<el-descriptions-item :span="1" label="用户名">{{ redis?.username }}</el-descriptions-item>
<el-descriptions-item :span="3" label="关联标签"><ResourceTags :tags="redis.tags" /></el-descriptions-item>
<el-descriptions-item :span="1" label="主机">{{ `${redis?.host}` }}</el-descriptions-item>
<el-descriptions-item :span="1" label="库">{{ state.db }}</el-descriptions-item>
<el-descriptions-item :span="1" label="mode">
{{ redis.mode }}
</el-descriptions-item>
<el-descriptions-item :span="3" label="执行Cmd">
<el-input type="textarea" disabled v-model="cmd" rows="5" />
</el-descriptions-item>
</el-descriptions>
</div>
</template>
<script lang="ts" setup>
import { toRefs, reactive, watch, onMounted } from 'vue';
import ResourceTags from '@/views/ops/component/ResourceTags.vue';
import { redisApi } from '@/views/ops/redis/api';
const props = defineProps({
// 业务表单
bizForm: {
type: [String],
default: '',
},
});
const state = reactive({
cmd: '',
db: 0,
redis: {} as any,
});
const { cmd, redis } = toRefs(state);
onMounted(() => {
parseRunCmdForm(props.bizForm);
});
watch(
() => props.bizForm,
(newValue: any) => {
parseRunCmdForm(newValue);
}
);
const parseRunCmdForm = async (bizForm: string) => {
if (!bizForm) {
return;
}
const form = JSON.parse(bizForm);
const cmds = form.cmd.map((item: any, index: number) => {
if (index === 0) {
return item; // 第一个元素直接返回原值
}
if (typeof item === 'string') {
return `'${item}'`; // 字符串加单引号
}
return item; // 其他类型直接返回
});
state.cmd = cmds.join(' ');
state.db = form.db;
const res = await redisApi.redisList.request({ id: form.id });
if (!res.list) {
return;
}
state.redis = res.list?.[0];
};
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,103 @@
<template>
<div>
<el-descriptions :column="3" border>
<el-descriptions-item :span="3" label="标签"><TagCodePath :path="db.codePaths" /></el-descriptions-item>
<el-descriptions-item :span="1" label="名称">{{ db?.name }}</el-descriptions-item>
<el-descriptions-item :span="1" label="主机">
<SvgIcon :name="getDbDialect(db?.type).getInfo().icon" :size="20" />
{{ `${db?.host}:${db?.port}` }}
</el-descriptions-item>
<el-descriptions-item :span="1" label="数据库">{{ dbName }}</el-descriptions-item>
<el-descriptions-item label="执行SQL">
<monaco-editor height="300px" language="sql" v-model="sql" :options="{ readOnly: true }" />
</el-descriptions-item>
</el-descriptions>
<div v-if="runRes && runRes.length > 0">
<el-divider content-position="left">处理结果</el-divider>
<el-table :data="runRes" :max-height="400">
<el-table-column prop="sql" label="SQL" show-overflow-tooltip />
<el-table-column prop="res" label="执行结果" :min-width="30" show-overflow-tooltip>
<template #default="scope">
<el-popover placement="top" :width="400" trigger="hover">
<template #reference>
<el-link icon="view" :type="scope.row.errorMsg ? 'danger' : 'success'" :underline="false"> </el-link>
</template>
<el-text v-if="scope.row.errorMsg">{{ scope.row.errorMsg }}</el-text>
<el-table v-else :data="scope.row.res" size="small">
<el-table-column v-for="col in scope.row.columns" :key="col.name" :label="col.name" :prop="col.name" />
</el-table>
</el-popover>
</template>
</el-table-column>
<!-- <el-table-column prop="errorMsg" label="错误信息" :min-width="60" show-overflow-tooltip /> -->
</el-table>
</div>
</div>
</template>
<script lang="ts" setup>
import { toRefs, reactive, watch, onMounted } from 'vue';
import { dbApi } from '@/views/ops/db/api';
import MonacoEditor from '@/components/monaco/MonacoEditor.vue';
import { getDbDialect } from '@/views/ops/db/dialect';
import { tagApi } from '@/views/ops/tag/api';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import TagCodePath from '@/views/ops/component/TagCodePath.vue';
import SvgIcon from '@/components/svgIcon/index.vue';
const props = defineProps({
procinst: {
type: [Object],
default: () => {},
},
});
const state = reactive({
// sqlExec: {
// sql: '',
// } as any,
db: {} as any,
dbName: '',
sql: '',
runRes: [],
});
const { db, dbName, sql, runRes } = toRefs(state);
onMounted(() => {
parseBizForm(props.procinst.bizForm);
});
watch(
() => props.procinst.bizForm,
(newValue: any) => {
parseBizForm(newValue);
}
);
const parseBizForm = async (bizFormStr: string) => {
if (props.procinst.bizHandleRes) {
state.runRes = JSON.parse(props.procinst.bizHandleRes);
} else {
state.runRes = [];
}
const bizForm = JSON.parse(bizFormStr);
state.sql = bizForm.sql;
state.dbName = bizForm.dbName;
const dbRes = await dbApi.dbs.request({ id: bizForm.dbId });
state.db = dbRes.list?.[0];
tagApi.listByQuery.request({ type: TagResourceTypeEnum.DbName.value, codes: state.db.code }).then((res) => {
state.db.codePaths = res.map((item: any) => item.codePath);
});
};
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,83 @@
<template>
<el-form :model="bizForm" ref="formRef" :rules="rules" label-width="auto">
<el-form-item prop="dbId" label="数据库" required>
<db-select-tree
placeholder="请选择数据库"
v-model:db-id="bizForm.dbId"
v-model:db-name="bizForm.dbName"
v-model:db-type="dbType"
@select-db="changeResourceCode"
/>
</el-form-item>
<el-form-item prop="sql" label="SQL" required>
<div class="w100">
<monaco-editor height="300px" language="sql" v-model="bizForm.sql" />
</div>
</el-form-item>
</el-form>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import DbSelectTree from '@/views/ops/db/component/DbSelectTree.vue';
import MonacoEditor from '@/components/monaco/MonacoEditor.vue';
import { registerDbCompletionItemProvider } from '@/views/ops/db/db';
import { TagResourceTypeEnum } from '@/common/commonEnum';
const rules = {
dbId: [
{
required: true,
message: '请选择数据库',
trigger: ['change', 'blur'],
},
],
sql: [
{
required: true,
message: '请输入执行SQL',
trigger: ['change', 'blur'],
},
],
};
const emit = defineEmits(['changeResourceCode']);
const formRef: any = ref(null);
const bizForm = defineModel<any>('bizForm', {
default: {
dbId: 0,
dbName: '',
sql: '',
},
});
const dbType = ref('');
watch(
() => bizForm.value.dbId,
() => {
registerDbCompletionItemProvider(bizForm.value.dbId, bizForm.value.dbName, [bizForm.value.dbName], dbType.value);
}
);
const changeResourceCode = async (db: any) => {
emit('changeResourceCode', TagResourceTypeEnum.Db.value, db.code);
};
const validateBizForm = async () => {
return formRef.value.validate();
};
const resetBizForm = () => {
//重置表单域
formRef.value.resetFields();
bizForm.value.dbId = 0;
bizForm.value.dbName = '';
};
defineExpose({ validateBizForm, resetBizForm });
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,89 @@
<template>
<div>
<el-descriptions :column="3" border>
<el-descriptions-item :span="3" label="标签"><TagCodePath :path="redis.codePaths" /></el-descriptions-item>
<el-descriptions-item :span="2" label="编号">{{ redis?.code }}</el-descriptions-item>
<el-descriptions-item :span="1" label="名称">{{ redis?.name }}</el-descriptions-item>
<el-descriptions-item :span="1" label="主机">{{ `${redis?.host}` }}</el-descriptions-item>
<el-descriptions-item :span="1" label="库">{{ state.db }}</el-descriptions-item>
<el-descriptions-item :span="1" label="mode">
{{ redis.mode }}
</el-descriptions-item>
<el-descriptions-item :span="3" label="执行Cmd">
<el-input type="textarea" disabled v-model="cmd" rows="5" />
</el-descriptions-item>
</el-descriptions>
<div v-if="runRes && runRes.length > 0">
<el-divider content-position="left">处理结果</el-divider>
<el-table :data="runRes" :max-height="400">
<el-table-column prop="cmd" label="命令" show-overflow-tooltip />
<el-table-column prop="res" label="执行结果" :min-width="50" show-overflow-tooltip> </el-table-column>
</el-table>
</div>
</div>
</template>
<script lang="ts" setup>
import { toRefs, reactive, watch, onMounted } from 'vue';
import { redisApi } from '@/views/ops/redis/api';
import TagCodePath from '@/views/ops/component/TagCodePath.vue';
import { tagApi } from '@/views/ops/tag/api';
import { TagResourceTypeEnum } from '@/common/commonEnum';
const props = defineProps({
procinst: {
type: [Object],
default: () => {},
},
});
const state = reactive({
cmd: '',
runRes: [],
db: 0,
redis: {} as any,
});
const { cmd, redis, runRes } = toRefs(state);
onMounted(() => {
parseRunCmdForm(props.procinst.bizForm);
});
watch(
() => props.procinst.bizForm,
(newValue: any) => {
parseRunCmdForm(newValue);
}
);
const parseRunCmdForm = async (bizFormStr: string) => {
if (props.procinst.bizHandleRes) {
state.runRes = JSON.parse(props.procinst.bizHandleRes);
} else {
state.runRes = [];
}
if (!bizFormStr) {
return;
}
const bizForm = JSON.parse(bizFormStr);
state.cmd = bizForm.cmd;
state.db = bizForm.db;
const res = await redisApi.redisList.request({ id: bizForm.id });
if (!res.list) {
return;
}
state.redis = res.list?.[0];
tagApi.listByQuery.request({ type: TagResourceTypeEnum.Redis.value, codes: state.redis.code }).then((res) => {
state.redis.codePaths = res.map((item: any) => item.codePath);
});
};
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,148 @@
<template>
<el-form :model="bizForm" ref="formRef" :rules="rules" label-width="auto">
<el-form-item prop="id" label="库" required>
<TagTreeResourceSelect
v-bind="$attrs"
v-model="selectRedis"
@change="changeRedis"
:resource-type="TagResourceTypeEnum.Redis.value"
:tag-path-node-type="NodeTypeTagPath"
placeholder="请选择Redis实例与库"
>
</TagTreeResourceSelect>
</el-form-item>
<el-form-item prop="cmd" label="CMD" required>
<el-input type="textarea" v-model="bizForm.cmd" placeholder="如: SET 'key' 'value'; 多条命令;分割" :rows="5" />
</el-form-item>
</el-form>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import TagTreeResourceSelect from '@/views/ops/component/TagTreeResourceSelect.vue';
import { NodeType, TagTreeNode } from '@/views/ops/component/tag';
import { redisApi } from '@/views/ops/redis/api';
import { sleep } from '@/common/utils/loading';
const rules = {
id: [
{
required: true,
message: '请选择Redis实例',
trigger: ['change', 'blur'],
},
],
cmd: [
{
required: true,
message: '请输入执行CMD',
trigger: ['change', 'blur'],
},
],
};
// tagpath 节点类型
const NodeTypeTagPath = new NodeType(TagTreeNode.TagPath).withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const res = await redisApi.redisList.request({ tagPath: parentNode.key });
if (!res.total) {
return [];
}
const redisInfos = res.list;
await sleep(100);
return redisInfos.map((x: any) => {
x.tagPath = parentNode.key;
return new TagTreeNode(`${x.code}`, x.name, NodeTypeRedis).withParams(x);
});
});
// redis实例节点类型
const NodeTypeRedis = new NodeType(1).withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const redisInfo = parentNode.params;
let dbs: TagTreeNode[] = redisInfo.db.split(',').map((x: string) => {
return new TagTreeNode(x, `db${x}`, 2 as any).withIsLeaf(true).withParams({
id: redisInfo.id,
db: x,
name: `db${x}`,
keys: 0,
tagPath: redisInfo.tagPath,
redisName: redisInfo.name,
code: redisInfo.code,
});
});
if (redisInfo.mode == 'cluster') {
return dbs;
}
const res = await redisApi.redisInfo.request({ id: redisInfo.id, host: redisInfo.host, section: 'Keyspace' });
for (let db in res.Keyspace) {
for (let d of dbs) {
if (db == d.params.name) {
d.params.keys = res.Keyspace[db]?.split(',')[0]?.split('=')[1] || 0;
}
}
}
// 替换label
dbs.forEach((e: any) => {
e.label = `${e.params.name}`;
});
return dbs;
});
const emit = defineEmits(['changeResourceCode']);
const formRef: any = ref(null);
const bizForm = defineModel<any>('bizForm', {
default: {
id: 0,
db: 0,
cmd: '',
},
});
const redisName = ref('');
const tagPath = ref('');
const selectRedis = computed({
get: () => {
return redisName.value ? `${tagPath.value} > ${redisName.value} > db${bizForm.value.db}` : '';
},
set: () => {
//
},
});
const changeRedis = (nodeData: TagTreeNode) => {
const params = nodeData.params;
tagPath.value = params.tagPath;
redisName.value = params.redisName;
bizForm.value.id = params.id;
bizForm.value.db = parseInt(params.db);
changeResourceCode(params.code);
};
const changeResourceCode = async (redisCode: any) => {
emit('changeResourceCode', TagResourceTypeEnum.Redis.value, redisCode);
};
const validateBizForm = async () => {
return formRef.value.validate();
};
const resetBizForm = () => {
//重置表单域
formRef.value.resetFields();
bizForm.value.id = 0;
bizForm.value.db = 0;
bizForm.value.cmd = '';
};
defineExpose({ validateBizForm, resetBizForm });
</script>
<style lang="scss"></style>

View File

@@ -1,137 +1,587 @@
<template>
<div class="home-container">
<div class="home-container personal">
<el-row :gutter="15">
<el-col :sm="6" class="mb15">
<div @click="toPage({ id: 'personal' })" class="home-card-item home-card-first">
<div class="flex-margin flex">
<img :src="userInfo.photo" />
<div class="home-card-first-right ml15">
<div class="flex-margin">
<div class="home-card-first-right-title">{{ `${currentTime}, ${userInfo.username}` }}</div>
</div>
<!-- 个人信息 -->
<el-col :xs="24" :sm="16">
<el-card shadow="hover" header="个人信息">
<div class="personal-user">
<div class="personal-user-left">
<el-upload
class="h100 personal-user-left-upload"
:action="getUploadFileUrl(`avatar_${userInfo.username}`)"
:limit="1"
:show-file-list="false"
:before-upload="beforeAvatarUpload"
:on-success="handleAvatarSuccess"
accept=".png,.jpg,.jpeg"
>
<img :src="userInfo.photo" />
</el-upload>
</div>
<div class="personal-user-right">
<el-row>
<el-col :span="24" class="personal-title mb18"
>{{ currentTime }}{{ userInfo.name }}生活变的再糟糕也不妨碍我变得更好
</el-col>
<el-col :span="24">
<el-row>
<el-col :xs="24" :sm="12" class="personal-item mb6">
<div class="personal-item-label">用户名</div>
<div class="personal-item-value">{{ userInfo.username }}</div>
</el-col>
<el-col :xs="24" :sm="12" class="personal-item mb6">
<div class="personal-item-label">角色</div>
<div class="personal-item-value">{{ roleInfo }}</div>
</el-col>
</el-row>
</el-col>
<el-col :span="24">
<el-row>
<el-col :xs="24" :sm="12" class="personal-item mb6">
<div class="personal-item-label">上次登录IP</div>
<div class="personal-item-value">{{ userInfo.lastLoginIp }}</div>
</el-col>
<el-col :xs="24" :sm="12" class="personal-item mb6">
<div class="personal-item-label">上次登录时间</div>
<div class="personal-item-value">{{ formatDate(userInfo.lastLoginTime) }}</div>
</el-col>
</el-row>
</el-col>
</el-row>
</div>
</div>
</div>
</el-card>
</el-col>
<el-col :sm="3" class="mb15" v-for="(v, k) in topCardItemList as any" :key="k">
<div @click="toPage(v)" class="home-card-item home-card-item-box" :style="{ background: v.color }">
<div class="home-card-item-flex">
<div class="home-card-item-title pb3">{{ v.title }}</div>
<div class="home-card-item-title-num pb6" :id="v.id"></div>
<!-- 消息通知 -->
<el-col :xs="24" :sm="8" class="pl15 personal-info">
<el-card shadow="hover">
<template #header>
<span>消息通知</span>
<span @click="showMsgs" class="personal-info-more">更多</span>
</template>
<div class="personal-info-box">
<ul class="personal-info-ul">
<li v-for="(v, k) in state.msgs as any" :key="k" class="personal-info-li">
<a class="personal-info-li-title">{{ `[${getMsgTypeDesc(v.type)}] ${v.msg}` }}</a>
</li>
</ul>
</div>
<i :class="v.icon" :style="{ color: v.iconColor }"></i>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="mt20 resource-info">
<el-col :sm="12">
<el-card shadow="hover">
<template #header>
<el-row justify="center">
<div class="resource-num pointer-icon" @click="toPage('machine')">
<SvgIcon
class="mb5 mr5"
:size="28"
:name="TagResourceTypeEnum.Machine.extra.icon"
:color="TagResourceTypeEnum.Machine.extra.iconColor"
/>
<span class="">{{ state.machine.num }}</span>
</div>
</el-row>
</template>
<el-row>
<el-col :sm="24">
<el-table :data="state.machine.opLogs" :height="state.resourceOpTableHeight" stripe size="small" empty-text="暂无操作记录">
<el-table-column prop="createTime" show-overflow-tooltip width="135">
<template #default="scope">
{{ formatDate(scope.row.createTime) }}
</template>
</el-table-column>
<el-table-column prop="codePath" min-width="400" show-overflow-tooltip>
<template #default="scope">
<TagCodePath :path="scope.row.codePath" :tagInfos="state.machine.tagInfos" />
</template>
</el-table-column>
<el-table-column width="30">
<template #default="scope">
<el-link @click="toPage('machine', scope.row.codePath)" type="primary" icon="Position"></el-link>
</template>
</el-table-column>
</el-table>
</el-col>
</el-row>
</el-card>
</el-col>
<el-col :sm="12">
<el-card shadow="hover">
<template #header>
<el-row justify="center">
<div class="resource-num pointer-icon" @click="toPage('db')">
<SvgIcon class="mb5 mr5" :size="28" :name="TagResourceTypeEnum.Db.extra.icon" :color="TagResourceTypeEnum.Db.extra.iconColor" />
<span class="">{{ state.db.num }}</span>
</div>
</el-row>
</template>
<el-row>
<el-col :sm="24">
<el-table :data="state.db.opLogs" :height="state.resourceOpTableHeight" stripe size="small" empty-text="暂无操作记录">
<el-table-column prop="createTime" show-overflow-tooltip min-width="135">
<template #default="scope">
{{ formatDate(scope.row.createTime) }}
</template>
</el-table-column>
<el-table-column prop="codePath" min-width="380" show-overflow-tooltip>
<template #default="scope">
<TagCodePath :path="scope.row.codePath" :tagInfos="state.db.tagInfos" />
</template>
</el-table-column>
<el-table-column width="30">
<template #default="scope">
<el-link @click="toPage('db', scope.row.codePath)" type="primary" icon="Position"></el-link>
</template>
</el-table-column>
</el-table>
</el-col>
</el-row>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="mt20 resource-info">
<el-col :sm="12">
<el-card shadow="hover">
<template #header>
<el-row justify="center">
<div class="resource-num pointer-icon" @click="toPage('redis')">
<SvgIcon
class="mb5 mr5"
:size="28"
:name="TagResourceTypeEnum.Redis.extra.icon"
:color="TagResourceTypeEnum.Redis.extra.iconColor"
/>
<span class="">{{ state.redis.num }}</span>
</div>
</el-row>
</template>
<el-row>
<el-col :sm="24">
<el-table :data="state.redis.opLogs" :height="state.resourceOpTableHeight" stripe size="small" empty-text="暂无操作记录">
<el-table-column prop="createTime" show-overflow-tooltip min-width="135">
<template #default="scope">
{{ formatDate(scope.row.createTime) }}
</template>
</el-table-column>
<el-table-column prop="codePath" min-width="380" show-overflow-tooltip>
<template #default="scope">
<TagCodePath :path="scope.row.codePath" :tagInfos="state.redis.tagInfos" />
</template>
</el-table-column>
<el-table-column width="30">
<template #default="scope">
<el-link @click="toPage('redis', scope.row.codePath)" type="primary" icon="Position"></el-link>
</template>
</el-table-column>
</el-table>
</el-col>
</el-row>
</el-card>
</el-col>
<el-col :sm="12">
<el-card shadow="hover">
<template #header>
<el-row justify="center">
<div class="resource-num pointer-icon" @click="toPage('mongo')">
<SvgIcon
class="mb5 mr5"
:size="28"
:name="TagResourceTypeEnum.Mongo.extra.icon"
:color="TagResourceTypeEnum.Mongo.extra.iconColor"
/>
<span class="">{{ state.mongo.num }}</span>
</div>
</el-row>
</template>
<el-row>
<el-col :sm="24">
<el-table :data="state.mongo.opLogs" :height="state.resourceOpTableHeight" stripe size="small" empty-text="暂无操作记录">
<el-table-column prop="createTime" show-overflow-tooltip min-width="135">
<template #default="scope">
{{ formatDate(scope.row.createTime) }}
</template>
</el-table-column>
<el-table-column prop="codePath" min-width="380" show-overflow-tooltip>
<template #default="scope">
<TagCodePath :path="scope.row.codePath" :tagInfos="state.mongo.tagInfos" />
</template>
</el-table-column>
<el-table-column width="30">
<template #default="scope">
<el-link @click="toPage('mongo', scope.row.codePath)" type="primary" icon="Position"></el-link>
</template>
</el-table-column>
</el-table>
</el-col>
</el-row>
</el-card>
</el-col>
</el-row>
<el-dialog width="900px" title="消息" v-model="msgDialog.visible">
<el-table border :data="msgDialog.msgs.list" size="small">
<el-table-column property="type" label="类型" width="60">
<template #default="scope">
{{ getMsgTypeDesc(scope.row.type) }}
</template>
</el-table-column>
<el-table-column property="msg" label="消息"></el-table-column>
<el-table-column property="createTime" label="时间" width="150">
<template #default="scope">
{{ formatDate(scope.row.createTime) }}
</template>
</el-table-column>
</el-table>
<el-row type="flex" class="mt5" justify="center">
<el-pagination
small
@current-change="searchMsg"
style="text-align: center"
background
layout="prev, pager, next, total, jumper"
:total="msgDialog.msgs.total"
v-model:current-page="msgDialog.query.pageNum"
:page-size="msgDialog.query.pageSize"
/>
</el-row>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { toRefs, reactive, onMounted, nextTick, computed } from 'vue';
import { computed, onMounted, reactive, toRefs } from 'vue';
// import * as echarts from 'echarts';
import { CountUp } from 'countup.js';
import { formatAxis } from '@/common/utils/format';
import { formatAxis, formatDate } from '@/common/utils/format';
import { indexApi } from './api';
import { useRouter } from 'vue-router';
import { storeToRefs } from 'pinia';
import { useUserInfo } from '@/store/userInfo';
import { personApi } from '../personal/api';
import SvgIcon from '@/components/svgIcon/index.vue';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { resourceOpLogApi } from '../ops/tag/api';
import TagCodePath from '../ops/component/TagCodePath.vue';
import { useAutoOpenResource } from '@/store/autoOpenResource';
import { getAllTagInfoByCodePaths } from '../ops/component/tag';
import { ElMessage } from 'element-plus';
import { getFileUrl, getUploadFileUrl } from '@/common/request';
import { saveUser } from '@/common/utils/storage';
const router = useRouter();
const { userInfo } = storeToRefs(useUserInfo());
const state = reactive({
topCardItemList: [
{
title: 'Linux机器',
id: 'machineNum',
color: '#F95959',
accountInfo: {
roles: [],
},
msgs: [],
msgDialog: {
visible: false,
query: {
pageSize: 10,
pageNum: 1,
},
{
title: '数据库',
id: 'dbNum',
color: '#8595F4',
msgs: {
list: [],
total: null,
},
{
title: 'redis',
id: 'redisNum',
color: '#1abc9c',
},
{
title: 'Mongo',
id: 'mongoNum',
color: '#FEBB50',
},
],
},
resourceOpTableHeight: 180,
defaultLogSize: 5,
machine: {
num: 0,
opLogs: [],
tagInfos: {},
},
db: {
num: 0,
opLogs: [],
tagInfos: {},
},
redis: {
num: 0,
opLogs: [],
tagInfos: {},
},
mongo: {
num: 0,
opLogs: [],
tagInfos: {},
},
});
const { topCardItemList } = toRefs(state);
const { msgDialog } = toRefs(state);
const roleInfo = computed(() => {
if (state.accountInfo.roles.length == 0) {
return '';
}
return state.accountInfo.roles.map((val: any) => val.roleName).join('、');
});
// 当前时间提示语
const currentTime = computed(() => {
return formatAxis(new Date());
});
// 页面加载时
onMounted(() => {
initData();
getAccountInfo();
getMsgs().then((res) => {
state.msgs = res.list;
});
});
const showMsgs = async () => {
state.msgDialog.query.pageNum = 1;
searchMsg();
state.msgDialog.visible = true;
};
const searchMsg = async () => {
state.msgDialog.msgs = await getMsgs();
};
const getMsgTypeDesc = (type: number) => {
if (type == 1) {
return '登录';
}
if (type == 2) {
return '通知';
}
};
const getAccountInfo = async () => {
state.accountInfo = await personApi.accountInfo.request();
};
const getMsgs = async () => {
return await personApi.getMsgs.request(state.msgDialog.query);
};
const beforeAvatarUpload = (rawFile: any) => {
if (rawFile.size >= 512 * 1024) {
ElMessage.error('头像不能超过512KB!');
return false;
}
return true;
};
const handleAvatarSuccess = (response: any, uploadFile: any) => {
userInfo.value.photo = URL.createObjectURL(uploadFile.raw);
const newUser = { ...userInfo.value };
newUser.photo = getFileUrl(`avatar_${userInfo.value.username}`);
// 存储用户信息到浏览器缓存
saveUser(newUser);
};
// 初始化数字滚动
const initNumCountUp = async () => {
indexApi.machineDashbord.request().then((res: any) => {
nextTick(() => {
new CountUp('machineNum', res.machineNum).start();
const initData = async () => {
resourceOpLogApi.getAccountResourceOpLogs
.request({ resourceType: TagResourceTypeEnum.MachineAuthCert.value, pageSize: state.defaultLogSize })
.then(async (res: any) => {
const tagInfos = await getAllTagInfoByCodePaths(res.list?.map((item: any) => item.codePath));
state.machine.tagInfos = tagInfos;
state.machine.opLogs = res.list;
});
resourceOpLogApi.getAccountResourceOpLogs
.request({ resourceType: TagResourceTypeEnum.DbName.value, pageSize: state.defaultLogSize })
.then(async (res: any) => {
const tagInfos = await getAllTagInfoByCodePaths(res.list?.map((item: any) => item.codePath));
state.db.tagInfos = tagInfos;
state.db.opLogs = res.list;
});
resourceOpLogApi.getAccountResourceOpLogs
.request({ resourceType: TagResourceTypeEnum.Redis.value, pageSize: state.defaultLogSize })
.then(async (res: any) => {
const tagInfos = await getAllTagInfoByCodePaths(res.list?.map((item: any) => item.codePath));
state.redis.tagInfos = tagInfos;
state.redis.opLogs = res.list;
});
resourceOpLogApi.getAccountResourceOpLogs
.request({ resourceType: TagResourceTypeEnum.Mongo.value, pageSize: state.defaultLogSize })
.then(async (res: any) => {
const tagInfos = await getAllTagInfoByCodePaths(res.list?.map((item: any) => item.codePath));
state.mongo.tagInfos = tagInfos;
state.mongo.opLogs = res.list;
});
indexApi.machineDashbord.request().then((res: any) => {
state.machine.num = res.machineNum;
});
indexApi.dbDashbord.request().then((res: any) => {
nextTick(() => {
new CountUp('dbNum', res.dbNum).start();
});
state.db.num = res.dbNum;
});
indexApi.redisDashbord.request().then((res: any) => {
nextTick(() => {
new CountUp('redisNum', res.redisNum).start();
});
state.redis.num = res.redisNum;
});
indexApi.mongoDashbord.request().then((res: any) => {
nextTick(() => {
new CountUp('mongoNum', res.mongoNum).start();
});
state.mongo.num = res.mongoNum;
});
};
const toPage = (item: any) => {
switch (item.id) {
const toPage = (item: any, codePath = '') => {
let path;
switch (item) {
case 'personal': {
router.push('/personal');
break;
}
case 'mongoNum': {
router.push('/mongo/mongo-data-operation');
case 'mongo': {
useAutoOpenResource().setMongoCodePath(codePath);
path = '/mongo/mongo-data-operation';
break;
}
case 'machineNum': {
router.push('/machine/machines-op');
case 'machine': {
useAutoOpenResource().setMachineCodePath(codePath);
path = '/machine/machines-op';
break;
}
case 'dbNum': {
router.push('/dbms/sql-exec');
case 'db': {
useAutoOpenResource().setDbCodePath(codePath);
path = '/dbms/sql-exec';
break;
}
case 'redisNum': {
router.push('/redis/data-operation');
case 'redis': {
useAutoOpenResource().setRedisCodePath(codePath);
path = '/redis/data-operation';
break;
}
}
};
// 页面加载时
onMounted(() => {
initNumCountUp();
// initHomeLaboratory();
// initHomeOvertime();
});
router.push({ path });
};
</script>
<style scoped lang="scss">
@use '@/theme/mixins/index.scss' as mixins;
.personal {
.personal-user {
height: 130px;
display: flex;
align-items: center;
.personal-user-left {
width: 100px;
height: 130px;
border-radius: 3px;
::v-deep(.el-upload) {
height: 100%;
}
.personal-user-left-upload {
img {
width: 100%;
height: 100%;
border-radius: 3px;
}
&:hover {
img {
animation: logoAnimation 0.3s ease-in-out;
}
}
}
}
.personal-user-right {
flex: 1;
padding: 0 15px;
.personal-title {
font-size: 18px;
@include mixins.text-ellipsis(1);
}
.personal-item {
display: flex;
align-items: center;
font-size: 13px;
.personal-item-label {
color: gray;
@include mixins.text-ellipsis(1);
}
.personal-item-value {
@include mixins.text-ellipsis(1);
}
}
}
}
.personal-info {
.personal-info-more {
float: right;
color: gray;
font-size: 13px;
&:hover {
color: var(--el-color-primary);
cursor: pointer;
}
}
.personal-info-box {
height: 130px;
overflow: hidden;
.personal-info-ul {
list-style: none;
.personal-info-li {
font-size: 13px;
padding-bottom: 10px;
.personal-info-li-title {
display: inline-block;
@include mixins.text-ellipsis(1);
color: grey;
text-decoration: none;
}
& a:hover {
color: var(--el-color-primary);
cursor: pointer;
}
}
}
}
}
}
.resource-info {
text-align: center;
::v-deep(.el-card__header) {
padding: 2px 20px;
}
.resource-num {
font-weight: 700;
font-size: 2vw;
}
}
.home-container {
overflow-x: hidden;
@@ -182,7 +632,7 @@ onMounted(() => {
}
.home-card-item-title-num {
font-size: 18px;
font-size: 2vw;
}
.home-card-item-tip-num {
@@ -190,124 +640,5 @@ onMounted(() => {
}
}
}
.home-card-first {
background: var(--bg-main-color);
border: 1px solid var(--el-border-color-light, #ebeef5);
display: flex;
align-items: center;
img {
width: 60px;
height: 60px;
border-radius: 100%;
border: 2px solid var(--el-color-primary-light-5);
}
.home-card-first-right {
flex: 1;
display: flex;
flex-direction: column;
.home-card-first-right-msg {
font-size: 13px;
color: gray;
}
}
}
.home-monitor {
height: 200px;
.flex-warp-item {
width: 50%;
height: 100px;
display: flex;
.flex-warp-item-box {
margin: auto;
height: auto;
text-align: center;
}
}
}
.home-warning-card {
height: 292px;
::v-deep(.el-card) {
height: 100%;
}
}
.home-dynamic {
height: 200px;
.home-dynamic-item {
display: flex;
width: 100%;
height: 60px;
overflow: hidden;
&:first-of-type {
.home-dynamic-item-line {
i {
color: orange !important;
}
}
}
.home-dynamic-item-left {
text-align: right;
.home-dynamic-item-left-time1 {
}
.home-dynamic-item-left-time2 {
font-size: 13px;
color: gray;
}
}
.home-dynamic-item-line {
height: 60px;
border-right: 2px dashed #dfdfdf;
margin: 0 20px;
position: relative;
i {
color: var(--el-color-primary);
font-size: 12px;
position: absolute;
top: 1px;
left: -6px;
transform: rotate(46deg);
background: white;
}
}
.home-dynamic-item-right {
flex: 1;
.home-dynamic-item-right-title {
i {
margin-right: 5px;
border: 1px solid #dfdfdf;
width: 20px;
height: 20px;
border-radius: 100%;
padding: 3px 2px 2px;
text-align: center;
color: var(--el-color-primary);
}
}
.home-dynamic-item-right-label {
font-size: 13px;
color: gray;
}
}
}
}
}
</style>

View File

@@ -132,7 +132,7 @@ import { nextTick, onMounted, ref, toRefs, reactive, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { initRouter } from '@/router/index';
import { saveToken, saveUser } from '@/common/utils/storage';
import { getRefreshToken, saveRefreshToken, saveToken, saveUser } from '@/common/utils/storage';
import { formatAxis } from '@/common/utils/format';
import openApi from '@/common/openApi';
import { RsaEncrypt } from '@/common/rsa';
@@ -144,6 +144,7 @@ import { personApi } from '@/views/personal/api';
import { AccountUsernamePattern } from '@/common/pattern';
import { getToken } from '@/common/utils/storage';
import { useThemeConfig } from '@/store/themeConfig';
import { getFileUrl } from '@/common/request';
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
@@ -279,19 +280,20 @@ const login = () => {
};
const otpVerify = async () => {
otpFormRef.value.validate(async (valid: boolean) => {
if (!valid) {
return false;
}
try {
state.loading.otpConfirm = true;
const accessToken = await openApi.otpVerify(state.otpDialog.form);
await signInSuccess(accessToken);
state.otpDialog.visible = false;
} finally {
state.loading.otpConfirm = false;
}
});
try {
await otpFormRef.value.validate();
} catch (e: any) {
return false;
}
try {
state.loading.otpConfirm = true;
const res = await openApi.otpVerify(state.otpDialog.form);
await signInSuccess(res.token, res.refresh_token);
state.otpDialog.visible = false;
} finally {
state.loading.otpConfirm = false;
}
};
// 登录
@@ -327,46 +329,55 @@ const onSignIn = async () => {
};
const updateUserInfo = async () => {
baseInfoFormRef.value.validate(async (valid: boolean) => {
if (!valid) {
return false;
}
try {
state.loading.updateUserConfirm = true;
const form = state.baseInfoDialog.form;
await personApi.updateAccount.request(state.baseInfoDialog.form);
state.baseInfoDialog.visible = false;
useUserInfo().userInfo.username = form.username;
useUserInfo().userInfo.name = form.name;
await toIndex();
} finally {
state.loading.updateUserConfirm = false;
}
});
try {
await baseInfoFormRef.value.validate();
} catch (e: any) {
return false;
}
try {
state.loading.updateUserConfirm = true;
const form = state.baseInfoDialog.form;
await personApi.updateAccount.request(state.baseInfoDialog.form);
state.baseInfoDialog.visible = false;
useUserInfo().userInfo.username = form.username;
useUserInfo().userInfo.name = form.name;
await toIndex();
} finally {
state.loading.updateUserConfirm = false;
}
};
const loginResDeal = (loginRes: any) => {
const loginResDeal = async (loginRes: any) => {
state.loginRes = loginRes;
// 用户信息
const userInfos = {
name: loginRes.name,
username: loginRes.username,
// 头像
photo: letterAvatar(loginRes.username),
time: new Date().getTime(),
lastLoginTime: loginRes.lastLoginTime,
lastLoginIp: loginRes.lastLoginIp,
photo: '',
};
const avatarFileKey = `avatar_${loginRes.username}`;
const avatarFileDetail = await openApi.getFileDetail([avatarFileKey]);
// 说明存在头像文件
if (avatarFileDetail.length > 0) {
userInfos.photo = getFileUrl(avatarFileKey);
} else {
userInfos.photo = letterAvatar(loginRes.username);
}
// 存储用户信息到浏览器缓存
saveUser(userInfos);
// 1、请注意执行顺序(存储用户信息到vuex)
useUserInfo().setUserInfo(userInfos);
const token = loginRes.token;
// 如果不需要 otp校验则该token即为accessToken否则为otp校验token
// 如果不需要otp校验则该token即为accessToken否则为otp校验token
if (loginRes.otp == -1) {
signInSuccess(token);
signInSuccess(token, loginRes.refresh_token);
return;
}
@@ -379,12 +390,16 @@ const loginResDeal = (loginRes: any) => {
};
// 登录成功后的跳转
const signInSuccess = async (accessToken: string = '') => {
const signInSuccess = async (accessToken: string = '', refreshToken = '') => {
if (!accessToken) {
accessToken = getToken();
}
if (!refreshToken) {
refreshToken = getRefreshToken();
}
// 存储 token 到浏览器缓存
saveToken(accessToken);
saveRefreshToken(refreshToken);
// 初始化路由
await initRouter();
@@ -415,26 +430,27 @@ const toIndex = async () => {
}, 300);
};
const changePwd = () => {
changePwdFormRef.value.validate(async (valid: boolean) => {
if (!valid) {
return false;
}
try {
state.loading.changePwd = true;
const form = state.changePwdDialog.form;
const changePwdReq: any = { ...form };
changePwdReq.oldPassword = await RsaEncrypt(form.oldPassword);
changePwdReq.newPassword = await RsaEncrypt(form.newPassword);
await openApi.changePwd(changePwdReq);
ElMessage.success('密码修改成功, 新密码已填充至登录密码框');
state.loginForm.password = state.changePwdDialog.form.newPassword;
state.changePwdDialog.visible = false;
getCaptcha();
} finally {
state.loading.changePwd = false;
}
});
const changePwd = async () => {
try {
await changePwdFormRef.value.validate();
} catch (e: any) {
return false;
}
try {
state.loading.changePwd = true;
const form = state.changePwdDialog.form;
const changePwdReq: any = { ...form };
changePwdReq.oldPassword = await RsaEncrypt(form.oldPassword);
changePwdReq.newPassword = await RsaEncrypt(form.newPassword);
await openApi.changePwd(changePwdReq);
ElMessage.success('密码修改成功, 新密码已填充至登录密码框');
state.loginForm.password = state.changePwdDialog.form.newPassword;
state.changePwdDialog.visible = false;
getCaptcha();
} finally {
state.loading.changePwd = false;
}
};
const cancelChangePwd = () => {

View File

@@ -0,0 +1,28 @@
<template>
<div v-if="props.authCerts">
<el-select value-key="name" v-model="selectAuthCert" size="small">
<el-option v-for="item in props.authCerts" :key="item.name" :label="item.username" :value="item">
{{ item.username }}
<el-divider direction="vertical" border-style="dashed" />
<EnumTag :value="item.type" :enums="AuthCertTypeEnum" />
<el-divider direction="vertical" border-style="dashed" />
<EnumTag :value="item.ciphertextType" :enums="AuthCertCiphertextTypeEnum" />
</el-option>
</el-select>
</div>
</template>
<script lang="ts" setup>
import EnumTag from '@/components/enumtag/EnumTag.vue';
import { AuthCertTypeEnum, AuthCertCiphertextTypeEnum } from '../tag/enums';
const props = defineProps({
authCerts: {
type: [Array<any>],
required: true,
},
});
const selectAuthCert = defineModel('selectAuthCert');
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,271 @@
<template>
<div class="auth-cert-edit">
<el-dialog :title="props.title" v-model="dialogVisible" :show-close="false" width="500px" :destroy-on-close="true" :close-on-click-modal="false">
<el-form ref="acForm" :model="state.form" label-width="auto" :rules="rules">
<el-form-item prop="type" label="凭证类型" required>
<el-select @change="changeType" v-model="form.type" placeholder="请选择凭证类型">
<el-option
v-for="item in AuthCertTypeEnum"
:key="item.value"
:label="item.label"
:value="item.value"
v-show="!props.disableType?.includes(item.value)"
>
</el-option>
</el-select>
</el-form-item>
<el-form-item prop="ciphertextType" label="密文类型" required>
<el-select v-model="form.ciphertextType" placeholder="请选择密文类型" @change="changeCiphertextType">
<el-option
v-for="item in AuthCertCiphertextTypeEnum"
:key="item.value"
:label="item.label"
:value="item.value"
v-show="!props.disableCiphertextType?.includes(item.value)"
:disabled="item.value == AuthCertCiphertextTypeEnum.Public.value && form.type == AuthCertTypeEnum.Public.value"
>
</el-option>
</el-select>
</el-form-item>
<template v-if="showResourceEdit">
<el-form-item prop="type" label="资源类型" required>
<el-select :disabled="form.id" v-model="form.resourceType" placeholder="请选择资源类型">
<el-option
:key="TagResourceTypeEnum.Machine.value"
:label="TagResourceTypeEnum.Machine.label"
:value="TagResourceTypeEnum.Machine.value"
/>
<el-option :key="TagResourceTypeEnum.Db.value" :label="TagResourceTypeEnum.Db.label" :value="TagResourceTypeEnum.Db.value" />
<el-option
:key="TagResourceTypeEnum.Redis.value"
:label="TagResourceTypeEnum.Redis.label"
:value="TagResourceTypeEnum.Redis.value"
/>
</el-select>
</el-form-item>
<el-form-item prop="resourceCode" label="资源编号" required>
<el-input :disabled="form.id" v-model="form.resourceCode" placeholder="请输入资源编号"></el-input>
</el-form-item>
</template>
<el-form-item v-if="form.type == AuthCertTypeEnum.Public.value" prop="name" label="名称" required>
<el-input :disabled="form.id" v-model="form.name" placeholder="请输入凭证名 (全局唯一)"></el-input>
</el-form-item>
<template v-if="form.ciphertextType != AuthCertCiphertextTypeEnum.Public.value">
<el-form-item prop="username" label="用户名">
<el-input v-model="form.username"></el-input>
</el-form-item>
<el-form-item v-if="form.ciphertextType == AuthCertCiphertextTypeEnum.Password.value" prop="ciphertext" label="密码">
<el-input type="password" show-password clearable v-model.trim="form.ciphertext" placeholder="请输入密码" autocomplete="new-password">
<template #suffix>
<SvgIcon v-if="form.id" v-auth="'authcert:showciphertext'" @click="getCiphertext" name="search" />
</template>
</el-input>
</el-form-item>
<el-form-item v-if="form.ciphertextType == AuthCertCiphertextTypeEnum.PrivateKey.value" prop="ciphertext" label="秘钥">
<div class="w100" style="position: relative">
<SvgIcon
v-if="form.id"
v-auth="'authcert:showciphertext'"
@click="getCiphertext"
name="search"
style="position: absolute; top: 5px; right: 5px; cursor: pointer; z-index: 1"
/>
<el-input type="textarea" :rows="5" v-model="form.ciphertext" placeholder="请将私钥文件内容拷贝至此"> </el-input>
</div>
</el-form-item>
<el-form-item v-if="form.ciphertextType == AuthCertCiphertextTypeEnum.PrivateKey.value" prop="passphrase" label="秘钥密码">
<el-input type="password" show-password v-model="form.extra.passphrase"> </el-input>
</el-form-item>
</template>
<template v-else>
<el-form-item label="公共凭证">
<el-select default-first-option filterable v-model="form.ciphertext" @change="changePublicAuthCert">
<el-option v-for="item in state.publicAuthCerts" :key="item.name" :label="item.name" :value="item.name">
{{ item.name }}
<el-divider direction="vertical" border-style="dashed" />
{{ item.username }}
<el-divider direction="vertical" border-style="dashed" />
<EnumTag :value="item.ciphertextType" :enums="AuthCertCiphertextTypeEnum" />
<el-divider direction="vertical" border-style="dashed" />
{{ item.remark }}
</el-option>
</el-select>
</el-form-item>
</template>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2"></el-input>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancelEdit"> </el-button>
<el-button type="primary" :loading="btnLoading" @click="btnOk"> </el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref, toRefs, computed, watch } from 'vue';
import { AuthCertTypeEnum, AuthCertCiphertextTypeEnum } from '../tag/enums';
import EnumTag from '@/components/enumtag/EnumTag.vue';
import { resourceAuthCertApi } from '../tag/api';
import { ResourceCodePattern } from '@/common/pattern';
import { TagResourceTypeEnum } from '@/common/commonEnum';
const props = defineProps({
title: {
type: String,
default: '凭证保存',
},
authCert: {
type: Object,
},
disableCiphertextType: {
type: Array,
},
disableType: {
type: Array,
},
// 是否为资源编辑该授权凭证,即机器编辑等页面等
resourceEdit: {
type: Boolean,
default: true,
},
});
const DefaultForm = {
id: null,
name: '',
username: '',
ciphertextType: AuthCertCiphertextTypeEnum.Password.value,
type: AuthCertTypeEnum.Private.value,
resourceType: TagResourceTypeEnum.AuthCert.value,
resourceCode: '',
ciphertext: '',
extra: {} as any,
remark: '',
};
const rules = {
name: [
{
required: true,
message: '请输入凭证名',
trigger: ['change', 'blur'],
},
{
pattern: ResourceCodePattern.pattern,
message: ResourceCodePattern.message,
trigger: ['blur'],
},
],
resourceCode: [
{
required: true,
message: '请输入资源编号',
trigger: ['change', 'blur'],
},
],
};
const emit = defineEmits(['confirm', 'cancel']);
const dialogVisible = defineModel<boolean>('visible', { default: false });
const acForm: any = ref(null);
const state = reactive({
form: { ...DefaultForm },
btnLoading: false,
publicAuthCerts: [] as any,
});
const showResourceEdit = computed(() => {
return state.form.type != AuthCertTypeEnum.Public.value && !props.resourceEdit;
});
watch(dialogVisible, (val: any) => {
if (val) {
setForm(props.authCert);
} else {
cancelEdit();
}
});
const setForm = (val: any) => {
val = { ...val };
if (!val.extra) {
val.extra = {};
}
state.form = val;
if (state.form.ciphertextType == AuthCertCiphertextTypeEnum.Public.value) {
getPublicAuthCerts();
}
};
const { form, btnLoading } = toRefs(state);
const changeType = (val: any) => {
// 如果选择了公共凭证,则需要保证密文类型不能为公共凭证
if (val == AuthCertTypeEnum.Public.value && state.form.ciphertextType == AuthCertCiphertextTypeEnum.Public.value) {
state.form.ciphertextType = AuthCertCiphertextTypeEnum.Password.value;
}
};
const changeCiphertextType = (val: any) => {
if (val == AuthCertCiphertextTypeEnum.Public.value) {
getPublicAuthCerts();
}
};
const changePublicAuthCert = (val: string) => {
// 使用公共授权凭证名称赋值username
state.form.username = val;
};
const getPublicAuthCerts = async () => {
const res = await resourceAuthCertApi.listByQuery.request({
type: AuthCertTypeEnum.Public.value,
pageNum: 1,
pageSize: 100,
});
state.publicAuthCerts = res.list;
};
const getCiphertext = async () => {
const res = await resourceAuthCertApi.detail.request({ name: state.form.name });
state.form.ciphertext = res.ciphertext;
state.form.extra.passphrase = res.extra?.passphrase;
};
const cancelEdit = () => {
dialogVisible.value = false;
setTimeout(() => {
state.form = { ...DefaultForm };
acForm.value?.resetFields();
emit('cancel');
}, 300);
};
const btnOk = async () => {
acForm.value.validate(async (valid: boolean) => {
if (valid) {
emit('confirm', { ...state.form });
}
});
};
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,147 @@
<template>
<div class="auth-cert-manage">
<el-table :data="authCerts" max-height="180" stripe size="small">
<el-table-column min-wdith="120px">
<template #header>
<el-button v-auth="'authcert:save'" class="ml0" type="primary" circle size="small" icon="Plus" @click="edit(null)"> </el-button>
</template>
<template #default="scope">
<el-button v-auth="'authcert:save'" @click="edit(scope.row, scope.$index)" type="primary" icon="edit" link></el-button>
<el-button class="ml1" v-auth="'authcert:del'" type="danger" @click="deleteRow(scope.$index)" icon="delete" link></el-button>
<el-button
title="测试连接"
:loading="props.testConnBtnLoading && scope.$index == state.idx"
:disabled="props.testConnBtnLoading"
class="ml1"
type="success"
@click="testConn(scope.row, scope.$index)"
icon="Link"
link
></el-button>
</template>
</el-table-column>
<!-- <el-table-column prop="name" label="名称" show-overflow-tooltip min-width="100px"> </el-table-column> -->
<el-table-column prop="username" label="用户名" min-width="120px" show-overflow-tooltip> </el-table-column>
<el-table-column prop="ciphertextType" label="密文类型" width="100px">
<template #default="scope">
<EnumTag :value="scope.row.ciphertextType" :enums="AuthCertCiphertextTypeEnum" />
</template>
</el-table-column>
<el-table-column prop="type" label="凭证类型" width="100px">
<template #default="scope">
<EnumTag :value="scope.row.type" :enums="AuthCertTypeEnum" />
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" show-overflow-tooltip width="120px"> </el-table-column>
</el-table>
<ResourceAuthCertEdit
v-model:visible="state.dvisible"
:auth-cert="state.form"
@confirm="btnOk"
@cancel="cancelEdit"
:disable-type="[AuthCertTypeEnum.Public.value]"
:disable-ciphertext-type="props.disableCiphertextType"
/>
</div>
</template>
<script lang="ts" setup>
import { onMounted, reactive } from 'vue';
import { AuthCertTypeEnum, AuthCertCiphertextTypeEnum } from '../tag/enums';
import { resourceAuthCertApi } from '../tag/api';
import EnumTag from '@/components/enumtag/EnumTag.vue';
import ResourceAuthCertEdit from './ResourceAuthCertEdit.vue';
import { ElMessage } from 'element-plus';
const props = defineProps({
resourceType: { type: Number },
resourceCode: { type: String },
disableCiphertextType: {
type: Array,
},
testConnBtnLoading: { type: Boolean },
});
const authCerts = defineModel<any>('modelValue', { required: true, default: [] });
const emit = defineEmits(['testConn']);
const state = reactive({
dvisible: false,
params: [] as any,
form: {},
idx: -1,
});
onMounted(() => {
getAuthCerts();
});
const getAuthCerts = async () => {
if (!props.resourceCode || !props.resourceType) {
return;
}
const res = await resourceAuthCertApi.listByQuery.request({
resourceCode: props.resourceCode,
resourceType: props.resourceType,
pageNum: 1,
pageSize: 100,
});
authCerts.value = res.list?.reverse() || [];
};
const testConn = async (row: any, idx: number) => {
state.idx = idx;
emit('testConn', row);
};
const edit = (form: any, idx = -1) => {
state.idx = idx;
if (form) {
state.form = form;
} else {
state.form = { ciphertextType: AuthCertCiphertextTypeEnum.Password.value, type: AuthCertTypeEnum.Private.value, extra: {} };
}
state.dvisible = true;
};
const deleteRow = (idx: any) => {
authCerts.value.splice(idx, 1);
};
const cancelEdit = () => {
state.dvisible = false;
};
const btnOk = async (authCert: any) => {
const isEdit = authCert.id;
// if (!isEdit) {
// const res = await resourceAuthCertApi.listByQuery.request({
// name: authCert.name,
// pageNum: 1,
// pageSize: 100,
// });
// if (res.total) {
// ElMessage.error('该授权凭证名称已存在');
// return;
// }
// }
if (isEdit || state.idx >= 0) {
authCerts.value[state.idx] = authCert;
cancelEdit();
return;
}
if (authCerts.value?.filter((x: any) => x.username == authCert.username).length > 0) {
ElMessage.error('该用户名已存在于该账号列表中');
return;
}
authCerts.value.push(authCert);
cancelEdit();
};
</script>
<style lang="scss"></style>

View File

@@ -1,8 +1,8 @@
<template>
<div v-if="props.tags">
<el-row v-for="(tag, idx) in props.tags?.slice(0, 1)" :key="idx">
<TagInfo :tag-path="tag.tagPath" />
<span class="ml3">{{ tag.tagPath }}</span>
<TagInfo :tag-path="tag.codePath" />
<span class="ml3">{{ tag.codePath }}</span>
<!-- 展示剩余的标签信息 -->
<el-popover :show-after="300" v-if="props.tags.length > 1 && idx == 0" placement="top-start" width="230" trigger="hover">
@@ -11,8 +11,8 @@
</template>
<el-row v-for="i in props.tags.slice(1)" :key="i">
<TagInfo :tag-path="i.tagPath" />
<span class="ml3">{{ i.tagPath }}</span>
<TagInfo :tag-path="i.codePath" />
<span class="ml3">{{ i.codePath }}</span>
</el-row>
</el-popover>
</el-row>

View File

@@ -17,6 +17,7 @@
<script lang="ts" setup>
import { toRefs, reactive, onMounted } from 'vue';
import { machineApi } from '../machine/api';
import { MachineProtocolEnum } from '../machine/enums';
const props = defineProps({
modelValue: {
@@ -46,7 +47,7 @@ onMounted(async () => {
const getSshTunnelMachines = async () => {
if (state.sshTunnelMachineList.length == 0) {
const res = await machineApi.list.request({ pageNum: 1, pageSize: 100 });
const res = await machineApi.list.request({ pageNum: 1, pageSize: 100, protocol: MachineProtocolEnum.Ssh.value });
state.sshTunnelMachineList = res.list;
}
};

View File

@@ -0,0 +1,145 @@
<template>
<div v-if="codePaths">
<el-row v-for="(path, idx) in codePaths?.slice(0, 1)" :key="idx">
<span v-for="item in path" :key="item.code">
<SvgIcon
:name="EnumValue.getEnumByValue(TagResourceTypeEnum, item.type)?.extra.icon"
:color="EnumValue.getEnumByValue(TagResourceTypeEnum, item.type)?.extra.iconColor"
class="mr2"
/>
<span> {{ item.name ? item.name : item.code }}</span>
<SvgIcon v-if="!item.isEnd" class="mr5 ml5" name="arrow-right" />
</span>
<!-- 展示剩余的标签信息 -->
<el-popover :show-after="300" v-if="paths.length > 1 && idx == 0" placement="bottom" width="500" trigger="hover">
<template #reference>
<SvgIcon class="mt5 ml5" color="var(--el-color-primary)" name="MoreFilled" />
</template>
<el-row v-for="i in paths.slice(1)" :key="i">
<span v-for="item in parseTagPath(i)" :key="item.code">
<SvgIcon
:name="EnumValue.getEnumByValue(TagResourceTypeEnum, item.type)?.extra.icon"
:color="EnumValue.getEnumByValue(TagResourceTypeEnum, item.type)?.extra.iconColor"
class="mr2"
/>
<span> {{ item.name ? item.name : item.code }}</span>
<SvgIcon v-if="!item.isEnd" class="mr5 ml5" name="arrow-right" />
</span>
</el-row>
</el-popover>
</el-row>
</div>
</template>
<script lang="ts" setup>
import { TagResourceTypeEnum } from '@/common/commonEnum';
import EnumValue from '@/common/Enum';
import { computed, onMounted, ref, watch } from 'vue';
import { getAllTagInfoByCodePaths } from './tag';
const props = defineProps({
path: {
type: [String, Array<string>],
},
tagInfos: {
type: Object, // key: code , value: code info
},
});
const codePaths: any = ref([]);
let allTagInfos: any = {};
const paths = computed(() => {
if (Array.isArray(props.path)) {
return props.path;
}
return [props.path];
});
onMounted(() => {
setCodePaths();
});
watch(
() => props.path,
() => {
setCodePaths();
}
);
const setCodePaths = async () => {
if (!paths.value) {
return;
}
if (!props.tagInfos || Object.keys(props.tagInfos).length == 0) {
const tagInfos = await getAllTagInfoByCodePaths(paths.value as any);
allTagInfos = tagInfos;
} else {
allTagInfos = props.tagInfos;
}
codePaths.value = paths.value.map((p) => parseTagPath(p));
};
const parseTagPath = (tagPath: string = '') => {
if (!tagPath) {
return [];
}
const res = [] as any;
const codes = tagPath.split('/');
for (let code of codes) {
const typeAndCode = code.split('|');
let tagInfo;
if (typeAndCode.length == 1) {
const tagCode = typeAndCode[0];
if (!tagCode) {
continue;
}
tagInfo = {
type: TagResourceTypeEnum.Tag.value,
code: typeAndCode[0],
};
res.push(tagInfo);
continue;
} else {
tagInfo = {
type: typeAndCode[0],
code: typeAndCode[1],
name: '',
};
}
const ti = getTagInfo(tagInfo.type, tagInfo.code);
if (ti) {
tagInfo.name = ti.name;
}
res.push(tagInfo);
}
res[res.length - 1].isEnd = true;
return res;
};
const getTagInfo = (type: any, code: string) => {
if (type == TagResourceTypeEnum.Tag.value) {
return {};
}
if (allTagInfos && Object.keys(allTagInfos).length > 0) {
const key = `${type}|${code}`;
if (allTagInfos[key]) {
return allTagInfos[key];
}
}
return {};
};
</script>
<style lang="scss"></style>

View File

@@ -1,7 +1,7 @@
<template>
<div class="tag-tree card pd5">
<el-scrollbar>
<el-input v-model="filterText" placeholder="输入关键字->搜索已展开节点信息" clearable size="small" class="mb5 w100" />
<div class="card pd5">
<el-input v-model="filterText" placeholder="输入关键字->搜索已展开节点信息" clearable size="small" class="mb5 w100" />
<el-scrollbar class="tag-tree">
<el-tree
ref="treeRef"
:highlight-current="true"
@@ -10,14 +10,15 @@
:props="treeProps"
lazy
node-key="key"
:expand-on-click-node="true"
:expand-on-click-node="false"
:filter-node-method="filterNode"
@node-click="treeNodeClick"
@node-expand="treeNodeClick"
@node-contextmenu="nodeContextmenu"
:default-expanded-keys="props.defaultExpandedKeys"
>
<template #default="{ node, data }">
<span @dblclick="treeNodeDblclick(data)" :class="data.type.nodeDblclickFunc ? 'none-select' : ''">
<span :id="node.key" @dblclick="treeNodeDblclick(data)" :class="data.type.nodeDblclickFunc ? 'none-select' : ''">
<span v-if="data.type.value == TagTreeNode.TagPath">
<tag-info :tag-path="data.label" />
</span>
@@ -34,7 +35,9 @@
</slot>
</span>
<slot :node="node" :data="data" name="suffix"></slot>
<span class="label-suffix">
<slot :node="node" :data="data" name="suffix"></slot>
</span>
</span>
</template>
</el-tree>
@@ -45,17 +48,21 @@
</template>
<script lang="ts" setup>
import { onMounted, reactive, ref, watch, toRefs } from 'vue';
import { nextTick, onMounted, reactive, ref, toRefs, watch } from 'vue';
import { NodeType, TagTreeNode } from './tag';
import TagInfo from './TagInfo.vue';
import { Contextmenu } from '@/components/contextmenu';
import { tagApi } from '../tag/api';
import { isPrefixSubsequence } from '@/common/utils/string';
const props = defineProps({
resourceType: {
type: [Number],
required: true,
},
defaultExpandedKeys: {
type: [Array],
},
tagPathNodeType: {
type: [NodeType],
required: true,
@@ -99,8 +106,7 @@ watch(filterText, (val) => {
});
const filterNode = (value: string, data: any) => {
if (!value) return true;
return data.label.includes(value);
return !value || isPrefixSubsequence(value, data.label);
};
/**
@@ -120,7 +126,7 @@ const loadTags = async () => {
* @param { Object } node
* @param { Object } resolve
*/
const loadNode = async (node: any, resolve: any) => {
const loadNode = async (node: any, resolve: (data: any) => void, reject: () => void) => {
if (typeof resolve !== 'function') {
return;
}
@@ -135,14 +141,16 @@ const loadNode = async (node: any, resolve: any) => {
}
} catch (e: any) {
console.error(e);
// 调用 reject 以保持节点状态,并允许远程加载继续。
return reject();
}
return resolve(nodes);
};
const treeNodeClick = (data: any) => {
emit('nodeClick', data);
const treeNodeClick = async (data: any) => {
if (!data.disabled && !data.type.nodeDblclickFunc && data.type.nodeClickFunc) {
data.type.nodeClickFunc(data);
emit('nodeClick', data);
await data.type.nodeClickFunc(data);
}
// 关闭可能存在的右击菜单
contextmenuRef.value.closeContextmenu();
@@ -199,18 +207,43 @@ const getNode = (nodeKey: any) => {
return node;
};
const setCurrentKey = (nodeKey: any) => {
treeRef.value.setCurrentKey(nodeKey);
// 通过Id获取到对应的dom元素
const node = document.getElementById(nodeKey);
if (node) {
setTimeout(() => {
nextTick(() => {
// 通过scrollIntoView方法将对应的dom元素定位到可见区域 【block: 'center'】这个属性是在垂直方向居中显示
node.scrollIntoView({ block: 'center' });
});
}, 100);
}
};
defineExpose({
reloadNode,
getNode,
setCurrentKey,
});
</script>
<style lang="scss" scoped>
.tag-tree {
height: calc(100vh - 108px);
height: calc(100vh - 148px);
.el-tree {
display: inline-block;
min-width: 100%;
}
.label-suffix {
position: absolute;
right: 10px;
color: #c4c9c4;
font-size: 10px;
margin-top: 2px;
}
}
</style>

View File

@@ -0,0 +1,167 @@
<template>
<div class="w100 tag-tree-check">
<el-input v-model="filterTag" @input="onFilterValChanged" clearable placeholder="输入关键字过滤" size="small" />
<div class="mt3" style="border: 1px solid var(--el-border-color)">
<el-scrollbar :style="{ height: props.height }">
<el-tree
v-bind="$attrs"
ref="tagTreeRef"
:data="state.tags"
:default-expanded-keys="state.defaultExpandedKeys"
:default-checked-keys="checkedTags"
multiple
:render-after-expand="true"
show-checkbox
check-strictly
:node-key="$props.nodeKey"
:props="{
value: $props.nodeKey,
label: 'codePath',
children: 'children',
disabled: 'disabled',
}"
@check="tagTreeNodeCheck"
:filter-node-method="filterNode"
>
<template #default="{ data }">
<span>
<SvgIcon
:name="EnumValue.getEnumByValue(TagResourceTypeEnum, data.type)?.extra.icon"
:color="EnumValue.getEnumByValue(TagResourceTypeEnum, data.type)?.extra.iconColor"
/>
<span class="font13 ml5">
{{ data.name }}
<span style="color: #3c8dbc"></span>
{{ data.code }}
<span style="color: #3c8dbc"></span>
<el-tag v-if="data.children !== null" size="small">{{ data.children.length }} </el-tag>
</span>
</span>
</template>
</el-tree>
</el-scrollbar>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue';
import { tagApi } from '../tag/api';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import EnumValue from '@/common/Enum';
import { isPrefixSubsequence } from '@/common/utils/string';
const props = defineProps({
height: {
type: [String, Number],
default: 'calc(100vh - 330px)',
},
tagType: {
type: [Number, Array<Number>],
default: TagResourceTypeEnum.Tag.value,
},
nodeKey: {
type: String,
default: 'codePath',
},
});
const checkedTags = defineModel<Array<any>>('modelValue', {
default: () => [],
});
const tagTreeRef: any = ref(null);
const filterTag = ref('');
const state = reactive({
defaultExpandedKeys: [] as any,
tags: [],
});
onMounted(() => {
state.defaultExpandedKeys = checkedTags.value;
search();
});
const search = async () => {
let tagType: any = props.tagType;
if (Array.isArray(props.tagType)) {
tagType = props.tagType.join(',');
}
state.tags = await tagApi.getTagTrees.request({ type: tagType });
setTimeout(() => {
const checkedNodes = tagTreeRef.value.getCheckedNodes();
console.log('check nodes: ', checkedNodes);
// 禁用选中节点的所有父节点,不可选中
for (let checkNodeData of checkedNodes) {
disableParentNodes(tagTreeRef.value.getNode(checkNodeData.codePath).parent);
}
}, 200);
};
const filterNode = (value: string, data: any) => {
return !value || isPrefixSubsequence(value, data.codePath) || isPrefixSubsequence(value, data.name);
};
const onFilterValChanged = (val: string) => {
tagTreeRef.value!.filter(val);
};
const tagTreeNodeCheck = (data: any) => {
const node = tagTreeRef.value.getNode(data.codePath);
console.log('check node: ', node);
if (node.checked) {
// 如果选中了子节点,则需要将父节点全部取消选中,并禁用父节点
unCheckParentNodes(node.parent);
disableParentNodes(node.parent);
} else {
// 如果取消了选中,则需要根据条件恢复父节点的选中状态
disableParentNodes(node.parent, false);
}
// 更新绑定的值
checkedTags.value = tagTreeRef.value.getCheckedKeys(false);
};
const unCheckParentNodes = (node: any) => {
if (!node) {
return;
}
tagTreeRef.value.setChecked(node, false, false);
unCheckParentNodes(node.parent);
};
/**
* 禁用该节点以及所有父节点
* @param node 节点
* @param disable 是否禁用
*/
const disableParentNodes = (node: any, disable = true) => {
if (!node) {
return;
}
if (!disable) {
// 恢复为非禁用状态时,若同层级存在一个选中状态或者禁用状态,则继续禁用 不恢复非禁用状态。
for (let oneLevelNodes of node.childNodes) {
if (oneLevelNodes.checked || oneLevelNodes.data.disabled) {
return;
}
}
}
node.data.disabled = disable;
disableParentNodes(node.parent, disable);
};
</script>
<style lang="scss" scoped>
.tag-tree-check {
.el-tree {
min-width: 100%;
// 横向滚动生效
display: inline-block;
}
}
</style>

View File

@@ -4,21 +4,20 @@
v-bind="$attrs"
v-model="state.selectTags"
@change="changeTag"
style="width: 100%"
:data="tags"
placeholder="请选择关联标签"
:render-after-expand="true"
:default-expanded-keys="[state.selectTags]"
:default-expanded-keys="defaultExpandedKeys"
show-checkbox
node-key="id"
node-key="codePath"
:props="{
value: 'id',
value: 'codePath',
label: 'codePath',
children: 'children',
}"
>
<template #default="{ data }">
<span class="custom-tree-node">
<SvgIcon :name="EnumValue.getEnumByValue(TagResourceTypeEnum, data.type)?.extra.icon" class="mr2" />
<span style="font-size: 13px">
{{ data.code }}
<span style="color: #3c8dbc"></span>
@@ -33,32 +32,45 @@
</template>
<script lang="ts" setup>
import { toRefs, reactive, onMounted } from 'vue';
import { toRefs, reactive, onMounted, computed } from 'vue';
import { tagApi } from '../tag/api';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import EnumValue from '@/common/Enum';
//定义事件
const emit = defineEmits(['update:modelValue', 'changeTag', 'input']);
const props = defineProps({
selectTags: {
type: [Array<any>],
type: [Array<any>, Object],
},
tagType: {
type: Number,
default: TagResourceTypeEnum.Tag.value,
},
});
const state = reactive({
tags: [],
// 单选则为id多选为id数组
// 单选则为codePath多选为codePath数组
selectTags: [] as any,
});
const { tags } = toRefs(state);
onMounted(async () => {
if (props.selectTags) {
state.selectTags = props.selectTags;
const defaultExpandedKeys = computed(() => {
if (Array.isArray(state.selectTags)) {
// 如果 state.selectTags 是数组,直接返回
return state.selectTags;
}
state.tags = await tagApi.getTagTrees.request(null);
// 如果 state.selectTags 不是数组,转换为包含 state.selectTags 的数组
return [state.selectTags];
});
onMounted(async () => {
state.selectTags = props.selectTags;
state.tags = await tagApi.getTagTrees.request({ type: props.tagType });
});
const changeTag = () => {

View File

@@ -1,6 +1,7 @@
import { OptionsApi, SearchItem } from '@/components/SearchForm';
import { ContextmenuItem } from '@/components/contextmenu';
import { tagApi } from '../tag/api';
import {OptionsApi, SearchItem} from '@/components/SearchForm';
import {ContextmenuItem} from '@/components/contextmenu';
import {TagResourceTypeEnum} from '@/common/commonEnum';
import {tagApi} from '../tag/api';
export class TagTreeNode {
/**
@@ -161,7 +162,7 @@ export class NodeType {
*/
export function getTagPathSearchItem(resourceType: number) {
return SearchItem.select('tagPath', '标签').withOptionsApi(
OptionsApi.new(tagApi.getResourceTagPaths, { resourceType }).withConvertFn((res: any) => {
OptionsApi.new(tagApi.getResourceTagPaths, {resourceType}).withConvertFn((res: any) => {
return res.map((x: any) => {
return {
label: x,
@@ -171,3 +172,79 @@ export function getTagPathSearchItem(resourceType: number) {
})
);
}
/**
* 根据标签路径获取对应的类型与编号数组
* @param codePath 编号路径 tag1/tag2/1|xxx/11|yyy/
* @returns {1: ['xxx'], 11: ['yyy']}
*/
export function getTagTypeCodeByPath(codePath: string) {
const result: any = {};
if (!codePath) return result
const parts = codePath.split('/'); // 切分字符串并保留数字和对应的值部分
for (let part of parts) {
if (!part) {
continue;
}
let [key, value] = part.split('|'); // 分割数字和值部分
// 如果不存在第二个参数,则说明为标签类型
if (!value) {
value = key;
key = '-1';
}
if (!result[key]) {
result[key] = [];
}
result[key].push(value);
}
return result;
}
/**
* 完善标签路径信息
* @param codePaths 标签路径
* @returns
*/
export async function getAllTagInfoByCodePaths(codePaths: string[]) {
if (!codePaths) return
const allTypeAndCode: any = {};
for (let codePath of codePaths) {
const typeAndCode = getTagTypeCodeByPath(codePath);
for (let type in typeAndCode) {
allTypeAndCode[type] = [...new Set(typeAndCode[type].concat(allTypeAndCode[type] || []))];
}
}
for (let type in allTypeAndCode) {
if (type == TagResourceTypeEnum.Tag.value) {
continue;
}
const tagInfo = await tagApi.listByQuery.request({type: type, codes: allTypeAndCode[type]});
allTypeAndCode[type] = tagInfo;
}
const code2CodeInfo: any = {};
for (let type in allTypeAndCode) {
for (let code of allTypeAndCode[type]) {
code2CodeInfo[`${type}|${code.code}`] = code;
}
}
return code2CodeInfo;
}
export function expandCodePath(codePath: string) {
const parts = codePath.split('/');
const result = [];
let currentPath = '';
for (let i = 0; i < parts.length - 1; i++) {
currentPath += parts[i] + '/';
result.push(currentPath);
}
return result;
}

View File

@@ -10,49 +10,36 @@
width="38%"
>
<el-form :model="form" ref="dbForm" :rules="rules" label-width="auto">
<el-form-item ref="tagSelectRef" prop="tagId" label="标签" required>
<tag-tree-select
@change-tag="
(tagIds) => {
form.tagId = tagIds;
tagSelectRef.validate();
}
"
multiple
:select-tags="form.tagId"
style="width: 100%"
/>
<el-form-item prop="name" label="名称" required>
<el-input v-model.trim="form.name" placeholder="请输入数据库别名" auto-complete="off"></el-input>
</el-form-item>
<el-form-item prop="instanceId" label="数据库实例" required>
<el-select
:disabled="form.id !== undefined"
remote
:remote-method="getInstances"
@change="changeInstance"
v-model="form.instanceId"
placeholder="请输入实例名称搜索并选择实例"
filterable
clearable
class="w100"
>
<el-option v-for="item in state.instances" :key="item.id" :label="`${item.name}`" :value="item.id">
<el-form-item prop="authCertName" label="授权凭证" required>
<el-select v-model="form.authCertName" placeholder="请选择授权凭证" filterable>
<el-option v-for="item in state.authCerts" :key="item.id" :label="`${item.name}`" :value="item.name">
{{ item.name }}
<el-divider direction="vertical" border-style="dashed" />
{{ item.type }} / {{ item.host }}:{{ item.port }}
<el-divider direction="vertical" border-style="dashed" />
{{ item.username }}
<el-divider direction="vertical" border-style="dashed" />
<EnumTag :value="item.ciphertextType" :enums="AuthCertCiphertextTypeEnum" />
<el-divider direction="vertical" border-style="dashed" />
{{ item.remark }}
</el-option>
</el-select>
</el-form-item>
<el-form-item prop="name" label="别名" required>
<el-input v-model.trim="form.name" placeholder="请输入数据库别名" auto-complete="off"></el-input>
<el-form-item prop="getDatabaseMode" label="获库方式" required>
<el-select v-model="form.getDatabaseMode" @change="onChangeGetDatabaseMode" placeholder="请选择库名获取方式">
<el-option v-for="item in DbGetDbNamesMode" :key="item.value" :label="item.label" :value="item.value"> </el-option>
</el-select>
</el-form-item>
<el-form-item prop="database" label="数据库名">
<el-select
:disabled="form.getDatabaseMode == DbGetDbNamesMode.Auto.value || !form.authCertName"
v-model="dbNamesSelected"
multiple
clearable
@@ -61,8 +48,9 @@
filterable
:filter-method="filterDbNames"
allow-create
placeholder="请确保数据库实例信息填写完整后获取库名"
style="width: 100%"
placeholder="获库方式为‘指定库名’时,可选择"
@focus="getAllDatabase(form.authCertName)"
:loading="state.loadingDbNames"
>
<template #header>
<el-checkbox v-model="checkAllDbNames" :indeterminate="indeterminateDbNames" @change="handleCheckAll"> 全选 </el-checkbox>
@@ -74,14 +62,12 @@
<el-form-item prop="remark" label="备注">
<el-input v-model.trim="form.remark" auto-complete="off" type="textarea"></el-input>
</el-form-item>
<procdef-select-form-item v-model="form.flowProcdefKey" />
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancel()"> </el-button>
<el-button type="primary" :loading="saveBtnLoading" @click="btnOk">确 定</el-button>
<el-button type="primary" @click="btnOk"> </el-button>
</div>
</template>
</el-dialog>
@@ -92,15 +78,22 @@
import { toRefs, reactive, watch, ref } from 'vue';
import { dbApi } from './api';
import { ElMessage } from 'element-plus';
import TagTreeSelect from '../component/TagTreeSelect.vue';
import type { CheckboxValueType } from 'element-plus';
import ProcdefSelectFormItem from '@/views/flow/components/ProcdefSelectFormItem.vue';
import { DbType } from '@/views/ops/db/dialect';
import EnumTag from '@/components/enumtag/EnumTag.vue';
import { AuthCertCiphertextTypeEnum } from '../tag/enums';
import { resourceAuthCertApi } from '../tag/api';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { DbGetDbNamesMode } from './enums';
const props = defineProps({
visible: {
type: Boolean,
},
instance: {
type: [Boolean, Object],
},
db: {
type: [Boolean, Object],
},
@@ -110,7 +103,7 @@ const props = defineProps({
});
//定义事件
const emit = defineEmits(['update:visible', 'cancel', 'val-change']);
const emit = defineEmits(['update:visible', 'cancel', 'val-change', 'confirm']);
const rules = {
tagId: [
@@ -128,7 +121,6 @@ const rules = {
trigger: ['change', 'blur'],
},
],
name: [
{
required: true,
@@ -136,10 +128,17 @@ const rules = {
trigger: ['change', 'blur'],
},
],
database: [
authCertName: [
{
required: true,
message: '请添加数据库',
message: '请选择授权凭证',
trigger: ['change', 'blur'],
},
],
getDatabaseMode: [
{
required: true,
message: '请选择库名获取方式',
trigger: ['change', 'blur'],
},
],
@@ -149,7 +148,7 @@ const checkAllDbNames = ref(false);
const indeterminateDbNames = ref(false);
const dbForm: any = ref(null);
const tagSelectRef: any = ref(null);
// const tagSelectRef: any = ref(null);
const state = reactive({
dialogVisible: false,
@@ -157,47 +156,67 @@ const state = reactive({
dbNamesSelected: [] as any,
dbNamesFiltered: [] as any,
filterString: '',
selectInstalce: {} as any,
authCerts: [] as any,
form: {
id: null,
tagId: [],
name: null,
code: '',
getDatabaseMode: DbGetDbNamesMode.Auto.value,
database: '',
remark: '',
instanceId: null as any,
flowProcdefKey: '',
authCertName: '',
},
instances: [] as any,
loadingDbNames: false,
});
const { dialogVisible, allDatabases, form, dbNamesSelected } = toRefs(state);
const { isFetching: saveBtnLoading, execute: saveDbExec } = dbApi.saveDb.useApi(form);
watch(props, async (newValue: any) => {
state.dialogVisible = newValue.visible;
if (!state.dialogVisible) {
return;
watch(
() => props.visible,
() => {
state.dialogVisible = props.visible;
if (!state.dialogVisible) {
return;
}
const db: any = props.db;
if (db.code) {
state.form = { ...db };
if (db.getDatabaseMode == DbGetDbNamesMode.Assign.value) {
// 将数据库名使用空格切割,获取所有数据库列表
state.dbNamesSelected = db.database.split(' ');
}
} else {
state.form = { getDatabaseMode: DbGetDbNamesMode.Auto.value } as any;
state.dbNamesSelected = [];
}
}
if (newValue.db) {
state.form = { ...newValue.db };
state.form.tagId = newValue.db.tags.map((t: any) => t.tagId);
// 将数据库名使用空格切割,获取所有数据库列表
state.dbNamesSelected = newValue.db.database.split(' ');
} else {
state.form = {} as any;
);
const onChangeGetDatabaseMode = (val: any) => {
if (val == DbGetDbNamesMode.Auto.value) {
state.dbNamesSelected = [];
}
});
const changeInstance = () => {
state.dbNamesSelected = [];
getAllDatabase();
};
const getAllDatabase = async () => {
if (state.form.instanceId > 0) {
let dbs = await dbApi.getAllDatabase.request({ instanceId: state.form.instanceId });
const getAuthCerts = async () => {
const inst: any = props.instance;
const res = await resourceAuthCertApi.listByQuery.request({
resourceCode: inst.code,
resourceType: TagResourceTypeEnum.Db.value,
pageSize: 100,
});
state.authCerts = res.list || [];
};
const getAllDatabase = async (authCertName: string) => {
try {
state.loadingDbNames = true;
const req = { ...(props.instance as any) };
req.authCert = state.authCerts?.find((x: any) => x.name == authCertName);
let dbs = await dbApi.getAllDatabase.request(req);
state.allDatabases = dbs;
// 如果是oracle且没查出数据库列表则取实例sid
@@ -205,40 +224,27 @@ const getAllDatabase = async () => {
if (instance && instance.type === DbType.oracle && dbs.length === 0) {
state.allDatabases = [instance.sid];
}
}
};
const getInstances = async (instanceName: string = '', id = 0) => {
if (!id && !instanceName) {
state.instances = [];
return;
}
const data = await dbApi.instances.request({ id, name: instanceName });
if (data) {
state.instances = data.list;
} finally {
state.loadingDbNames = false;
}
};
const open = async () => {
if (state.form.instanceId) {
// 根据id获取因为需要回显实例名称
await getInstances('', state.form.instanceId);
await getAuthCerts();
if (state.form.authCertName) {
await getAllDatabase(state.form.authCertName);
}
await getAllDatabase();
};
const btnOk = async () => {
dbForm.value.validate(async (valid: boolean) => {
if (!valid) {
ElMessage.error('请正确填写信息');
return false;
}
try {
await dbForm.value.validate();
} catch (e: any) {
ElMessage.error('请正确填写信息');
return false;
}
await saveDbExec();
ElMessage.success('保存成功');
emit('val-change', state.form);
cancel();
});
emit('confirm', state.form);
};
const resetInputDb = () => {

View File

@@ -1,103 +1,126 @@
<template>
<div class="db-list">
<page-table
ref="pageTableRef"
:page-api="dbApi.dbs"
:before-query-fn="checkRouteTagPath"
:search-items="searchItems"
v-model:query-form="query"
:show-selection="true"
v-model:selection-data="state.selectionData"
:columns="columns"
<el-drawer
:title="title"
v-model="dialogVisible"
@open="search"
:before-close="cancel"
:destroy-on-close="true"
:close-on-click-modal="true"
size="60%"
>
<template #instanceSelect>
<el-select remote :remote-method="getInstances" v-model="query.instanceId" placeholder="输入并选择实例" filterable clearable>
<el-option v-for="item in state.instances" :key="item.id" :label="`${item.name}`" :value="item.id">
{{ item.name }}
<el-divider direction="vertical" border-style="dashed" />
{{ item.type }} / {{ item.host }}:{{ item.port }}
<el-divider direction="vertical" border-style="dashed" />
{{ item.username }}
</el-option>
</el-select>
</template>
<template #tableHeader>
<el-button v-auth="perms.saveDb" type="primary" icon="plus" @click="editDb(false)">添加</el-button>
<el-button v-auth="perms.delDb" :disabled="selectionData.length < 1" @click="deleteDb()" type="danger" icon="delete">删除</el-button>
</template>
<template #type="{ data }">
<el-tooltip :content="data.type" placement="top">
<SvgIcon :name="getDbDialect(data.type).getInfo().icon" :size="20" />
</el-tooltip>
</template>
<template #host="{ data }">
{{ `${data.host}:${data.port}` }}
</template>
<template #tagPath="{ data }">
<ResourceTags :tags="data.tags" />
</template>
<template #action="{ data }">
<span v-if="actionBtns[perms.saveDb]">
<el-button type="primary" @click="editDb(data)" link>编辑</el-button>
<el-divider direction="vertical" border-style="dashed" />
</span>
<el-button type="primary" @click="onShowSqlExec(data)" link>SQL记录</el-button>
<el-divider direction="vertical" border-style="dashed" />
<el-dropdown @command="handleMoreActionCommand">
<span class="el-dropdown-link-more">
更多
<el-icon class="el-icon--right">
<arrow-down />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item :command="{ type: 'detail', data }"> 详情 </el-dropdown-item>
<el-dropdown-item :command="{ type: 'dumpDb', data }" v-if="supportAction('dumpDb', data.type)"> 导出 </el-dropdown-item>
<el-dropdown-item :command="{ type: 'backupDb', data }" v-if="actionBtns[perms.backupDb] && supportAction('backupDb', data.type)">
备份任务
</el-dropdown-item>
<el-dropdown-item
:command="{ type: 'backupHistory', data }"
v-if="actionBtns[perms.backupDb] && supportAction('backupDb', data.type)"
>
备份历史
</el-dropdown-item>
<el-dropdown-item
:command="{ type: 'restoreDb', data }"
v-if="actionBtns[perms.restoreDb] && supportAction('restoreDb', data.type)"
>
恢复任务
</el-dropdown-item>
</el-dropdown-menu>
<template #header>
<DrawerHeader :header="title" :back="cancel">
<template #extra>
<div class="mr20">
<span>{{ $props.instance?.tags?.[0]?.codePath }}</span>
<el-divider direction="vertical" border-style="dashed" />
<SvgIcon :name="getDbDialect($props.instance?.type).getInfo()?.icon" :size="20" />
<el-divider direction="vertical" border-style="dashed" />
<span>{{ $props.instance?.host }}:{{ $props.instance?.port }}</span>
</div>
</template>
</el-dropdown>
</DrawerHeader>
</template>
</page-table>
<el-dialog width="750px" :title="`${db} 数据库导出`" v-model="exportDialog.visible">
<page-table
ref="pageTableRef"
:page-api="dbApi.dbs"
v-model:query-form="query"
:columns="columns"
lazy
show-selection
v-model:selection-data="state.selectionData"
>
<template #tableHeader>
<el-button v-auth="perms.saveDb" type="primary" circle icon="Plus" @click="editDb(null)"> </el-button>
<el-button v-auth="perms.delDb" :disabled="state.selectionData.length < 1" @click="deleteDb" type="danger" circle icon="delete"></el-button>
</template>
<template #type="{ data }">
<el-tooltip :content="data.type" placement="top">
<SvgIcon :name="getDbDialect(data.type).getInfo().icon" :size="20" />
</el-tooltip>
</template>
<template #database="{ data }">
<el-popover placement="bottom" :width="200" trigger="click">
<template #reference>
<el-button @click="getDbNames(data)" type="primary" link>查看库</el-button>
</template>
<el-table :data="filterDbs" v-loading="state.loadingDbNames" size="small">
<el-table-column prop="dbName" label="数据库">
<template #header>
<el-input v-model="state.dbNameSearch" size="small" placeholder="库名: 输入可过滤" clearable />
</template>
</el-table-column>
</el-table>
</el-popover>
</template>
<template #tagPath="{ data }">
<ResourceTags :tags="data.tags" />
</template>
<template #action="{ data }">
<el-button v-auth="perms.saveDb" @click="editDb(data)" type="primary" link>编辑</el-button>
<el-divider direction="vertical" border-style="dashed" />
<el-button type="primary" @click="onShowSqlExec(data)" link>SQL记录</el-button>
<el-divider direction="vertical" border-style="dashed" />
<el-dropdown @command="handleMoreActionCommand">
<span class="el-dropdown-link-more">
更多
<el-icon class="el-icon--right">
<arrow-down />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item :command="{ type: 'dumpDb', data }"> 导出 </el-dropdown-item>
<!-- <el-dropdown-item
:command="{ type: 'backupDb', data }"
v-if="actionBtns[perms.backupDb] && supportAction('backupDb', data.type)"
>
备份任务
</el-dropdown-item>
<el-dropdown-item
:command="{ type: 'backupHistory', data }"
v-if="actionBtns[perms.backupDb] && supportAction('backupDb', data.type)"
>
备份历史
</el-dropdown-item>
<el-dropdown-item
:command="{ type: 'restoreDb', data }"
v-if="actionBtns[perms.restoreDb] && supportAction('restoreDb', data.type)"
>
恢复任务
</el-dropdown-item> -->
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</page-table>
</el-drawer>
<el-dialog width="750px" :title="`${exportDialog.db} 数据库导出`" v-model="exportDialog.visible">
<el-row justify="space-between">
<el-col :span="9">
<el-form-item label="导出内容: ">
<el-checkbox-group v-model="exportDialog.contents" :min="1">
<el-checkbox label="结构" />
<el-checkbox label="数据" />
<el-checkbox label="结构" value="结构" />
<el-checkbox label="数据" value="数据" />
</el-checkbox-group>
</el-form-item>
</el-col>
<el-col :span="9">
<el-form-item label="扩展名: ">
<el-radio-group v-model="exportDialog.extName">
<el-radio label="sql" />
<el-radio label="gzip" />
<el-radio label="sql" value="sql" />
<el-radio label="gzip" value="gzip" />
</el-radio-group>
</el-form-item>
</el-col>
@@ -164,64 +187,65 @@
<db-restore-list :dbId="dbRestoreDialog.dbId" :dbNames="dbRestoreDialog.dbs" />
</el-dialog>
<el-dialog v-if="infoDialog.visible" v-model="infoDialog.visible" :before-close="onBeforeCloseInfoDialog">
<el-descriptions title="详情" :column="3" border>
<el-descriptions-item :span="2" label="名称">{{ infoDialog.data?.name }}</el-descriptions-item>
<el-descriptions-item :span="1" label="id">{{ infoDialog.data?.id }}</el-descriptions-item>
<el-descriptions-item :span="3" label="关联标签"><ResourceTags :tags="infoDialog.data.tags" /></el-descriptions-item>
<el-descriptions-item :span="3" label="数据库实例名称">{{ infoDialog.instance?.name }}</el-descriptions-item>
<el-descriptions-item :span="2" label="主机">{{ infoDialog.instance?.host }}</el-descriptions-item>
<el-descriptions-item :span="1" label="端口">{{ infoDialog.instance?.port }}</el-descriptions-item>
<el-descriptions-item :span="2" label="用户名">{{ infoDialog.instance?.username }}</el-descriptions-item>
<el-descriptions-item :span="1" label="类型">
<SvgIcon :name="getDbDialect(infoDialog.instance?.type).getInfo().icon" :size="20" />{{ infoDialog.instance?.type }}
</el-descriptions-item>
<el-descriptions-item :span="3" label="数据库">{{ infoDialog.data?.database }}</el-descriptions-item>
<el-descriptions-item :span="3" label="备注">{{ infoDialog.data?.remark }}</el-descriptions-item>
<el-descriptions-item :span="3" label="工单流程key">{{ infoDialog.data?.flowProcdefKey }}</el-descriptions-item>
<el-descriptions-item :span="2" label="创建时间">{{ dateFormat(infoDialog.data?.createTime) }} </el-descriptions-item>
<el-descriptions-item :span="1" label="创建者">{{ infoDialog.data?.creator }}</el-descriptions-item>
<el-descriptions-item :span="2" label="更新时间">{{ dateFormat(infoDialog.data?.updateTime) }} </el-descriptions-item>
<el-descriptions-item :span="1" label="修改者">{{ infoDialog.data?.modifier }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
<db-edit @val-change="search" :title="dbEditDialog.title" v-model:visible="dbEditDialog.visible" v-model:db="dbEditDialog.data"></db-edit>
<db-edit
@confirm="confirmEditDb"
@cancel="cancelEditDb"
:title="dbEditDialog.title"
v-model:visible="dbEditDialog.visible"
:instance="props.instance"
v-model:db="dbEditDialog.data"
></db-edit>
</div>
</template>
<script lang="ts" setup>
import { ref, toRefs, reactive, onMounted, defineAsyncComponent, Ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { computed, defineAsyncComponent, reactive, ref, Ref, toRefs } from 'vue';
import { dbApi } from './api';
import config from '@/common/config';
import { joinClientParams } from '@/common/request';
import { isTrue } from '@/common/assert';
import { dateFormat } from '@/common/utils/date';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { hasPerms } from '@/components/auth/auth';
import DbSqlExecLog from './DbSqlExecLog.vue';
import { DbType } from './dialect';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { useRoute } from 'vue-router';
import { getDbDialect } from './dialect/index';
import { getTagPathSearchItem } from '../component/tag';
import { SearchItem } from '@/components/SearchForm';
import DbBackupList from './DbBackupList.vue';
import DbBackupHistoryList from './DbBackupHistoryList.vue';
import DbRestoreList from './DbRestoreList.vue';
import ResourceTags from '../component/ResourceTags.vue';
import { sleep } from '@/common/utils/loading';
import { DbGetDbNamesMode } from './enums';
import { DbInst } from './db';
import { ElMessage, ElMessageBox } from 'element-plus';
import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue';
const DbEdit = defineAsyncComponent(() => import('./DbEdit.vue'));
const props = defineProps({
instance: {
type: [Object],
required: true,
},
title: {
type: String,
},
});
const dialogVisible = defineModel<boolean>('visible');
const emit = defineEmits(['cancel']);
const columns = ref([
TableColumn.new('name', '名称'),
TableColumn.new('authCertName', '授权凭证'),
TableColumn.new('getDatabaseMode', '获库方式').typeTag(DbGetDbNamesMode),
TableColumn.new('database', '库').isSlot().setMinWidth(80),
TableColumn.new('remark', '备注'),
TableColumn.new('code', '编号'),
TableColumn.new('action', '操作').isSlot().setMinWidth(210).fixedRight().alignCenter(),
]);
const perms = {
base: 'db',
saveDb: 'db:save',
@@ -230,57 +254,31 @@ const perms = {
restoreDb: 'db:restore',
};
const searchItems = [getTagPathSearchItem(TagResourceTypeEnum.Db.value), SearchItem.slot('instanceId', '实例', 'instanceSelect')];
const actionBtns: any = hasPerms(Object.values(perms));
const columns = ref([
TableColumn.new('tags[0].tagPath', '关联标签').isSlot('tagPath').setAddWidth(20),
TableColumn.new('name', '名称'),
TableColumn.new('type', '类型').isSlot().setAddWidth(-15).alignCenter(),
TableColumn.new('instanceName', '实例名'),
TableColumn.new('host', 'ip:port').isSlot().setAddWidth(40),
TableColumn.new('username', 'username'),
TableColumn.new('flowProcdefKey', '关联流程'),
TableColumn.new('remark', '备注'),
]);
// 该用户拥有的的操作列按钮权限
// const actionBtns = hasPerms([perms.base, perms.saveDb]);
const actionBtns = hasPerms(Object.values(perms));
const actionColumn = TableColumn.new('action', '操作').isSlot().setMinWidth(220).fixedRight().alignCenter();
const route = useRoute();
const pageTableRef: Ref<any> = ref(null);
const state = reactive({
row: {} as any,
dbId: 0,
db: '',
loadingDbNames: false,
currentDbNames: [],
dbNameSearch: '',
instances: [] as any,
/**
* 选中的数据
*/
selectionData: [],
selectionData: [] as any,
/**
* 查询条件
*/
query: {
tagPath: '',
instanceId: null,
instanceId: 0,
pageNum: 1,
pageSize: 0,
},
infoDialog: {
visible: false,
data: null as any,
instance: null as any,
query: {
instanceId: 0,
},
},
// sql执行记录弹框
sqlExecLogDialog: {
title: '',
visible: false,
dbs: [],
dbs: [] as any,
dbId: 0,
},
// 数据库备份弹框
@@ -311,6 +309,7 @@ const state = reactive({
exportDialog: {
visible: false,
dbId: 0,
db: '',
type: 3,
data: [] as any,
value: [],
@@ -329,48 +328,77 @@ const state = reactive({
},
});
const { db, selectionData, query, infoDialog, sqlExecLogDialog, exportDialog, dbEditDialog, dbBackupDialog, dbBackupHistoryDialog, dbRestoreDialog } =
toRefs(state);
onMounted(async () => {
if (Object.keys(actionBtns).length > 0) {
columns.value.push(actionColumn);
}
});
const checkRouteTagPath = (query: any) => {
if (route.query.tagPath) {
query.tagPath = route.query.tagPath as string;
}
return query;
};
const { query, sqlExecLogDialog, exportDialog, dbEditDialog, dbBackupDialog, dbBackupHistoryDialog, dbRestoreDialog } = toRefs(state);
const search = async () => {
state.query.instanceId = props.instance?.id;
pageTableRef.value.search();
};
const showInfo = async (info: any) => {
state.infoDialog.data = info;
state.infoDialog.query.instanceId = info.instanceId;
const res = await dbApi.getInstance.request(state.infoDialog.query);
state.infoDialog.instance = res;
state.infoDialog.visible = true;
};
const onBeforeCloseInfoDialog = () => {
state.infoDialog.visible = false;
state.infoDialog.data = null;
state.infoDialog.instance = null;
};
const getInstances = async (instanceName = '') => {
if (!instanceName) {
state.instances = [];
return;
const getDbNames = async (db: any) => {
try {
state.loadingDbNames = true;
state.currentDbNames = await DbInst.getDbNames(db);
} finally {
state.loadingDbNames = false;
}
const data = await dbApi.instances.request({ name: instanceName });
};
const filterDbs = computed(() => {
const dbNames = state.currentDbNames;
if (!dbNames) {
return [];
}
const dbNameObjs = dbNames.map((x) => {
return {
dbName: x,
};
});
return dbNameObjs.filter((db: any) => {
return db.dbName.includes(state.dbNameSearch);
});
});
const editDb = (data: any) => {
if (data) {
state.instances = data.list;
state.dbEditDialog.data = { ...data };
} else {
state.dbEditDialog.data = {
instanceId: props.instance.id,
};
}
state.dbEditDialog.title = data ? '编辑数据库' : '新增数据库';
state.dbEditDialog.visible = true;
};
const confirmEditDb = async (db: any) => {
db.instanceId = props.instance.id;
await dbApi.saveDb.request(db);
ElMessage.success('保存成功');
search();
cancelEditDb();
};
const cancelEditDb = () => {
state.dbEditDialog.visible = false;
state.dbEditDialog.data = {};
};
const deleteDb = async () => {
try {
await ElMessageBox.confirm(`确定删除【${state.selectionData.map((x: any) => x.name).join(', ')}】库?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
for (let db of state.selectionData) {
await dbApi.deleteDb.request({ id: db.id });
}
ElMessage.success('删除成功');
} catch (err) {
//
} finally {
search();
}
};
@@ -378,14 +406,6 @@ const handleMoreActionCommand = (commond: any) => {
const data = commond.data;
const type = commond.type;
switch (type) {
case 'detail': {
showInfo(data);
return;
}
case 'edit': {
editDb(data);
return;
}
case 'dumpDb': {
onDumpDbs(data);
return;
@@ -405,36 +425,12 @@ const handleMoreActionCommand = (commond: any) => {
}
};
const editDb = async (data: any) => {
if (!data) {
state.dbEditDialog.data = null;
state.dbEditDialog.title = '新增数据库资源';
} else {
state.dbEditDialog.data = data;
state.dbEditDialog.title = '修改数据库资源';
}
state.dbEditDialog.visible = true;
};
const deleteDb = async () => {
try {
await ElMessageBox.confirm(`确定删除【${state.selectionData.map((x: any) => x.name).join(', ')}】库?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await dbApi.deleteDb.request({ id: state.selectionData.map((x: any) => x.id).join(',') });
ElMessage.success('删除成功');
search();
} catch (err) {
//
}
};
const onShowSqlExec = async (row: any) => {
state.sqlExecLogDialog.title = `${row.name}`;
state.sqlExecLogDialog.dbId = row.id;
state.sqlExecLogDialog.dbs = row.database.split(' ');
DbInst.getDbNames(row).then((res) => {
state.sqlExecLogDialog.dbs = res;
});
state.sqlExecLogDialog.visible = true;
};
@@ -447,26 +443,32 @@ const onBeforeCloseSqlExecDialog = () => {
const onShowDbBackupDialog = async (row: any) => {
state.dbBackupDialog.title = `${row.name}`;
state.dbBackupDialog.dbId = row.id;
state.dbBackupDialog.dbs = row.database.split(' ');
DbInst.getDbNames(row).then((res) => {
state.sqlExecLogDialog.dbs = res;
});
state.dbBackupDialog.visible = true;
};
const onShowDbBackupHistoryDialog = async (row: any) => {
state.dbBackupHistoryDialog.title = `${row.name}`;
state.dbBackupHistoryDialog.dbId = row.id;
state.dbBackupHistoryDialog.dbs = row.database.split(' ');
DbInst.getDbNames(row).then((res) => {
state.sqlExecLogDialog.dbs = res;
});
state.dbBackupHistoryDialog.visible = true;
};
const onShowDbRestoreDialog = async (row: any) => {
state.dbRestoreDialog.title = `${row.name}`;
state.dbRestoreDialog.dbId = row.id;
state.dbRestoreDialog.dbs = row.database.split(' ');
DbInst.getDbNames(row).then((res) => {
state.sqlExecLogDialog.dbs = res;
});
state.dbRestoreDialog.visible = true;
};
const onDumpDbs = async (row: any) => {
const dbs = row.database.split(' ');
const dbs = await DbInst.getDbNames(row);
const data = [];
for (let name of dbs) {
data.push({
@@ -474,6 +476,7 @@ const onDumpDbs = async (row: any) => {
label: name,
});
}
state.exportDialog.db = row.name;
state.exportDialog.value = [];
state.exportDialog.data = data;
state.exportDialog.dbId = row.id;
@@ -485,9 +488,8 @@ const onDumpDbs = async (row: any) => {
/**
* 数据库信息导出
*/
const dumpDbs = () => {
const dumpDbs = async () => {
isTrue(state.exportDialog.value.length > 0, '请添加要导出的数据库');
const a = document.createElement('a');
let type = 0;
for (let c of state.exportDialog.contents) {
if (c == '结构') {
@@ -496,13 +498,15 @@ const dumpDbs = () => {
type += 2;
}
}
a.setAttribute(
'href',
`${config.baseApiUrl}/dbs/${state.exportDialog.dbId}/dump?db=${state.exportDialog.value.join(',')}&type=${type}&extName=${
state.exportDialog.extName
}&${joinClientParams()}`
);
a.click();
for (let db of state.exportDialog.value) {
const a = document.createElement('a');
a.setAttribute(
'href',
`${config.baseApiUrl}/dbs/${state.exportDialog.dbId}/dump?db=${db}&type=${type}&extName=${state.exportDialog.extName}&${joinClientParams()}`
);
a.click();
await sleep(500);
}
state.exportDialog.visible = false;
};
@@ -515,6 +519,11 @@ const supportAction = (action: string, dbType: string): boolean => {
}
return actions.includes(action);
};
const cancel = () => {
dialogVisible.value = false;
emit('cancel');
};
</script>
<style lang="scss">
.db-list {

View File

@@ -45,14 +45,14 @@
<el-descriptions :column="1" border>
<el-descriptions-item :span="1" label="数据库名称">{{ infoDialog.data.dbName }}</el-descriptions-item>
<el-descriptions-item v-if="infoDialog.data.pointInTime" :span="1" label="恢复时间点">{{
dateFormat(infoDialog.data.pointInTime)
formatDate(infoDialog.data.pointInTime)
}}</el-descriptions-item>
<el-descriptions-item v-if="!infoDialog.data.pointInTime" :span="1" label="数据库备份">{{
infoDialog.data.dbBackupHistoryName
}}</el-descriptions-item>
<el-descriptions-item :span="1" label="开始时间">{{ dateFormat(infoDialog.data.startTime) }}</el-descriptions-item>
<el-descriptions-item :span="1" label="开始时间">{{ formatDate(infoDialog.data.startTime) }}</el-descriptions-item>
<el-descriptions-item :span="1" label="是否启用">{{ infoDialog.data.enabledDesc }}</el-descriptions-item>
<el-descriptions-item :span="1" label="执行时间">{{ dateFormat(infoDialog.data.lastTime) }}</el-descriptions-item>
<el-descriptions-item :span="1" label="执行时间">{{ formatDate(infoDialog.data.lastTime) }}</el-descriptions-item>
<el-descriptions-item :span="1" label="执行结果">{{ infoDialog.data.lastResult }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
@@ -66,7 +66,7 @@ import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { SearchItem } from '@/components/SearchForm';
import { ElMessage, ElMessageBox } from 'element-plus';
import { dateFormat } from '@/common/utils/date';
import { formatDate } from '@/common/utils/format';
const DbRestoreEdit = defineAsyncComponent(() => import('./DbRestoreEdit.vue'));
const pageTableRef: Ref<any> = ref(null);

Some files were not shown because too many files have changed in this diff Show More