86 Commits

Author SHA1 Message Date
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
meilin.huang
986b187f0a feat: v1.7.4 2024-03-04 20:33:04 +08:00
zongyangleo
008d34c453 !107 feat:支持修改表名、注释,oracle bug修复
* fix:修复oracle查询数据参数超过1000错误 ORA-01795
* feat:支持右键重命名表
* feat:支持修改表名、表注释
2024-03-04 11:32:04 +00:00
meilin.huang
49d3f988c9 feat: redis支持工单流程审批 2024-03-02 19:08:19 +08:00
zongyangleo
76475e807e !106 feat:数据同步支持唯一键冲突策略
* refactor:sql同步
* fix: 表格右键导出菜单换行符修复
* feat:数据同步支持唯一键冲突策略
2024-03-01 04:03:03 +00:00
meilin.huang
f93231da61 feat: dbms新增支持工单流程审批 2024-02-29 22:12:50 +08:00
meilin.huang
bf75483a3c refactor: 简化api层相关调用 2024-02-25 12:46:18 +08:00
meilin.huang
b56b0187cf refactor: api层尽可能屏蔽gin框架相关代码 2024-02-24 16:30:29 +08:00
meilin.huang
7e7f02b502 refactor: 机器终端操作优化 2024-02-23 22:53:17 +08:00
meilin.huang
878985f7c5 refactor: 依赖版本升级等 2024-02-22 21:03:13 +08:00
meilin.huang
2133d9b737 fix: 终端操作col和row初始化问题修复 2024-02-18 18:42:25 +08:00
meilin.huang
d711a36749 feat: v1.7.3 2024-02-08 09:53:48 +08:00
meilin.huang
9dbf104ef1 refactor: 机器操作界面调整 2024-02-07 21:14:29 +08:00
zongyangleo
20eb06fb28 !101 feat: 新增机器操作菜单
* feat: 新增机器操作菜单
2024-02-07 06:37:59 +00:00
meilin.huang
9c20bdef39 Merge branch 'dev' of https://gitee.com/objs/mayfly-go into dev 2024-02-06 15:33:31 +08:00
zongyangleo
3fdd98a390 !99 feat: DBMS新增kingbaseES、vastbase,还有一些优化
* refactor: 重构机器列表展示
* fix:修复编辑表问题
* refactor: 优化下拉实例显示
* feat: DBMS新增kingbaseES(已测试postgres、oracle兼容模式) 、vastbase
2024-02-06 07:32:03 +00:00
meilin.huang
d4f456c0cf Merge branch 'dev' of https://gitee.com/objs/mayfly-go into dev 2024-02-06 15:17:39 +08:00
kanzihuang
f2b6e15cf4 !100 定时清理数据库备份数据
* feat: 优化数据库 BINLOG 同步机制
* feat: 删除数据库实例前需删除关联的数据库备份与恢复任务
* refactor: 重构数据库备份与恢复模块
* feat: 定时清理数据库备份历史和本地 Binlog 文件
* feat: 压缩数据库备份文件
2024-02-06 07:16:56 +00:00
meilin.huang
6be0ea6aed fix: dbms数据行编辑 2024-02-01 12:05:41 +08:00
meilin.huang
eee08be2cc feat: 数据库支持编辑行数据 2024-01-31 20:41:41 +08:00
meilin.huang
252fc553f2 feat: v1.7.2 2024-01-31 12:53:27 +08:00
meilin.huang
ac2ceed3f9 refactor: code review 2024-01-30 21:56:49 +08:00
kanzihuang
3f828cc5b0 !96 删除数据库备份和恢复历史
* feat: 删除数据库备份历史
* refactor dbScheduler
* feat: 从数据库备份历史中恢复数据库
* feat: 删除数据库恢复历史记录
* refactor dbScheuler
2024-01-30 13:12:43 +00:00
zongyangleo
fc1b9ef35d !97 一些优化
* refactor: 重构表格分页组件,适配大数据量分页
* fix:定时任务修复
* feat: gaussdb单独提出来
2024-01-30 13:09:26 +00:00
meilin.huang
d0b71a1c40 refactor: dialect使用方式调整 2024-01-29 16:02:28 +08:00
meilin.huang
a743a6a05a Merge branch 'master' into dev 2024-01-29 12:21:22 +08:00
zongyangleo
0e6b9713ce !93 feat: DBMS支持mssql和一些功能优化
* feat: 表格+表格元数据缓存
* feat:跳板机支持多段跳
* fix: 所有数据库区分字段主键和自增
* feat: DBMS支持mssql
* refactor: 去除无用的getter方法
2024-01-29 04:20:23 +00:00
meilin.huang
b9afbc764d refactor: 去除无用的getter方法 2024-01-29 11:34:48 +08:00
meilin.huang
923e183a67 refactor: code review 2024-01-26 17:17:26 +08:00
meilin.huang
7e9a381641 refactor: 数据库meta使用注册方式,方便可插拔 2024-01-24 17:01:17 +08:00
zongyangleo
bed95254d0 !91 fix: oracle数据同步 bug
* fix: oracle数据同步 bug
2024-01-24 08:29:16 +00:00
meilin.huang
e4d13f3377 refactor: 引入日志切割库、indexApi拆分等 2024-01-23 19:30:28 +08:00
Coder慌
d530365ef9 !90 fix: 依赖注入支持私有变量
Merge pull request !90 from kanzihuang/feat-db-bak
2024-01-23 09:02:37 +00:00
wanli
070d4ea104 fix: 依赖注入支持私有变量 2024-01-23 16:29:41 +08:00
zongyangleo
3fc86f0fae !88 feat: dbms表支持右键菜单:删除表、编辑表、新建表、复制表
* feat: 支持复制表
* feat: dbms表支持右键菜单:删除表、编辑表、新建表
2024-01-23 04:08:02 +00:00
kanzihuang
3b77ab2727 !89 feat: 给数据库备份和恢复配置操作权限
* feat: 给数据库备份和恢复配置操作权限
* refactor: 数据库备份与恢复采用最新依赖注入机制
2024-01-23 04:06:08 +00:00
meilin.huang
76cb991282 fix: 数据同步更新时间展示等问题 2024-01-23 09:27:05 +08:00
meilin.huang
9efd20f1b9 refactor: ioc与系统初始化处理方式调整 2024-01-22 11:35:28 +08:00
kanzihuang
de5b9e46d3 !87 fix: 修复数据库备份与恢复问题
* feat: 修复数据库备份与恢复问题
* feat: 启用 BINLOG 支持全量备份和增量备份,未启用 BINLOG 仅支持全量备份
* feat: 数据库恢复后自动备份,避免数据丢失
2024-01-22 03:12:16 +00:00
meilin.huang
f27d3d200f feat: 新增简易版ioc 2024-01-21 22:52:20 +08:00
meilin.huang
f4a64b96a9 feat: v1.7.1新增支持sqlite&oracle分页限制等问题修复 2024-01-19 21:33:37 +08:00
zongyangleo
9a59749763 !86 dbms支持sqlite和一些bug修复
* fix: 达梦数据库连接修复,以支持带特殊字符的密码和schema
* fix: oracle bug修复
* feat: dbms支持sqlite
* fix: dbms 修改字段名bug
2024-01-19 08:59:35 +00:00
kanzihuang
b017b902f8 !85 fix: 修复 BINLOG同步任务加载问题
* Merge branch 'dev' of gitee.com:dromara/mayfly-go into feat-db-bak
* fix: 修复 BINLOG 同步任务加载问题
2024-01-19 00:40:44 +00:00
meilin.huang
7c53353c60 fix: sqlite数据问题时间类型问题修复等 2024-01-18 17:18:17 +08:00
608 changed files with 45827 additions and 11303 deletions

View File

@@ -5,12 +5,12 @@ WORKDIR /mayfly
COPY mayfly_go_web .
RUN yarn config set registry 'https://registry.npm.taobao.org' && \
RUN yarn config set registry 'https://registry.npmmirror.com' && \
yarn install && \
yarn build
# 构建后端资源
FROM golang:1.21.5 as be-builder
FROM golang:1.22 as be-builder
ENV GOPROXY https://goproxy.cn
WORKDIR /mayfly
@@ -24,7 +24,7 @@ COPY --from=fe-builder /mayfly/dist /mayfly/static/static
# Build
RUN GO111MODULE=on CGO_ENABLED=0 GOOS=linux \
go build -a \
go build -a -ldflags=-w \
-o mayfly-go main.go
FROM debian:bookworm-slim

View File

@@ -13,7 +13,7 @@
<img src="https://img.shields.io/docker/pulls/mayflygo/mayfly-go.svg?label=docker%20pulls&color=fac858" alt="docker pulls"/>
</a>
<a href="https://github.com/golang/go" target="_blank">
<img src="https://img.shields.io/badge/Golang-1.21%2B-yellow.svg" alt="golang"/>
<img src="https://img.shields.io/badge/Golang-1.22%2B-yellow.svg" alt="golang"/>
</a>
<a href="https://cn.vuejs.org" target="_blank">
<img src="https://img.shields.io/badge/Vue-3.x-green.svg" alt="vue">
@@ -22,7 +22,7 @@
### 介绍
web 版 **linux(终端[终端回放] 文件 脚本 进程 计划任务)、数据库mysql postgres oracle 达梦 高斯)、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.3
build:
context: .
dockerfile: Dockerfile

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2021 lyt-Top
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -10,31 +10,32 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@vueuse/core": "^10.7.2",
"asciinema-player": "^3.6.3",
"@vueuse/core": "^10.9.0",
"asciinema-player": "^3.7.1",
"axios": "^1.6.2",
"clipboard": "^2.0.11",
"countup.js": "^2.7.0",
"cropperjs": "^1.5.11",
"echarts": "^5.4.3",
"element-plus": "^2.5.1",
"js-base64": "^3.7.5",
"cropperjs": "^1.6.1",
"dayjs": "^1.11.11",
"echarts": "^5.5.0",
"element-plus": "^2.7.2",
"js-base64": "^3.7.7",
"jsencrypt": "^3.3.2",
"lodash": "^4.17.21",
"mitt": "^3.0.1",
"monaco-editor": "^0.45.0",
"monaco-editor": "^0.48.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.0",
"sortablejs": "^1.15.2",
"splitpanes": "^3.1.5",
"sql-formatter": "^15.0.2",
"trzsz": "^1.1.5",
"uuid": "^9.0.1",
"vue": "^3.4.14",
"vue-router": "^4.2.5",
"vue": "^3.4.27",
"vue-router": "^4.3.2",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-search": "^0.13.0",
@@ -44,19 +45,20 @@
"@types/lodash": "^4.14.178",
"@types/node": "^18.14.0",
"@types/nprogress": "^0.2.0",
"@types/sortablejs": "^1.15.3",
"@types/sortablejs": "^1.15.8",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"@vitejs/plugin-vue": "^5.0.3",
"@vue/compiler-sfc": "^3.4.14",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/compiler-sfc": "^3.4.27",
"code-inspector-plugin": "^0.4.5",
"dotenv": "^16.3.1",
"eslint": "^8.35.0",
"eslint-plugin-vue": "^9.19.2",
"prettier": "^3.1.0",
"sass": "^1.69.0",
"typescript": "^5.3.2",
"vite": "^5.0.11",
"vue-eslint-parser": "^9.4.0"
"eslint-plugin-vue": "^9.25.0",
"prettier": "^3.2.5",
"sass": "^1.76.0",
"typescript": "^5.4.5",
"vite": "^5.2.11",
"vue-eslint-parser": "^9.4.2"
},
"browserslist": [
"> 1%",

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

@@ -55,11 +55,11 @@
"unicode_decimal": 58905
},
{
"icon_id": "11617944",
"icon_id": "25271976",
"name": "oracle",
"font_class": "oracle",
"unicode": "e6ea",
"unicode_decimal": 59114
"unicode": "e507",
"unicode_decimal": 58631
},
{
"icon_id": "8105644",
@@ -67,6 +67,41 @@
"font_class": "mariadb",
"unicode": "e513",
"unicode_decimal": 58643
},
{
"icon_id": "13601813",
"name": "sqlite",
"font_class": "sqlite",
"unicode": "e546",
"unicode_decimal": 58694
},
{
"icon_id": "29340317",
"name": "temp-mssql",
"font_class": "MSSQLNATIVE",
"unicode": "e600",
"unicode_decimal": 58880
},
{
"icon_id": "7699332",
"name": "gaussdb",
"font_class": "gauss",
"unicode": "e683",
"unicode_decimal": 59011
},
{
"icon_id": "34836637",
"name": "kingbase",
"font_class": "kingbase",
"unicode": "e882",
"unicode_decimal": 59522
},
{
"icon_id": "33047500",
"name": "vastbase",
"font_class": "vastbase",
"unicode": "e62b",
"unicode_decimal": 58923
}
]
}

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.0',
version: 'v1.8.4',
};
export default config;

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),

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;

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

@@ -7,24 +7,22 @@ export function exportCsv(filename: string, columns: string[], datas: []) {
for (let column of columns) {
let val: any = data[column];
if (val == null || val == undefined) {
dataValueArr.push('');
continue;
}
val = '';
} else if (val && typeof val == 'string') {
// 替换换行符
val = val.replace(/[\r\n]/g, '\\n');
if (typeof val == 'string' && val) {
// csv格式如果有逗号整体用双引号括起来如果里面还有双引号就替换成两个双引号这样导出来的格式就不会有问题了
if (val.indexOf(',') != -1) {
// 如果还有双引号,先将双引号转义,避免两边加了双引号后转义错误
if (val.indexOf('"') != -1) {
val = val.replace(/\"/g, '""');
val = val.replace(/"/g, '""');
}
// 再将逗号转义
val = `"${val}"`;
}
dataValueArr.push(val + '\t');
} else {
dataValueArr.push(val + '\t');
}
dataValueArr.push(String(val));
}
cvsData.push(dataValueArr);
}

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
@@ -47,161 +62,42 @@ export function convertToBytes(sizeStr: string) {
}
/**
* 格式化json字符串
* @param txt json字符串
* @param compress 是否压缩
* @returns 格式化后的字符串
* 格式化指定时间数为人性化可阅读的内容(默认time为秒单位)
*
* @param time 时间数
* @param unit time对应的单位
* @returns
*/
export function formatJsonString(txt: string, compress: boolean) {
var indentChar = ' ';
if (/^\s*$/.test(txt)) {
console.log('数据为空,无法格式化! ');
return txt;
}
try {
var data = JSON.parse(txt);
} catch (e: any) {
console.log('数据源语法错误,格式化失败! 错误信息: ' + e.description, 'err');
return txt;
}
var draw: any = [],
line = compress ? '' : '\n',
// eslint-disable-next-line no-unused-vars
nodeCount: number = 0,
// eslint-disable-next-line no-unused-vars
maxDepth: number = 0;
export function formatTime(time: number, unit: string = 's') {
const units = {
y: 31536000,
M: 2592000,
d: 86400,
h: 3600,
m: 60,
s: 1,
};
var notify = function (name: any, value: any, isLast: any, indent: any, formObj: any) {
nodeCount++; /*节点计数*/
for (var i = 0, tab = ''; i < indent; i++) tab += indentChar; /* 缩进HTML */
tab = compress ? '' : tab; /*压缩模式忽略缩进*/
maxDepth = ++indent; /*缩进递增并记录*/
if (value && value.constructor == Array) {
/*处理数组*/
draw.push(tab + (formObj ? '"' + name + '": ' : '') + '[' + line); /*缩进'[' 然后换行*/
for (var i = 0; i < value.length; i++) notify(i, value[i], i == value.length - 1, indent, false);
draw.push(tab + ']' + (isLast ? line : ',' + line)); /*缩进']'换行,若非尾元素则添加逗号*/
} else if (value && typeof value == 'object') {
/*处理对象*/
draw.push(tab + (formObj ? '"' + name + '": ' : '') + '{' + line); /*缩进'{' 然后换行*/
var len = 0,
i = 0;
for (var key in value) len++;
for (var key in value) notify(key, value[key], ++i == len, indent, true);
draw.push(tab + '}' + (isLast ? line : ',' + line)); /*缩进'}'换行,若非尾元素则添加逗号*/
} else {
if (typeof value == 'string') value = '"' + value + '"';
draw.push(tab + (formObj ? '"' + name + '": ' : '') + value + (isLast ? '' : ',') + line);
if (!units[unit]) {
return 'Invalid unit';
}
let seconds = time * units[unit];
let result = '';
const timeUnits = Object.entries(units).map(([unit, duration]) => {
const value = Math.floor(seconds / duration);
seconds %= duration;
return { value, unit };
});
timeUnits.forEach(({ value, unit }) => {
if (value > 0) {
result += `${value}${unit} `;
}
};
var isLast = true,
indent = 0;
notify('', data, isLast, indent, false);
return draw.join('');
}
});
/*
* 年(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);
}
return result;
}
/**

View File

@@ -0,0 +1,33 @@
/**
* 根据对象访问路径,获取对应的值
*
* @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') {
return undefined;
}
if (key.includes('[') && key.includes(']')) {
// 处理包含数组索引的情况
const arrayKey = key.substring(0, key.indexOf('['));
const matchIndex = key.match(/\[(.*?)\]/);
if (!matchIndex) {
return undefined;
}
const index = parseInt(matchIndex[1]);
result = Array.isArray(result[arrayKey]) ? result[arrayKey][index] : undefined;
} else {
result = result[key];
}
}
return result;
}

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

@@ -1,5 +1,5 @@
<template>
<el-input v-model="cron" placeholder="可点击左边按钮进行可视化配置">
<el-input v-model="cron" placeholder="可点击左边按钮配置">
<template #prepend>
<el-button @click="showCron = true" icon="Pointer"></el-button>
</template>

View File

@@ -0,0 +1,33 @@
<template>
<el-page-header @back="props.back">
<template #content>
<span>{{ header }}</span>
<span v-if="resource && !hideResource">
-
<el-tooltip v-if="resource.length > 25" :content="resource" placement="bottom">
<el-tag effect="dark" type="success">{{ resource.substring(0, 23) + '...' }}</el-tag>
</el-tooltip>
<el-tag v-else effect="dark" type="success">{{ resource }}</el-tag>
</span>
<el-divider v-if="slots.buttons" direction="vertical" />
<slot v-if="slots.buttons" name="buttons"></slot>
</template>
<template #extra>
<slot v-if="slots.extra" name="extra"></slot>
</template>
</el-page-header>
</template>
<script lang="ts" setup>
import { useSlots } from 'vue';
const slots = useSlots();
defineOptions({ name: 'DrawerHeader' });
const props = defineProps({
header: String,
back: Function,
resource: String,
hideResource: Boolean,
});
</script>

View File

@@ -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

@@ -119,8 +119,8 @@ const open = (optionProps: MonacoEditorDialogProps) => {
}
setTimeout(() => {
editorRef.value?.format();
editorRef.value?.focus();
editorRef.value?.format();
}, 300);
state.dialogVisible = true;

View File

@@ -74,7 +74,7 @@
trigger="click"
>
<div v-for="(item, index) in tableColumns" :key="index">
<el-checkbox v-model="item.show" :label="item.label" :true-label="true" :false-label="false" />
<el-checkbox v-model="item.show" :label="item.label" :true-value="true" :false-value="false" />
</div>
<template #reference>
<el-button icon="Operation" circle :size="props.size"></el-button>
@@ -115,18 +115,18 @@
>
<!-- 插槽预留功能 -->
<template #default="scope" v-if="item.slot">
<slot :name="item.prop" :data="scope.row"></slot>
<slot :name="item.slotName ? item.slotName : item.prop" :data="scope.row"></slot>
</template>
<!-- 枚举类型使用tab展示 -->
<template #default="scope" v-else-if="item.type == 'tag'">
<enum-tag :size="props.size" :enums="item.typeParam" :value="scope.row[item.prop]"></enum-tag>
<enum-tag :size="props.size" :enums="item.typeParam" :value="item.getValueByData(scope.row)"></enum-tag>
</template>
<template #default="scope" v-else>
<!-- 配置了美化文本按钮以及文本内容大于指定长度则显示美化按钮 -->
<el-popover
v-if="item.isBeautify && scope.row[item.prop]?.length > 35"
v-if="item.isBeautify && item.getValueByData(scope.row)?.length > 35"
effect="light"
trigger="click"
placement="top"
@@ -137,7 +137,7 @@
</template>
<template #reference>
<el-link
@click="formatText(scope.row[item.prop])"
@click="formatText(item.getValueByData(scope.row))"
:underline="false"
type="success"
icon="MagicStick"
@@ -159,7 +159,7 @@
@current-change="handlePageNumChange"
@size-change="handlePageSizeChange"
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,11 +185,11 @@ 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', 'pageChange']);
export interface PageTableProps {
size?: string;
pageApi: Api; // 请求表格数据的 api
pageApi?: Api; // 请求表格数据的 api
columns: TableColumn[]; // 列配置项 ==> 必传
showSelection?: boolean;
selectable?: (row: any) => boolean; // 是否可选
@@ -257,7 +257,7 @@ const changeSimpleFormItem = (searchItem: SearchItem) => {
nowSearchItem.value = searchItem;
};
const { tableData, total, loading, search, reset, getTableData, handlePageNumChange, handlePageSizeChange } = usePageTable(
let { tableData, total, loading, search, reset, getTableData, handlePageNumChange, handlePageSizeChange } = usePageTable(
props.pageable,
props.pageApi,
queryForm,
@@ -288,6 +288,13 @@ watch(isShowSearch, () => {
calcuTableHeight();
});
watch(
() => props.data,
(newValue: any) => {
tableData = newValue;
}
);
onMounted(async () => {
calcuTableHeight();
useEventListener(window, 'resize', calcuTableHeight);

View File

@@ -1,5 +1,6 @@
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';
export class TableColumn {
@@ -29,10 +30,15 @@ export class TableColumn {
minWidth: number | string;
/**
* 是否插槽,是的话插槽名为prop属性名
* 是否插槽,若slotName为空则插槽名为prop属性名
*/
slot: boolean = false;
/**
* 插槽名,
*/
slotName: string = '';
showOverflowTooltip: boolean = true;
sortable: boolean = false;
@@ -87,7 +93,7 @@ export class TableColumn {
if (this.formatFunc) {
return this.formatFunc(rowData, this.prop);
}
return rowData[this.prop];
return getValueByPath(rowData, this.prop);
}
static new(prop: string, label: string): TableColumn {
@@ -144,8 +150,9 @@ export class TableColumn {
* 标识该列为插槽
* @returns this
*/
isSlot(): TableColumn {
isSlot(slotName: string = ''): TableColumn {
this.slot = true;
this.slotName = slotName;
return this;
}
@@ -165,7 +172,7 @@ export class TableColumn {
*/
isTime(): TableColumn {
this.setFormatFunc((data: any, prop: string) => {
return dateFormat(data[prop]);
return formatDate(getValueByPath(data, prop));
});
return this;
}
@@ -176,7 +183,7 @@ export class TableColumn {
*/
isEnum(enums: any): TableColumn {
this.setFormatFunc((data: any, prop: string) => {
return EnumValue.getLabelByValue(enums, data[prop]);
return EnumValue.getLabelByValue(enums, getValueByPath(data, prop));
});
return this;
}
@@ -218,7 +225,7 @@ export class TableColumn {
// 获取该列中最长的数据(内容)
for (let i = 0; i < tableData.length; i++) {
let nowData = tableData[i];
let nowValue = nowData[prop];
let nowValue = getValueByPath(nowData, prop);
if (!nowValue) {
continue;
}

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: {},
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

@@ -1,5 +1,5 @@
<template>
<div id="terminal-body" :style="{ height, background: themeConfig.terminalBackground }">
<div id="terminal-body" :style="{ height }">
<div ref="terminalRef" class="terminal" />
<TerminalSearch ref="terminalSearchRef" :search-addon="state.addon.search" @close="focus" />
@@ -8,7 +8,7 @@
<script lang="ts" setup>
import 'xterm/css/xterm.css';
import { Terminal } from 'xterm';
import { Terminal, ITheme } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { SearchAddon } from 'xterm-addon-search';
import { WebLinksAddon } from 'xterm-addon-web-links';
@@ -20,8 +20,15 @@ import TerminalSearch from './TerminalSearch.vue';
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方法
mountInit: {
type: Boolean,
default: true,
},
/**
* 初始化执行命令
*/
@@ -64,9 +71,9 @@ const state = reactive({
});
onMounted(() => {
nextTick(() => {
if (props.mountInit) {
init();
});
}
});
watch(
@@ -76,6 +83,14 @@ watch(
}
);
// 监听 themeConfig terminalTheme配置的变化
watch(
() => themeConfig.value.terminalTheme,
() => {
term.options.theme = getTerminalTheme();
}
);
onBeforeUnmount(() => {
close();
});
@@ -85,6 +100,12 @@ function init() {
console.log('重新连接...');
close();
}
nextTick(() => {
initTerm();
});
}
function initTerm() {
term = new Terminal({
fontSize: themeConfig.value.terminalFontSize || 15,
fontWeight: themeConfig.value.terminalFontWeight || 'normal',
@@ -92,13 +113,10 @@ function init() {
cursorBlink: true,
disableStdin: false,
allowProposedApi: true,
theme: {
foreground: themeConfig.value.terminalForeground || '#7e9192', //字体
background: themeConfig.value.terminalBackground || '#002833', //背景色
cursor: themeConfig.value.terminalCursor || '#268F81', //设置光标
// cursorAccent: "red", // 光标停止颜色
} as any,
fastScrollModifier: 'ctrl',
theme: getTerminalTheme(),
});
term.open(terminalRef.value);
// 注册自适应组件
@@ -106,31 +124,12 @@ function init() {
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);
// 初始化websocket
initSocket();
}
/**
* 连接成功
*/
const onConnected = () => {
// 注册心跳
pingInterval = setInterval(sendPing, 15000);
// 注册 terminal 事件
term.onResize((event) => sendResize(event.cols, event.rows));
term.onData((event) => sendCmd(event));
// 注册其他插件
loadAddon();
// 注册自定义快捷键
term.attachCustomKeyEventHandler((event: KeyboardEvent) => {
@@ -142,50 +141,25 @@ const onConnected = () => {
return true;
});
state.status = TerminalStatus.Connected;
// 注册窗口大小监听器
useEventListener('resize', debounce(fitTerminal, 400));
focus();
// 如果有初始要执行的命令,则发送执行命令
if (props.cmd) {
sendCmd(props.cmd + ' \r');
}
};
// 自适应终端
const fitTerminal = () => {
const dimensions = state.addon.fit && state.addon.fit.proposeDimensions();
if (!dimensions) {
return;
}
if (dimensions?.cols && dimensions?.rows) {
term.resize(dimensions.cols, dimensions.rows);
}
};
const focus = () => {
setTimeout(() => term.focus(), 400);
};
const clear = () => {
term.clear();
term.clearSelection();
term.focus();
};
}
function initSocket() {
if (props.socketUrl) {
let socketUrl = `${props.socketUrl}&rows=${term?.rows}&cols=${term?.cols}`;
socket = new WebSocket(socketUrl);
if (!props.socketUrl) {
return;
}
socket = new WebSocket(`${props.socketUrl}&rows=${term?.rows}&cols=${term?.cols}`);
// 监听socket连接
socket.onopen = () => {
onConnected();
// 注册心跳
pingInterval = setInterval(sendPing, 15000);
state.status = TerminalStatus.Connected;
focus();
// 如果有初始要执行的命令,则发送执行命令
if (props.cmd) {
sendCmd(props.cmd + ' \r');
}
};
// 监听socket错误信息
@@ -197,20 +171,96 @@ function initSocket() {
socket.onclose = (e: CloseEvent) => {
console.log('terminal socket close...', e.reason);
// 清除 ping
pingInterval && clearInterval(pingInterval);
state.status = TerminalStatus.Disconnected;
};
// 监听socket消息
socket.onmessage = getMessage;
}
function getMessage(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);
});
window.addEventListener('resize', () => state.addon.fit.fit());
// 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;
// 如果不是自定义主题,则返回内置主题
if (terminalTheme != 'custom') {
return themes[terminalTheme];
}
// 自定义主题
return {
foreground: themeConfig.value.terminalForeground || '#7e9192', //字体
background: themeConfig.value.terminalBackground || '#002833', //背景色
cursor: themeConfig.value.terminalCursor || '#268F81', //设置光标
// cursorAccent: "red", // 光标停止颜色
} as ITheme;
};
// 自适应终端
const fitTerminal = () => {
state.addon.fit.fit();
};
const focus = () => {
setTimeout(() => term.focus(), 300);
};
const clear = () => {
term.clear();
term.clearSelection();
term.focus();
};
enum MsgType {
Resize = 1,
Data = 2,
@@ -218,29 +268,19 @@ enum MsgType {
}
const send = (msg: any) => {
state.status == TerminalStatus.Connected && socket.send(JSON.stringify(msg));
state.status == TerminalStatus.Connected && socket?.send(msg);
};
const sendResize = (cols: number, rows: number) => {
send({
type: MsgType.Resize,
Cols: cols,
Rows: rows,
});
send(`${MsgType.Resize}|${rows}|${cols}`);
};
const sendPing = () => {
send({
type: MsgType.Ping,
msg: 'ping',
});
send(`${MsgType.Ping}|ping`);
};
function sendCmd(key: any) {
send({
type: MsgType.Data,
msg: key,
});
send(`${MsgType.Data}|${key}`);
}
function closeSocket() {
@@ -265,20 +305,19 @@ const getStatus = (): TerminalStatus => {
return state.status;
};
defineExpose({ init, fitTerminal, focus, clear, close, getStatus });
defineExpose({ init, fitTerminal, focus, clear, close, getStatus, sendResize, write2Term, writeln2Term });
</script>
<style lang="scss">
#terminal-body {
background: #212529;
width: 100%;
.terminal {
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"
@@ -58,7 +58,7 @@
</div>
</div>
</template>
<div class="terminal-wrapper" :style="{ height: `calc(100vh - ${openTerminal.fullscreen ? '49px' : '200px'})` }">
<div :style="{ height: `calc(100vh - ${openTerminal.fullscreen ? '49px' : '200px'})` }">
<TerminalBody
@status-change="terminalStatusChange(openTerminal.terminalId, $event)"
:ref="(el) => setTerminalRef(el, openTerminal.terminalId)"
@@ -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';
@@ -259,6 +259,10 @@ defineExpose({
padding: 10px;
}
.el-dialog {
padding: 1px 1px;
}
// 取消body最大高度否则全屏有问题
.el-dialog__body {
max-height: 100% !important;

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

@@ -0,0 +1,92 @@
export default {
dark: {
foreground: '#c7c7c7',
background: '#000000',
cursor: '#c7c7c7',
selectionBackground: '#686868',
black: '#000000',
brightBlack: '#676767',
red: '#c91b00',
brightRed: '#ff6d67',
green: '#00c200',
brightGreen: '#5ff967',
yellow: '#c7c400',
brightYellow: '#fefb67',
blue: '#0225c7',
brightBlue: '#6871ff',
magenta: '#c930c7',
brightMagenta: '#ff76ff',
cyan: '#00c5c7',
brightCyan: '#5ffdff',
white: '#c7c7c7',
brightWhite: '#fffefe',
},
light: {
foreground: '#000000',
background: '#fffefe',
cursor: '#000000',
selectionBackground: '#c7c7c7',
black: '#000000',
brightBlack: '#676767',
red: '#c91b00',
brightRed: '#ff6d67',
green: '#00c200',
brightGreen: '#5ff967',
yellow: '#c7c400',
brightYellow: '#fefb67',
blue: '#0225c7',
brightBlue: '#6871ff',
magenta: '#c930c7',
brightMagenta: '#ff76ff',
cyan: '#00c5c7',
brightCyan: '#5ffdff',
white: '#c7c7c7',
brightWhite: '#fffefe',
},
solarizedLight: {
foreground: '#657b83',
background: '#fdf6e3',
cursor: '#657b83',
selectionBackground: '#c7c7c7',
black: '#073642',
brightBlack: '#002b36',
red: '#dc322f',
brightRed: '#cb4b16',
green: '#859900',
brightGreen: '#586e75',
yellow: '#b58900',
brightYellow: '#657b83',
blue: '#268bd2',
brightBlue: '#839496',
magenta: '#d33682',
brightMagenta: '#6c71c4',
cyan: '#2aa198',
brightCyan: '#93a1a1',
white: '#eee8d5',
brightWhite: '#fdf6e3',
},
};

View File

@@ -1,5 +1,5 @@
import Api from '@/common/Api';
import { isReactive, reactive, toRefs, toValue } from 'vue';
import { reactive, toRefs, toValue } from 'vue';
/**
* @description table 页面操作方法封装
@@ -41,15 +41,10 @@ export const usePageTable = (
let sp = toValue(state.searchParams);
if (beforeQueryFn) {
sp = beforeQueryFn(sp);
if (isReactive(state.searchParams)) {
state.searchParams.value = sp;
} else {
state.searchParams = sp;
}
}
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();
@@ -48,9 +49,6 @@ export function useApiFetch<T>(api: Api, params: any = null, reqOptions: Request
}
let paramsValue = unref(params);
if (api.beforeHandler) {
paramsValue = api.beforeHandler(paramsValue);
}
let apiUrl = url;
// 简单判断该url是否是restful风格
@@ -58,6 +56,10 @@ export function useApiFetch<T>(api: Api, params: any = null, reqOptions: Request
apiUrl = templateResolve(apiUrl, paramsValue);
}
if (api.beforeHandler) {
paramsValue = api.beforeHandler(paramsValue);
}
if (paramsValue) {
const method = options.method?.toLowerCase();
// post和put使用json格式传参
@@ -87,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,18 +1,15 @@
<template>
<div class="layout-navbars-breadcrumb" v-show="themeConfig.isBreadcrumb">
<SvgIcon class="layout-navbars-breadcrumb-icon" :name="themeConfig.isCollapse ? 'expand' : 'fold'"
@click="onThemeConfigChange" />
<SvgIcon class="layout-navbars-breadcrumb-icon" :name="themeConfig.isCollapse ? 'expand' : 'fold'" @click="onThemeConfigChange" />
<el-breadcrumb class="layout-navbars-breadcrumb-hide">
<transition-group name="breadcrumb" mode="out-in">
<el-breadcrumb-item v-for="(v, k) in state.breadcrumbList" :key="v.meta.title">
<span v-if="k === state.breadcrumbList.length - 1" class="layout-navbars-breadcrumb-span">
<SvgIcon :name="v.meta.icon" class="layout-navbars-breadcrumb-iconfont"
v-if="themeConfig.isBreadcrumbIcon" />
<SvgIcon :name="v.meta.icon" class="layout-navbars-breadcrumb-iconfont" v-if="themeConfig.isBreadcrumbIcon" />
{{ v.meta.title }}
</span>
<a v-else @click.prevent="onBreadcrumbClick(v)">
<SvgIcon :name="v.meta.icon" class="layout-navbars-breadcrumb-iconfont"
v-if="themeConfig.isBreadcrumbIcon" />
<SvgIcon :name="v.meta.icon" class="layout-navbars-breadcrumb-iconfont" v-if="themeConfig.isBreadcrumbIcon" />
{{ v.meta.title }}
</a>
</el-breadcrumb-item>

View File

@@ -1,17 +1,22 @@
<template>
<div class="layout-search-dialog">
<el-dialog v-model="state.isShowSearch" width="300px" destroy-on-close :modal="false" fullscreen :show-close="false">
<el-autocomplete v-model="state.menuQuery" :fetch-suggestions="menuSearch" placeholder="菜单搜索"
prefix-icon="el-icon-search" ref="layoutMenuAutocompleteRef" @select="onHandleSelect" @blur="onSearchBlur">
<el-autocomplete
v-model="state.menuQuery"
:fetch-suggestions="menuSearch"
placeholder="菜单搜索"
prefix-icon="el-icon-search"
ref="layoutMenuAutocompleteRef"
@select="onHandleSelect"
@blur="onSearchBlur"
>
<template #prefix>
<el-icon class="el-input__icon">
<search />
</el-icon>
</template>
<template #default="{ item }">
<div>
<SvgIcon :name="item.meta.icon" class="mr5" />{{ item.meta.title }}
</div>
<div><SvgIcon :name="item.meta.icon" class="mr5" />{{ item.meta.title }}</div>
</template>
</el-autocomplete>
</el-dialog>
@@ -23,7 +28,7 @@ import { reactive, ref, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { useRoutesList } from '@/store/routesList';
const layoutMenuAutocompleteRef: any = ref(null);;
const layoutMenuAutocompleteRef: any = ref(null);
const router = useRouter();
const state: any = reactive({
isShowSearch: false,
@@ -54,8 +59,7 @@ const menuSearch = (queryString: any, cb: any) => {
const createFilter = (queryString: any) => {
return (restaurant: any) => {
return (
restaurant.path.toLowerCase().indexOf(queryString.toLowerCase()) > -1 ||
restaurant.meta.title.toLowerCase().indexOf(queryString.toLowerCase()) > -1
restaurant.path.toLowerCase().indexOf(queryString.toLowerCase()) > -1 || restaurant.meta.title.toLowerCase().indexOf(queryString.toLowerCase()) > -1
);
};
};
@@ -97,7 +101,7 @@ const onSearchBlur = () => {
closeSearch();
};
defineExpose({openSearch})
defineExpose({ openSearch });
</script>
<style scoped lang="scss">

View File

@@ -5,26 +5,39 @@
<!-- ssh终端主题 -->
<el-divider content-position="left">终端主题</el-divider>
<div class="layout-breadcrumb-seting-bar-flex">
<div class="layout-breadcrumb-seting-bar-flex-label">字体颜色</div>
<div class="layout-breadcrumb-seting-bar-flex-label">主题</div>
<div class="layout-breadcrumb-seting-bar-flex-value">
<el-color-picker v-model="themeConfig.terminalForeground" size="small" @change="onColorPickerChange('terminalForeground')">
</el-color-picker>
<el-select @change="setLocalThemeConfig" v-model="themeConfig.terminalTheme" size="small" style="width: 140px">
<el-option v-for="(_, k) in themes" :key="k" :label="k" :value="k"> </el-option>
<el-option label="自定义" value="custom"> </el-option>
</el-select>
</div>
</div>
<div class="layout-breadcrumb-seting-bar-flex">
<div class="layout-breadcrumb-seting-bar-flex-label">背景颜色</div>
<div class="layout-breadcrumb-seting-bar-flex-value">
<el-color-picker v-model="themeConfig.terminalBackground" size="small" @change="onColorPickerChange('terminalBackground')">
</el-color-picker>
<template v-if="themeConfig.terminalTheme == 'custom'">
<div class="layout-breadcrumb-seting-bar-flex mt10">
<div class="layout-breadcrumb-seting-bar-flex-label">字体颜色</div>
<div class="layout-breadcrumb-seting-bar-flex-value">
<el-color-picker v-model="themeConfig.terminalForeground" size="small" @change="onColorPickerChange('terminalForeground')">
</el-color-picker>
</div>
</div>
</div>
<div class="layout-breadcrumb-seting-bar-flex">
<div class="layout-breadcrumb-seting-bar-flex-label">cursor颜色</div>
<div class="layout-breadcrumb-seting-bar-flex-value">
<el-color-picker v-model="themeConfig.terminalCursor" size="small" @change="onColorPickerChange('terminalCursor')"> </el-color-picker>
<div class="layout-breadcrumb-seting-bar-flex">
<div class="layout-breadcrumb-seting-bar-flex-label">背景颜色</div>
<div class="layout-breadcrumb-seting-bar-flex-value">
<el-color-picker v-model="themeConfig.terminalBackground" size="small" @change="onColorPickerChange('terminalBackground')">
</el-color-picker>
</div>
</div>
</div>
<div class="layout-breadcrumb-seting-bar-flex mt15">
<div class="layout-breadcrumb-seting-bar-flex">
<div class="layout-breadcrumb-seting-bar-flex-label">cursor颜色</div>
<div class="layout-breadcrumb-seting-bar-flex-value">
<el-color-picker v-model="themeConfig.terminalCursor" size="small" @change="onColorPickerChange('terminalCursor')">
</el-color-picker>
</div>
</div>
</template>
<div class="layout-breadcrumb-seting-bar-flex mt10">
<div class="layout-breadcrumb-seting-bar-flex-label">字体大小</div>
<div class="layout-breadcrumb-seting-bar-flex-value">
<el-input-number
@@ -39,7 +52,7 @@
</el-input-number>
</div>
</div>
<div class="layout-breadcrumb-seting-bar-flex mt15">
<div class="layout-breadcrumb-seting-bar-flex mt10">
<div class="layout-breadcrumb-seting-bar-flex-label">字体粗细</div>
<div class="layout-breadcrumb-seting-bar-flex-value">
<el-select @change="setLocalThemeConfig" v-model="themeConfig.terminalFontWeight" size="small" style="width: 90px">
@@ -418,6 +431,7 @@ import { useThemeConfig } from '@/store/themeConfig';
import { getLightColor } from '@/common/utils/theme';
import { setLocal, getLocal, removeLocal } from '@/common/utils/storage';
import mittBus from '@/common/utils/mitt';
import themes from '@/components/terminal/themes';
const copyConfigBtnRef = ref();
const { themeConfig } = storeToRefs(useThemeConfig());
@@ -615,6 +629,9 @@ const setLocalThemeConfigStyle = () => {
};
// 一键复制配置
const onCopyConfigClick = (target: any) => {
if (!target) {
return;
}
let copyThemeConfig = getLocal('themeConfig');
copyThemeConfig.isDrawer = false;
const clipboard = new ClipboardJS(target, {
@@ -690,6 +707,25 @@ defineExpose({ openDrawer });
</script>
<style scoped lang="scss">
::v-deep(.el-drawer) {
--el-drawer-padding-primary: unset !important;
.el-drawer__header {
padding: 0 15px !important;
height: 50px;
display: flex;
align-items: center;
margin-bottom: 0 !important;
border-bottom: 1px solid var(--el-border-color);
}
.el-drawer__body {
width: 100%;
height: 100%;
overflow: auto;
}
}
.layout-breadcrumb-seting-bar {
height: calc(100vh - 50px);
padding: 0 15px;

View File

@@ -174,12 +174,7 @@ watch(preDark, (newValue) => {
});
const switchDark = () => {
themeConfig.value.isDark = isDark.value;
if (isDark.value) {
themeConfig.value.editorTheme = 'vs-dark';
} else {
themeConfig.value.editorTheme = 'vs';
}
themeConfigStore.switchDark(isDark.value);
saveThemeConfig(themeConfig.value);
};

View File

@@ -284,7 +284,9 @@ const onTagsClick = (v: any, k: number) => {
state.tagsRefsIndex = k;
try {
router.push(v);
} catch (e) {}
} catch (e) {
// skip
}
};
// 更新滚动条显示
const updateScrollbar = () => {

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,6 +114,7 @@ export const useThemeConfig = defineStore('themeConfig', {
// 默认布局,可选 1、默认 defaults 2、经典 classic 3、横向 transverse 4、分栏 columns
layout: 'classic',
terminalTheme: 'light',
// ssh终端字体颜色
terminalForeground: '#C5C8C6',
// ssh终端背景色
@@ -190,7 +191,25 @@ 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) {
this.themeConfig.isDark = isDark;
// 切换编辑器主题
if (isDark) {
this.themeConfig.editorTheme = 'vs-dark';
} else {
this.themeConfig.editorTheme = 'vs';
}
// 如果终端主题不是自定义主题,则切换主题
if (this.themeConfig.terminalTheme != 'custom') {
if (isDark) {
this.themeConfig.terminalTheme = 'dark';
} else {
this.themeConfig.terminalTheme = 'light';
}
}
},
},
});

View File

@@ -35,7 +35,7 @@ body,
width: 100%;
height: 100%;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-weight: 450;
font-weight: 500;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
background-color: var(--bg-main-color);

View File

@@ -7,335 +7,353 @@
------------------------------- */
// 菜单搜索
.el-autocomplete-suggestion__wrap {
max-height: 280px !important;
max-height: 280px !important;
}
/* Form 表单
------------------------------- */
.el-form {
// 用于修改弹窗时表单内容间隔太大问题,如系统设置的新增菜单弹窗里的表单内容
.el-form-item:last-of-type {
margin-bottom: 0 !important;
}
// 修复行内表单最后一个 el-form-item 位置下移问题
&.el-form--inline {
.el-form-item--large.el-form-item:last-of-type {
margin-bottom: 22px !important;
}
.el-form-item--default.el-form-item:last-of-type,
.el-form-item--small.el-form-item:last-of-type {
margin-bottom: 18px !important;
}
}
// https://gitee.com/lyt-top/vue-next-admin/issues/I5K1PM
.el-form-item .el-form-item__label .el-icon {
margin-right: 0px;
}
// 用于修改弹窗时表单内容间隔太大问题,如系统设置的新增菜单弹窗里的表单内容
.el-form-item:last-of-type {
margin-bottom: 0 !important;
}
// 修复行内表单最后一个 el-form-item 位置下移问题
&.el-form--inline {
.el-form-item--large.el-form-item:last-of-type {
margin-bottom: 22px !important;
}
.el-form-item--default.el-form-item:last-of-type,
.el-form-item--small.el-form-item:last-of-type {
margin-bottom: 18px !important;
}
}
// https://gitee.com/lyt-top/vue-next-admin/issues/I5K1PM
.el-form-item .el-form-item__label .el-icon {
margin-right: 0px;
}
}
/* Alert 警告
------------------------------- */
.el-alert {
border: 1px solid;
border: 1px solid;
}
.el-alert__title {
word-break: break-all;
word-break: break-all;
}
/* Message 消息提示
------------------------------- */
.el-message {
min-width: unset !important;
padding: 15px !important;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.02);
min-width: unset !important;
padding: 15px !important;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.02);
}
/* NavMenu 导航菜单
------------------------------- */
// 鼠标 hover 时颜色
.el-menu-hover-bg-color {
background-color: var(--bg-menuBarActiveColor) !important;
background-color: var(--bg-menuBarActiveColor) !important;
}
// 默认样式修改
.el-menu {
border-right: none !important;
width: 220px;
border-right: none !important;
width: 220px;
}
.el-menu-item {
height: 56px !important;
line-height: 56px !important;
height: 56px !important;
line-height: 56px !important;
}
.el-menu-item,
.el-sub-menu__title {
color: var(--bg-menuBarColor);
color: var(--bg-menuBarColor);
}
// 修复点击左侧菜单折叠再展开时,宽度不跟随问题
.el-menu--collapse {
width: 64px !important;
width: 64px !important;
}
// 外部链接时
.el-menu-item a,
.el-menu-item a:hover,
.el-menu-item i,
.el-sub-menu__title i {
color: inherit;
text-decoration: none;
color: inherit;
text-decoration: none;
}
// 第三方图标字体间距/大小设置
.el-menu-item .iconfont,
.el-sub-menu .iconfont,
.el-menu-item .fa,
.el-sub-menu .fa {
@include generalIcon;
@include generalIcon;
}
// 水平菜单、横向菜单高亮 背景色,鼠标 hover 时,有子级菜单的背景色
.el-menu-item.is-active,
.el-sub-menu.is-active .el-sub-menu__title,
.el-sub-menu:not(.is-opened):hover .el-sub-menu__title {
@extend .el-menu-hover-bg-color;
@extend .el-menu-hover-bg-color;
}
.el-menu-item:hover {
@extend .el-menu-hover-bg-color;
@extend .el-menu-hover-bg-color;
}
.el-sub-menu.is-active.is-opened .el-sub-menu__title {
background-color: unset !important;
background-color: unset !important;
}
// 子级菜单背景颜色
// .el-menu--inline {
// background: var(--next-bg-menuBar-light-1);
// }
// 水平菜单、横向菜单折叠 a 标签
.el-popper.is-dark a {
color: var(--el-color-white) !important;
text-decoration: none;
color: var(--el-color-white) !important;
text-decoration: none;
}
// 水平菜单、横向菜单折叠背景色
.el-popper.is-pure.is-light {
// 水平菜单
.el-menu--vertical {
background: var(--bg-menuBar);
.el-sub-menu.is-active .el-sub-menu__title {
color: var(--el-menu-active-color);
}
.el-popper.is-pure.is-light {
.el-menu--vertical {
.el-sub-menu .el-sub-menu__title {
background-color: unset !important;
color: var(--bg-menuBarColor);
}
.el-sub-menu.is-active .el-sub-menu__title {
color: var(--el-menu-active-color);
}
}
}
}
// 横向菜单
.el-menu--horizontal {
background: var(--bg-topBar);
.el-menu-item,
.el-sub-menu {
height: 48px !important;
line-height: 48px !important;
color: var(--bg-topBarColor);
.el-sub-menu__title {
height: 48px !important;
line-height: 48px !important;
color: var(--bg-topBarColor);
}
.el-popper.is-pure.is-light {
.el-menu--horizontal {
.el-sub-menu .el-sub-menu__title {
background-color: unset !important;
color: var(--bg-topBarColor);
}
.el-sub-menu.is-active .el-sub-menu__title {
color: var(--el-menu-active-color);
}
}
}
}
.el-menu-item.is-active,
.el-sub-menu.is-active .el-sub-menu__title {
color: var(--el-menu-active-color);
}
}
// 水平菜单
.el-menu--vertical {
background: var(--bg-menuBar);
.el-sub-menu.is-active .el-sub-menu__title {
color: var(--el-menu-active-color);
}
.el-popper.is-pure.is-light {
.el-menu--vertical {
.el-sub-menu .el-sub-menu__title {
background-color: unset !important;
color: var(--bg-menuBarColor);
}
.el-sub-menu.is-active .el-sub-menu__title {
color: var(--el-menu-active-color);
}
}
}
}
// 横向菜单
.el-menu--horizontal {
background: var(--bg-topBar);
.el-menu-item,
.el-sub-menu {
height: 48px !important;
line-height: 48px !important;
color: var(--bg-topBarColor);
.el-sub-menu__title {
height: 48px !important;
line-height: 48px !important;
color: var(--bg-topBarColor);
}
.el-popper.is-pure.is-light {
.el-menu--horizontal {
.el-sub-menu .el-sub-menu__title {
background-color: unset !important;
color: var(--bg-topBarColor);
}
.el-sub-menu.is-active .el-sub-menu__title {
color: var(--el-menu-active-color);
}
}
}
}
.el-menu-item.is-active,
.el-sub-menu.is-active .el-sub-menu__title {
color: var(--el-menu-active-color);
}
}
}
// 横向菜单(经典、横向)布局
.el-menu.el-menu--horizontal {
border-bottom: none !important;
width: 100% !important;
.el-menu-item,
.el-sub-menu__title {
height: 48px !important;
color: var(--bg-topBarColor);
}
.el-menu-item:not(.is-active):hover,
.el-sub-menu:not(.is-active):hover .el-sub-menu__title {
color: var(--bg-topBarColor);
}
border-bottom: none !important;
width: 100% !important;
.el-menu-item,
.el-sub-menu__title {
height: 48px !important;
color: var(--bg-topBarColor);
}
.el-menu-item:not(.is-active):hover,
.el-sub-menu:not(.is-active):hover .el-sub-menu__title {
color: var(--bg-topBarColor);
}
}
// 菜单收起时,图标不居中问题
.el-menu--collapse {
.el-menu-item .iconfont,
.el-sub-menu .iconfont,
.el-menu-item .fa,
.el-sub-menu .fa {
margin-right: 0 !important;
}
.el-sub-menu__title {
padding-right: 0 !important;
}
.el-menu-item .iconfont,
.el-sub-menu .iconfont,
.el-menu-item .fa,
.el-sub-menu .fa {
margin-right: 0 !important;
}
.el-sub-menu__title {
padding-right: 0 !important;
}
}
/* Tabs 标签页
------------------------------- */
.el-tabs__nav-wrap::after {
height: 1px !important;
height: 1px !important;
}
/* Dropdown 下拉菜单
------------------------------- */
.el-dropdown-menu {
list-style: none !important; /*修复 Dropdown 下拉菜单样式问题 2022.03.04*/
}
.el-dropdown-menu .el-dropdown-menu__item {
white-space: nowrap;
&:not(.is-disabled):hover {
background-color: var(--el-dropdown-menuItem-hover-fill);
color: var(--el-dropdown-menuItem-hover-color);
}
list-style: none !important;
/*修复 Dropdown 下拉菜单样式问题 2022.03.04*/
}
/* Steps 步骤条
------------------------------- */
.el-step__icon-inner {
font-size: 30px !important;
font-weight: 400 !important;
}
.el-step__title {
font-size: 14px;
.el-dropdown-menu .el-dropdown-menu__item {
white-space: nowrap;
&:not(.is-disabled):hover {
background-color: var(--el-dropdown-menuItem-hover-fill);
color: var(--el-dropdown-menuItem-hover-color);
}
}
/* Dialog 对话框
------------------------------- */
.el-overlay {
overflow: hidden;
.el-overlay-dialog {
display: flex;
align-items: center;
justify-content: center;
position: unset !important;
width: 100%;
height: 100%;
.el-dialog {
margin: 0 auto !important;
position: absolute;
.el-dialog__body {
padding: 20px !important;
}
}
}
overflow: hidden;
.el-overlay-dialog {
display: flex;
align-items: center;
justify-content: center;
position: unset !important;
width: 100%;
height: 100%;
.el-dialog {
margin: 0 auto !important;
position: absolute;
.el-dialog__body {
padding: 20px !important;
}
}
}
}
.el-dialog__body {
max-height: calc(90vh - 111px) !important;
overflow-y: auto;
overflow-x: hidden;
max-height: calc(90vh - 111px) !important;
overflow-y: auto;
overflow-x: hidden;
}
/* Card 卡片
------------------------------- */
.el-card__header {
padding: 15px 20px;
padding: 15px 20px;
}
/* Table 表格 element plus 2.2.0 版本
------------------------------- */
.el-table {
.el-button.is-text {
padding: 0;
}
.el-button.is-text {
padding: 0;
}
}
/* scrollbar
------------------------------- */
.el-scrollbar__bar {
z-index: 4;
z-index: 4;
}
/*防止页面切换时,滚动条高度不变的问题(滚动条高度非滚动条滚动高度)*/
.el-scrollbar__wrap {
max-height: 100%;
max-height: 100%;
}
.el-select-dropdown .el-scrollbar__wrap {
overflow-x: scroll !important;
overflow-x: scroll !important;
}
/*修复Select 选择器高度问题*/
.el-select-dropdown__wrap {
max-height: 274px !important;
max-height: 274px !important;
}
/*修复Cascader 级联选择器高度问题*/
.el-cascader-menu__wrap.el-scrollbar__wrap {
height: 204px !important;
height: 204px !important;
}
/*用于界面高度自适应main.vue区分 scrollbar__view防止其它使用 scrollbar 的地方出现滚动条消失*/
.layout-container-view .el-scrollbar__view {
height: 100%;
height: 100%;
}
/*防止分栏布局二级菜单很多时,滚动条消失问题*/
.layout-columns-warp .layout-aside .el-scrollbar__view {
height: unset !important;
height: unset !important;
}
/* Pagination 分页
------------------------------- */
.el-pagination__editor {
margin-right: 8px;
margin-right: 8px;
}
/*深色模式时分页高亮问题*/
.el-pagination.is-background .btn-next.is-active,
.el-pagination.is-background .btn-prev.is-active,
.el-pagination.is-background .el-pager li.is-active {
background-color: var(--el-color-primary) !important;
color: var(--el-color-white) !important;
}
/* Drawer 抽屉
------------------------------- */
.el-drawer {
--el-drawer-padding-primary: unset !important;
.el-drawer__header {
padding: 0 15px !important;
height: 50px;
display: flex;
align-items: center;
margin-bottom: 0 !important;
border-bottom: 1px solid var(--el-border-color);
color: var(--el-text-color-primary);
}
.el-drawer__body {
width: 100%;
height: 100%;
overflow: auto;
}
background-color: var(--el-color-primary) !important;
color: var(--el-color-white) !important;
}
/* Breadcrumb 面包屑
------------------------------- */
.el-breadcrumb__inner a:hover,
.el-breadcrumb__inner.is-link:hover {
color: var(--el-color-primary);
color: var(--el-color-primary);
}
.el-breadcrumb__inner a,
.el-breadcrumb__inner.is-link {
color: var(--bg-topBarColor);
font-weight: normal;
color: var(--bg-topBarColor);
font-weight: normal;
}
// el-tooltip使用自定义主题时的样式
.el-popper.is-customized {
/* Set padding to ensure the height is 32px */
// padding: 6px 12px;
// padding: 6px 12px;
background: linear-gradient(90deg, rgb(159, 229, 151), rgb(204, 229, 129));
}
.el-popper.is-customized .el-popper__arrow::before {
background: linear-gradient(45deg, #b2e68d, #bce689);
right: 0;
@@ -343,7 +361,9 @@
.el-dialog {
border-radius: 6px; /* 设置圆角 */
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); /* 添加轻微阴影效果 */
border-radius: 6px;
/* 设置圆角 */
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
/* 添加轻微阴影效果 */
border: 1px solid var(--el-border-color-lighter);
}

View File

@@ -52,6 +52,7 @@ declare interface ThemeConfigState {
logoIcon: string;
globalI18n: string;
globalComponentSize: string;
terminalTheme: string;
terminalForeground: string;
terminalBackground: string;
terminalCursor: string;

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

@@ -98,4 +98,3 @@ export default {
}
}
</style>
@/router/staticRouter

View File

@@ -0,0 +1,220 @@
<template>
<div>
<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>
<el-form :model="form" ref="formRef" :rules="rules" label-width="auto">
<el-form-item prop="name" label="名称">
<el-input v-model.trim="form.name" placeholder="请输入流程名称" auto-complete="off" clearable></el-input>
</el-form-item>
<el-form-item prop="defKey" label="key">
<el-input :disabled="form.id" v-model.trim="form.defKey" placeholder="请输入流程key" auto-complete="off" clearable></el-input>
</el-form-item>
<el-form-item prop="status" label="状态">
<el-select v-model="form.status" placeholder="请选择状态">
<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="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%">
<el-table-column prop="name" label="名称" min-width="100px">
<template #header>
<el-button class="ml0" type="primary" circle size="small" icon="Plus" @click="addTask()"> </el-button>
<span class="ml10">节点名称</span>
<el-tooltip content="点击指定节点可进行拖拽排序" placement="top">
<el-icon class="ml5">
<question-filled />
</el-icon>
</el-tooltip>
</template>
<template #default="scope">
<el-input v-model="scope.row.name"> </el-input>
</template>
</el-table-column>
<el-table-column prop="userId" label="审核人员" min-width="150px" show-overflow-tooltip>
<template #default="scope">
<AccountSelectFormItem v-model="scope.row.userId" label="" />
</template>
</el-table-column>
<el-table-column label="操作" width="60px">
<template #default="scope">
<el-link @click="deleteTask(scope.$index)" class="ml5" type="danger" icon="delete" plain></el-link>
</template>
</el-table-column>
</el-table>
</el-form>
<template #footer>
<div>
<el-button @click="cancel()"> </el-button>
<el-button type="primary" :loading="saveBtnLoading" @click="btnOk"> </el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script lang="ts" setup>
import { toRefs, reactive, watch, ref, nextTick } from 'vue';
import { procdefApi } from './api';
import { ElMessage } from 'element-plus';
import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue';
import AccountSelectFormItem from '@/views/system/account/components/AccountSelectFormItem.vue';
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: {
type: [Boolean, Object],
},
title: {
type: String,
},
});
const visible = defineModel<boolean>('visible', { default: false });
//定义事件
const emit = defineEmits(['cancel', 'val-change']);
const formRef: any = ref(null);
const taskTableRef: any = ref(null);
const rules = {
name: [
{
required: true,
message: '请输入流程名称',
trigger: ['change', 'blur'],
},
],
defKey: [
{
required: true,
message: '请输入流程key',
trigger: ['change', 'blur'],
},
],
};
const state = reactive({
tasks: [] as any,
form: {
id: null,
name: null,
defKey: null,
status: null,
remark: null,
// 流程的审批节点任务
tasks: '',
codePaths: [],
},
sortable: '' as any,
});
const { form, tasks } = toRefs(state);
const { isFetching: saveBtnLoading, execute: saveFlowDefExec } = procdefApi.save.useApi(form);
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);
});
state.tasks = tasks;
} else {
state.form = { status: ProcdefStatus.Enable.value } as any;
state.tasks = [];
}
});
const initSort = () => {
nextTick(() => {
const table = taskTableRef.value.$el.querySelector('table > tbody') as any;
state.sortable = Sortable.create(table, {
animation: 200,
//拖拽结束事件
onEnd: (evt) => {
const curRow = state.tasks.splice(evt.oldIndex, 1)[0];
state.tasks.splice(evt.newIndex, 0, curRow);
},
});
});
};
const addTask = () => {
state.tasks.push({ taskKey: randomUuid() });
};
const deleteTask = (idx: any) => {
state.tasks.splice(idx, 1);
};
const btnOk = async () => {
try {
await formRef.value.validate();
} catch (e: any) {
ElMessage.error('请正确填写信息');
return false;
}
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 = () => {
if (state.tasks?.length == 0) {
return { err: '请完善审批节点任务' };
}
const tasks = [];
for (let i = 0; i < state.tasks.length; i++) {
const task = { ...state.tasks[i] };
if (!task.name || !task.userId) {
return { err: `请完善第${i + 1}个审批节点任务信息` };
}
// 转为字符串(方便后续万一需要调整啥的)
task.userId = `${task.userId}`;
if (!task.taskKey) {
task.taskKey = randomUuid();
}
tasks.push(task);
}
return { tasks: tasks };
};
const cancel = () => {
visible.value = false;
emit('cancel');
};
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,147 @@
<template>
<div>
<page-table
ref="pageTableRef"
:page-api="procdefApi.list"
:search-items="searchItems"
v-model:query-form="query"
:show-selection="true"
v-model:selection-data="selectionData"
:columns="columns"
>
<template #tableHeader>
<el-button v-auth="perms.save" type="primary" icon="plus" @click="editFlowDef(false)">添加</el-button>
<el-button v-auth="perms.del" :disabled="state.selectionData.length < 1" @click="deleteProcdef()" type="danger" icon="delete">删除</el-button>
</template>
<template #tasks="{ data }">
<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>
</page-table>
<el-dialog v-model="flowTasksDialog.visible" :title="flowTasksDialog.title">
<procdef-tasks :tasks="flowTasksDialog.tasks" />
</el-dialog>
<procdef-edit v-model:visible="flowDefEditor.visible" :title="flowDefEditor.title" v-model:data="flowDefEditor.data" @val-change="valChange()" />
</div>
</template>
<script lang="ts" setup>
import { ref, toRefs, reactive, onMounted, Ref } from 'vue';
import { procdefApi } from './api';
import { ElMessage, ElMessageBox } from 'element-plus';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { hasPerms } from '@/components/auth/auth';
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',
del: 'flow:procdef:del',
};
const searchItems = [SearchItem.input('name', '名称'), SearchItem.input('defKey', 'key')];
const columns = [
TableColumn.new('name', '名称'),
TableColumn.new('defKey', 'key'),
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 actionColumn = TableColumn.new('action', '操作').isSlot().fixedRight().setMinWidth(160).noShowOverflowTooltip().alignCenter();
const pageTableRef: Ref<any> = ref(null);
const state = reactive({
/**
* 选中的数据
*/
selectionData: [],
/**
* 查询条件
*/
query: {
name: '',
pageNum: 1,
pageSize: 0,
},
flowDefEditor: {
title: '新建流程定义',
visible: false,
data: null as any,
},
flowTasksDialog: {
title: '',
visible: false,
tasks: '',
},
});
const { selectionData, query, flowDefEditor, flowTasksDialog } = toRefs(state);
onMounted(() => {
if (Object.keys(actionBtns).length > 0) {
columns.push(actionColumn);
}
});
const search = async () => {
pageTableRef.value.search();
};
const showProcdefTasks = (procdef: any) => {
state.flowTasksDialog.tasks = procdef.tasks;
state.flowTasksDialog.title = procdef.name + '-审批节点';
state.flowTasksDialog.visible = true;
};
const editFlowDef = (data: any) => {
if (!data) {
state.flowDefEditor.data = null;
state.flowDefEditor.title = '新建流程定义';
} else {
state.flowDefEditor.data = data;
state.flowDefEditor.title = '编辑流程定义';
}
state.flowDefEditor.visible = true;
};
const valChange = () => {
state.flowDefEditor.visible = false;
search();
};
const deleteProcdef = async () => {
try {
await ElMessageBox.confirm(`确定删除【${state.selectionData.map((x: any) => x.name).join(', ')}】的流程定义?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await procdefApi.del.request({ id: state.selectionData.map((x: any) => x.id).join(',') });
ElMessage.success('删除成功');
search();
} catch (err) {
//
}
};
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,171 @@
<template>
<div>
<el-drawer :title="props.title" v-model="visible" :before-close="cancel" size="40%" :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-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="发起时间">{{ formatDate(procinst.createTime) }}</el-descriptions-item>
<div v-if="procinst.duration">
<el-descriptions-item label="持续时间">{{ formatTime(procinst.duration) }}</el-descriptions-item>
<el-descriptions-item label="结束时间">{{ formatDate(procinst.endTime) }}</el-descriptions-item>
</div>
<el-descriptions-item label="流程状态">
<enum-tag :enums="ProcinstStatus" :value="procinst.status"></enum-tag>
</el-descriptions-item>
<el-descriptions-item label="业务状态">
<enum-tag :enums="ProcinstBizStatus" :value="procinst.bizStatus"></enum-tag>
</el-descriptions-item>
<el-descriptions-item label="备注">
{{ procinst.remark }}
</el-descriptions-item>
</el-descriptions>
</div>
<div>
<el-divider content-position="left">审批节点</el-divider>
<procdef-tasks :tasks="procinst?.procdef?.tasks" :procinst-tasks="procinst.procinstTasks" />
</div>
<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>
</div>
<div v-if="props.instTaskId">
<el-divider content-position="left">审批表单</el-divider>
<el-form :model="form" label-width="auto">
<el-form-item prop="status" label="结果" required>
<el-select v-model="form.status" placeholder="请选择审批结果">
<el-option :label="ProcinstTaskStatus.Pass.label" :value="ProcinstTaskStatus.Pass.value"> </el-option>
<!-- <el-option :label="ProcinstTaskStatus.Back.label" :value="ProcinstTaskStatus.Back.value"> </el-option> -->
<el-option :label="ProcinstTaskStatus.Reject.label" :value="ProcinstTaskStatus.Reject.value"> </el-option>
</el-select>
</el-form-item>
<el-form-item prop="remark" label="备注">
<el-input v-model.trim="form.remark" placeholder="备注" type="textarea" clearable></el-input>
</el-form-item>
</el-form>
</div>
<template #footer v-if="props.instTaskId">
<div>
<el-button @click="cancel()"> </el-button>
<el-button type="primary" :loading="saveBtnLoading" @click="btnOk"> </el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script lang="ts" setup>
import { toRefs, reactive, watch, defineAsyncComponent, shallowReactive } from 'vue';
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 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 props = defineProps({
procinstId: {
type: Number,
},
// 流程实例任务id存在则展示审批相关信息
instTaskId: {
type: Number,
},
title: {
type: String,
},
});
const visible = defineModel<boolean>('visible', { default: false });
//定义事件
const emit = defineEmits(['cancel', 'val-change']);
// 业务组件
const bizComponents = shallowReactive({
db_sql_exec_flow: DbSqlExecBiz,
redis_run_write_cmd_flow: RedisRunWriteCmdBiz,
});
const state = reactive({
procinst: {} as any,
tasks: [] as any,
form: {
status: ProcinstTaskStatus.Pass.value,
remark: '',
},
saveBtnLoading: false,
sortable: '' as any,
});
const { procinst, form, saveBtnLoading } = toRefs(state);
watch(
() => props.procinstId,
async (newValue: any) => {
if (newValue) {
state.procinst = await procinstApi.detail.request({ id: newValue });
} else {
state.procinst = {};
}
}
);
const btnOk = async () => {
const status = state.form.status;
let api = procinstApi.completeTask;
if (status === ProcinstTaskStatus.Back.value) {
api = procinstApi.backTask;
} else if (status === ProcinstTaskStatus.Reject.value) {
api = procinstApi.rejectTask;
}
try {
state.saveBtnLoading = true;
await api.request({ id: props.instTaskId, remark: state.form.remark });
ElMessage.success('操作成功');
cancel();
emit('val-change');
} finally {
state.saveBtnLoading = false;
}
};
const cancel = () => {
visible.value = false;
emit('cancel');
};
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,126 @@
<template>
<div>
<page-table
ref="pageTableRef"
:page-api="procinstApi.list"
:search-items="searchItems"
v-model:query-form="query"
v-model:selection-data="selectionData"
:columns="columns"
>
<template #tableHeader>
<!-- <el-button v-auth="perms.addAccount" type="primary" icon="plus" @click="editFlowDef(false)">添加</el-button> -->
</template>
<template #action="{ data }">
<el-button link @click="showProcinst(data)" type="primary">查看</el-button>
<el-popconfirm
v-if="data.status == ProcinstStatus.Active.value || data.status == ProcinstStatus.Suspended.value"
title="确认取消该流程?"
width="160"
@confirm="procinstCancel(data)"
>
<template #reference>
<el-button link type="warning">取消</el-button>
</template>
</el-popconfirm>
</template>
</page-table>
<ProcinstDetail
v-model:visible="procinstDetail.visible"
:title="procinstDetail.title"
:procinst-id="procinstDetail.procinstId"
:inst-task-id="procinstDetail.instTaskId"
@val-change="valChange()"
@cancel="procinstDetail.procinstId = 0"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, toRefs, reactive, Ref } from 'vue';
import { procinstApi } from './api';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { SearchItem } from '@/components/SearchForm';
import ProcinstDetail from './ProcinstDetail.vue';
import { FlowBizType, ProcinstBizStatus, ProcinstStatus } from './enums';
import { ElMessage } from 'element-plus';
import { formatTime } from '@/common/utils/format';
const searchItems = [
SearchItem.select('status', '流程状态').withEnum(ProcinstStatus),
SearchItem.select('bizType', '业务类型').withEnum(FlowBizType),
SearchItem.input('bizKey', '业务key'),
];
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),
TableColumn.new('createTime', '发起时间').isTime(),
TableColumn.new('endTime', '结束时间').isTime(),
TableColumn.new('duration', '持续时间').setFormatFunc((data: any, prop: string) => {
const duration = data[prop];
if (!duration) {
return '';
}
return formatTime(duration);
}),
TableColumn.new('bizHandleRes', '业务处理结果'),
TableColumn.new('action', '操作').isSlot().fixedRight().setMinWidth(160).noShowOverflowTooltip().alignCenter(),
];
const pageTableRef: Ref<any> = ref(null);
const state = reactive({
/**
* 选中的数据
*/
selectionData: [],
/**
* 查询条件
*/
query: {
status: null,
bizType: '',
pageNum: 1,
pageSize: 0,
},
procinstDetail: {
title: '查看流程',
visible: false,
procinstId: 0,
instTaskId: 0,
},
});
const { selectionData, query, procinstDetail } = toRefs(state);
const search = async () => {
pageTableRef.value.search();
};
const procinstCancel = async (data: any) => {
await procinstApi.cancel.request({ id: data.id });
ElMessage.success('操作成功');
search();
};
const showProcinst = (data: any) => {
state.procinstDetail.procinstId = data.id;
state.procinstDetail.title = '流程查看';
state.procinstDetail.visible = true;
};
const valChange = () => {
state.procinstDetail.visible = false;
search();
};
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,111 @@
<template>
<div>
<page-table
ref="pageTableRef"
:page-api="procinstApi.tasks"
:search-items="searchItems"
v-model:query-form="query"
v-model:selection-data="selectionData"
:columns="columns"
>
<template #tableHeader>
<!-- <el-button v-auth="perms.addAccount" type="primary" icon="plus" @click="editFlowDef(false)">添加</el-button> -->
</template>
<template #action="{ data }">
<el-button link @click="showProcinst(data, false)" type="primary">查看</el-button>
<el-button v-if="data.status == ProcinstTaskStatus.Process.value" link @click="showProcinst(data, true)" type="primary">审核</el-button>
</template>
</page-table>
<ProcinstDetail
v-model:visible="procinstDetail.visible"
:title="procinstDetail.title"
:procinst-id="procinstDetail.procinstId"
:inst-task-id="procinstDetail.instTaskId"
@val-change="valChange()"
@cancel="procinstDetail.procinstId = 0"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, toRefs, reactive, Ref } from 'vue';
import { procinstApi } from './api';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { SearchItem } from '@/components/SearchForm';
import ProcinstDetail from './ProcinstDetail.vue';
import { FlowBizType, ProcinstStatus, ProcinstTaskStatus } from './enums';
import { formatTime } from '@/common/utils/format';
const searchItems = [SearchItem.select('status', '任务状态').withEnum(ProcinstTaskStatus), SearchItem.select('bizType', '业务类型').withEnum(FlowBizType)];
const columns = [
TableColumn.new('procinst.bizType', '业务').typeTag(FlowBizType),
TableColumn.new('procinst.remark', '备注'),
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(),
TableColumn.new('createTime', '开始时间').isTime(),
TableColumn.new('endTime', '结束时间').isTime(),
TableColumn.new('duration', '持续时间').setFormatFunc((data: any, prop: string) => {
const duration = data[prop];
if (!duration) {
return '';
}
return formatTime(duration);
}),
TableColumn.new('action', '操作').isSlot().fixedRight().setMinWidth(160).noShowOverflowTooltip().alignCenter(),
];
const pageTableRef: Ref<any> = ref(null);
const state = reactive({
/**
* 选中的数据
*/
selectionData: [],
/**
* 查询条件
*/
query: {
status: ProcinstTaskStatus.Process.value,
bizType: '',
pageNum: 1,
pageSize: 0,
},
procinstDetail: {
title: '查看流程',
visible: false,
procinstId: 0,
instTaskId: 0,
},
});
const { selectionData, query, procinstDetail } = toRefs(state);
const search = async () => {
pageTableRef.value.search();
};
const showProcinst = (data: any, audit: boolean) => {
state.procinstDetail.procinstId = data.procinstId;
if (!audit) {
state.procinstDetail.instTaskId = 0;
state.procinstDetail.title = '流程查看';
} else {
state.procinstDetail.instTaskId = data.id;
state.procinstDetail.title = '流程审批';
}
state.procinstDetail.visible = true;
};
const valChange = () => {
state.procinstDetail.visible = false;
search();
};
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,20 @@
import Api from '@/common/Api';
export const procdefApi = {
list: Api.newGet('/flow/procdefs'),
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'),
detail: Api.newGet('/flow/procinsts/{id}'),
cancel: Api.newPost('/flow/procinsts/{id}/cancel'),
tasks: Api.newGet('/flow/procinsts/tasks'),
completeTask: Api.newPost('/flow/procinsts/tasks/complete'),
backTask: Api.newPost('/flow/procinsts/tasks/back'),
rejectTask: Api.newPost('/flow/procinsts/tasks/reject'),
save: Api.newPost('/flow/procdefs'),
del: Api.newDelete('/flow/procdefs/{id}'),
};

View File

@@ -0,0 +1,33 @@
<template>
<el-form-item :label="props.label">
<el-select style="width: 100%" v-model="procdefKey" filterable placeholder="绑定流程则开启对应审批流程" v-bind="$attrs" clearable>
<el-option v-for="item in procdefs" :key="item.defKey" :label="`${item.defKey} [${item.name}]`" :value="item.defKey"> </el-option>
</el-select>
</el-form-item>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { procdefApi } from '../api';
const props = defineProps({
label: {
type: String,
default: '工单流程',
},
});
onMounted(() => {
getProcdefs();
});
const procdefKey = defineModel('modelValue');
const procdefs: any = ref([]);
const getProcdefs = () => {
procdefApi.list.request({ pageSize: 200 }).then((res) => {
procdefs.value = res.list;
});
};
</script>

View File

@@ -0,0 +1,134 @@
<template>
<el-steps align-center :active="stepActive">
<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">{{ `${formatDate(task.completeTime)}` }}</div>
<div v-if="task.remark">{{ task.remark }}</div>
</template>
</el-step>
</el-steps>
</template>
<script lang="ts" setup>
import { toRefs, reactive, watch, onMounted } from 'vue';
import { accountApi } from '../../system/api';
import { ProcinstTaskStatus } from '../enums';
import { formatDate } from '@/common/utils/format';
import { ElSteps, ElStep } from 'element-plus';
const props = defineProps({
// 流程定义任务
tasks: {
type: [String, Object],
},
procdef: {
type: [Object],
},
// 流程实例任务列表
procinstTasks: {
type: [Array],
},
});
const state = reactive({
tasksArr: [] as any,
stepActive: 0,
});
const { tasksArr, stepActive } = toRefs(state);
watch(
() => props.tasks,
(newValue: any) => {
parseTasks(newValue);
}
);
watch(
() => props.procinstTasks,
() => {
parseTasks(props.tasks);
}
);
watch(
() => props.procdef,
async (newValue: any) => {
if (newValue) {
parseTasksByKey(newValue);
}
}
);
onMounted(() => {
if (props.procdef) {
parseTasksByKey(props.procdef);
return;
}
parseTasks(props.tasks);
});
const parseTasksByKey = async (procdef: any) => {
parseTasks(procdef.tasks);
};
const parseTasks = async (tasksStr: any) => {
if (!tasksStr) return;
const tasks = JSON.parse(tasksStr);
const userIds = tasks.map((x: any) => x.userId);
const usersRes = await accountApi.querySimple.request({ ids: [...new Set(userIds)].join(','), pageSize: 50 });
const users = usersRes.list;
// 将数组转换为 Map 结构,以 id 为 key
const userMap = users.reduce((acc: any, obj: any) => {
acc.set(obj.id, obj);
return acc;
}, new Map());
// 流程实例任务(用于显示完成时间,完成到哪一步等)
let instTasksMap: any;
if (props.procinstTasks) {
state.stepActive = props.procinstTasks.length - 1;
instTasksMap = props.procinstTasks.reduce((acc: any, obj: any) => {
acc.set(obj.taskKey, obj);
return acc;
}, new Map());
}
for (let task of tasks) {
const user = userMap.get(Number.parseInt(task.userId));
task.accountUsername = user.username;
task.accountName = user.name;
// 存在实例任务,则赋值实例任务对应的完成时间和备注
const instTask = instTasksMap?.get(task.taskKey);
if (instTask) {
task.status = instTask.status;
task.completeTime = instTask.endTime;
task.remark = instTask.remark;
}
}
state.tasksArr = tasks;
};
const getStepStatus = (task: any): any => {
const taskStatus = task.status;
if (!taskStatus) {
return 'wait';
}
if (taskStatus == ProcinstTaskStatus.Pass.value) {
return 'success';
}
if (taskStatus == ProcinstTaskStatus.Process.value) {
return 'proccess';
}
if (taskStatus == ProcinstTaskStatus.Back.value || taskStatus == ProcinstTaskStatus.Reject.value) {
return 'error';
}
return 'wait';
};
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,34 @@
import { EnumValue } from '@/common/Enum';
export const ProcdefStatus = {
Enable: EnumValue.of(1, '启用').setTagType('success'),
Disable: EnumValue.of(-1, '禁用').setTagType('warning'),
};
export const ProcinstStatus = {
Active: EnumValue.of(1, '执行中').setTagType('primary'),
Completed: EnumValue.of(2, '完成').setTagType('success'),
Suspended: EnumValue.of(-1, '挂起').setTagType('warning'),
Terminated: EnumValue.of(-2, '终止').setTagType('danger'),
Cancelled: EnumValue.of(-3, '取消').setTagType('warning'),
};
export const ProcinstBizStatus = {
Wait: EnumValue.of(1, '待处理').setTagType('primary'),
Success: EnumValue.of(2, '处理成功').setTagType('success'),
Fail: EnumValue.of(-2, '处理失败').setTagType('danger'),
No: EnumValue.of(-1, '不处理').setTagType('warning'),
};
export const ProcinstTaskStatus = {
Process: EnumValue.of(1, '待处理').setTagType('primary'),
Pass: EnumValue.of(2, '通过').setTagType('success'),
Reject: EnumValue.of(-1, '拒绝').setTagType('danger'),
Back: EnumValue.of(-2, '驳回').setTagType('warning'),
Canceled: EnumValue.of(-3, '取消').setTagType('warning'),
};
export const FlowBizType = {
DbSqlExec: EnumValue.of('db_sql_exec_flow', 'DBMS-执行SQL'),
RedisRunWriteCmd: EnumValue.of('redis_run_write_cmd_flow', 'Redis-执行write命令'),
};

View File

@@ -0,0 +1,79 @@
<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

@@ -0,0 +1,80 @@
<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

@@ -1,121 +1,541 @@
<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="" multiple :limit="1">
<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" />
</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" />
</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" />
</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" />
</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 { toRefs, reactive, onMounted, computed } from 'vue';
// import * as echarts from 'echarts';
import { CountUp } from 'countup.js';
import { formatAxis } 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 { formatDate } from '@/common/utils/format';
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';
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: [],
},
db: {
num: 0,
opLogs: [],
},
redis: {
num: 0,
opLogs: [],
},
mongo: {
num: 0,
opLogs: [],
},
});
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 initNumCountUp = async () => {
const res: any = await indexApi.getIndexCount.request();
nextTick(() => {
new CountUp('mongoNum', res.mongoNum).start();
new CountUp('machineNum', res.machineNum).start();
new CountUp('dbNum', res.dbNum).start();
new CountUp('redisNum', res.redisNum).start();
const initData = async () => {
resourceOpLogApi.getAccountResourceOpLogs
.request({ resourceType: TagResourceTypeEnum.MachineAuthCert.value, pageSize: state.defaultLogSize })
.then((res: any) => {
state.machine.opLogs = res.list;
});
resourceOpLogApi.getAccountResourceOpLogs.request({ resourceType: TagResourceTypeEnum.DbName.value, pageSize: state.defaultLogSize }).then((res: any) => {
state.db.opLogs = res.list;
});
resourceOpLogApi.getAccountResourceOpLogs.request({ resourceType: TagResourceTypeEnum.Redis.value, pageSize: state.defaultLogSize }).then((res: any) => {
state.redis.opLogs = res.list;
});
resourceOpLogApi.getAccountResourceOpLogs.request({ resourceType: TagResourceTypeEnum.Mongo.value, pageSize: state.defaultLogSize }).then((res: any) => {
state.mongo.opLogs = res.list;
});
indexApi.machineDashbord.request().then((res: any) => {
state.machine.num = res.machineNum;
});
indexApi.dbDashbord.request().then((res: any) => {
state.db.num = res.dbNum;
});
indexApi.redisDashbord.request().then((res: any) => {
state.redis.num = res.redisNum;
});
indexApi.mongoDashbord.request().then((res: any) => {
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');
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">
@import '@/theme/mixins/index.scss';
.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 text-ellipsis(1);
}
.personal-item {
display: flex;
align-items: center;
font-size: 13px;
.personal-item-label {
color: gray;
@include text-ellipsis(1);
}
.personal-item-value {
@include 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 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;
@@ -166,7 +586,7 @@ onMounted(() => {
}
.home-card-item-title-num {
font-size: 18px;
font-size: 2vw;
}
.home-card-item-tip-num {
@@ -174,124 +594,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

@@ -1,6 +1,8 @@
import Api from '@/common/Api';
export const indexApi = {
getIndexCount: Api.newGet("/common/index/count"),
}
machineDashbord: Api.newGet('/machines/dashbord'),
dbDashbord: Api.newGet('/dbs/dashbord'),
redisDashbord: Api.newGet('/redis/dashbord'),
mongoDashbord: Api.newGet('/mongos/dashbord'),
};

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';
@@ -279,19 +279,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,22 +328,23 @@ 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) => {
@@ -366,7 +368,7 @@ const loginResDeal = (loginRes: any) => {
const token = loginRes.token;
// 如果不需要 otp校验则该token即为accessToken否则为otp校验token
if (loginRes.otp == -1) {
signInSuccess(token);
signInSuccess(token, loginRes.refresh_token);
return;
}
@@ -379,12 +381,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 +421,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,266 @@
<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-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 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 || x.name == authCert.name).length > 0) {
ElMessage.error('该名称或用户名已存在于该账号列表中');
return;
}
authCerts.value.push(authCert);
cancelEdit();
};
</script>
<style lang="scss"></style>

View File

@@ -1,45 +0,0 @@
<template>
<div style="display: inline-flex; justify-content: center; align-items: center; cursor: pointer; vertical-align: middle">
<el-popover :show-after="500" @show="getTags" placement="top-start" width="230" trigger="hover">
<template #reference>
<div>
<!-- <el-button type="primary" link size="small">标签</el-button> -->
<SvgIcon name="view" :size="16" color="var(--el-color-primary)" />
</div>
</template>
<el-tag effect="plain" v-for="tag in tags" :key="tag" class="ml5" type="success" size="small">{{ tag.tagPath }}</el-tag>
</el-popover>
</div>
</template>
<script lang="ts" setup>
import { reactive, toRefs } from 'vue';
import { tagApi } from '../tag/api';
import SvgIcon from '@/components/svgIcon/index.vue';
const props = defineProps({
resourceCode: {
type: [String],
required: true,
},
resourceType: {
type: [Number],
required: true,
},
});
const state = reactive({
tags: [] as any,
});
const { tags } = toRefs(state);
const getTags = async () => {
state.tags = await tagApi.getTagResources.request({
resourceCode: props.resourceCode,
resourceType: props.resourceType,
});
};
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,33 @@
<template>
<div v-if="props.tags">
<el-row v-for="(tag, idx) in props.tags?.slice(0, 1)" :key="idx">
<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">
<template #reference>
<SvgIcon class="mt5 ml5" color="var(--el-color-primary)" name="MoreFilled" />
</template>
<el-row v-for="i in props.tags.slice(1)" :key="i">
<TagInfo :tag-path="i.codePath" />
<span class="ml3">{{ i.codePath }}</span>
</el-row>
</el-popover>
</el-row>
</div>
</template>
<script lang="ts" setup>
import SvgIcon from '@/components/svgIcon/index.vue';
import TagInfo from './TagInfo.vue';
const props = defineProps({
tags: {
type: [Array<any>],
required: true,
},
});
</script>
<style lang="scss"></style>

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,87 @@
<template>
<div v-if="paths">
<el-row v-for="(path, idx) in paths?.slice(0, 1)" :key="idx">
<span v-for="item in parseTagPath(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.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.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 } from 'vue';
const props = defineProps({
path: {
type: [String, Array<string>],
},
});
const paths = computed(() => {
if (Array.isArray(props.path)) {
return props.path;
}
return [props.path];
});
const parseTagPath = (tagPath: string = '') => {
if (!tagPath) {
return [];
}
const res = [] as any;
const codes = tagPath.split('/');
for (let code of codes) {
const typeAndCode = code.split('|');
if (typeAndCode.length == 1) {
const tagCode = typeAndCode[0];
if (!tagCode) {
continue;
}
res.push({
type: TagResourceTypeEnum.Tag.value,
code: typeAndCode[0],
});
continue;
}
res.push({
type: typeAndCode[0],
code: typeAndCode[1],
});
}
res[res.length - 1].isEnd = true;
return res;
};
</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>
<span @dblclick="treeNodeDblclick(data)" :class="data.type.nodeDblclickFunc ? 'none-select' : ''">
<span v-if="data.type.value == TagTreeNode.TagPath">
<tag-info :tag-path="data.label" />
</span>
@@ -25,10 +26,18 @@
<slot v-else :node="node" :data="data" name="prefix"></slot>
<span class="ml3" :title="data.labelRemark">
<slot name="label" :data="data"> {{ data.label }}</slot>
<slot name="label" :data="data" v-if="!data.disabled"> {{ data.label }}</slot>
<!-- 禁用状态 -->
<slot name="disabledLabel" :data="data" v-else>
<el-link type="danger" disabled :underline="false">
{{ `${data.label}` }}
</el-link>
</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>
@@ -50,6 +59,9 @@ const props = defineProps({
type: [Number],
required: true,
},
defaultExpandedKeys: {
type: [Array],
},
tagPathNodeType: {
type: [NodeType],
required: true,
@@ -134,16 +146,30 @@ const loadNode = async (node: any, resolve: any) => {
};
const treeNodeClick = (data: any) => {
emit('nodeClick', data);
if (data.type.nodeClickFunc) {
if (!data.disabled && !data.type.nodeDblclickFunc && data.type.nodeClickFunc) {
emit('nodeClick', data);
data.type.nodeClickFunc(data);
}
// 关闭可能存在的右击菜单
contextmenuRef.value.closeContextmenu();
};
// 树节点双击事件
const treeNodeDblclick = (data: any) => {
// emit('nodeDblick', data);
if (!data.disabled && data.type.nodeDblclickFunc) {
data.type.nodeDblclickFunc(data);
}
// 关闭可能存在的右击菜单
contextmenuRef.value.closeContextmenu();
};
// 树节点右击事件
const nodeContextmenu = (event: any, data: any) => {
if (data.disabled) {
return;
}
// 加载当前节点是否需要显示右击菜单
let items = data.type.contextMenuItems;
if (!items || items.length == 0) {
@@ -179,18 +205,32 @@ const getNode = (nodeKey: any) => {
return node;
};
const setCurrentKey = (nodeKey: any) => {
treeRef.value.setCurrentKey(nodeKey);
};
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="checkedTags"
: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.code }}
<span style="color: #3c8dbc"></span>
{{ data.name }}
<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';
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({
tags: [],
});
onMounted(() => {
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) => {
if (!value) {
return true;
}
return data.codePath.toLowerCase().includes(value) || data.name.includes(value);
};
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

@@ -14,6 +14,9 @@
v-model="modelValue"
@change="changeNode"
>
<template #prefix="{ node, data }">
<slot name="iconPrefix" :node="node" :data="data" />
</template>
<template #default="{ node, data }">
<span>
<span v-if="data.type.value == TagTreeNode.TagPath">
@@ -33,7 +36,7 @@
</template>
<script lang="ts" setup>
import { onMounted, reactive, ref, watch, toRefs } from 'vue';
import { onMounted, reactive, ref, toRefs, watch } from 'vue';
import { NodeType, TagTreeNode } from './tag';
import TagInfo from './TagInfo.vue';
import { tagApi } from '../tag/api';

View File

@@ -2,23 +2,22 @@
<div>
<el-tree-select
v-bind="$attrs"
v-model="selectTags"
v-model="state.selectTags"
@change="changeTag"
style="width: 100%"
:data="tags"
placeholder="请选择关联标签"
:render-after-expand="true"
:default-expanded-keys="[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,42 +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({
resourceCode: {
type: [String],
required: true,
selectTags: {
type: [Array<any>, Object],
},
resourceType: {
type: [Number],
required: true,
tagType: {
type: Number,
default: TagResourceTypeEnum.Tag.value,
},
});
const state = reactive({
tags: [],
// 单选则为id多选为id数组
selectTags: [],
// 单选则为codePath多选为codePath数组
selectTags: [] as any,
});
const { tags, selectTags } = toRefs(state);
const { tags } = toRefs(state);
onMounted(async () => {
if (props.resourceCode) {
const resourceTags = await tagApi.getTagResources.request({
resourceCode: props.resourceCode,
resourceType: props.resourceType,
});
state.selectTags = resourceTags.map((x: any) => x.tagId);
changeTag();
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

@@ -28,6 +28,11 @@ export class TagTreeNode {
*/
isLeaf: boolean = false;
/**
* 是否禁用状态
*/
disabled: boolean = false;
/**
* 额外需要传递的参数
*/
@@ -53,6 +58,11 @@ export class TagTreeNode {
return this;
}
withDisabled(disabled: boolean) {
this.disabled = disabled;
return this;
}
withParams(params: any) {
this.params = params;
return this;
@@ -91,8 +101,14 @@ export class NodeType {
loadNodesFunc: (parentNode: TagTreeNode) => Promise<TagTreeNode[]>;
/**
* 节点点击事件
*/
nodeClickFunc: (node: TagTreeNode) => void;
// 节点双击事件
nodeDblclickFunc: (node: TagTreeNode) => void;
constructor(value: number) {
this.value = value;
}
@@ -117,6 +133,16 @@ export class NodeType {
return this;
}
/**
* 赋值节点双击事件回调函数
* @param func 节点双击事件回调函数
* @returns this
*/
withNodeDblclickFunc(func: (node: TagTreeNode) => void) {
this.nodeDblclickFunc = func;
return this;
}
/**
* 赋值右击菜单按钮选项
* @param contextMenuItems 右击菜单按钮选项
@@ -145,3 +171,44 @@ 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 = {};
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;
}
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

@@ -23,13 +23,16 @@
</el-form-item>
<el-form-item prop="name" label="任务名称">
<el-input v-model.number="state.form.name" type="text" placeholder="任务名称"></el-input>
<el-input v-model="state.form.name" type="text" placeholder="任务名称"></el-input>
</el-form-item>
<el-form-item prop="startTime" label="开始时间">
<el-date-picker v-model="state.form.startTime" type="datetime" placeholder="开始时间" />
</el-form-item>
<el-form-item prop="intervalDay" label="备份周期">
<el-input v-model.number="state.form.intervalDay" type="number" placeholder="备份周期(单位:天"></el-input>
<el-form-item prop="intervalDay" label="备份周期(天)">
<el-input v-model.number="state.form.intervalDay" type="number" placeholder="单位:天"></el-input>
</el-form-item>
<el-form-item prop="maxSaveDays" label="备份历史保留天数">
<el-input v-model.number="state.form.maxSaveDays" type="number" placeholder="0: 永久保留"></el-input>
</el-form-item>
</el-form>
@@ -92,6 +95,14 @@ const rules = {
trigger: ['change', 'blur'],
},
],
maxSaveDays: [
{
required: true,
pattern: /^[0-9]\d*$/,
message: '请输入非负整数',
trigger: ['change', 'blur'],
},
],
};
const backupForm: any = ref(null);
@@ -101,10 +112,11 @@ const state = reactive({
id: 0,
dbId: 0,
dbNames: '',
name: null as any,
intervalDay: null,
name: '',
intervalDay: 1,
startTime: null as any,
repeated: null as any,
repeated: true,
maxSaveDays: 0,
},
btnLoading: false,
dbNamesSelected: [] as any,
@@ -137,12 +149,14 @@ const init = (data: any) => {
state.form.name = data.name;
state.form.intervalDay = data.intervalDay;
state.form.startTime = data.startTime;
state.form.maxSaveDays = data.maxSaveDays;
} else {
state.editOrCreate = false;
state.form.name = '';
state.form.intervalDay = null;
state.form.intervalDay = 1;
const now = new Date();
state.form.startTime = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
state.form.maxSaveDays = 0;
getDbNamesWithoutBackup();
}
};

View File

@@ -0,0 +1,155 @@
<template>
<div class="db-backup-history">
<page-table
height="100%"
ref="pageTableRef"
:page-api="dbApi.getDbBackupHistories"
:show-selection="true"
v-model:selection-data="state.selectedData"
:searchItems="searchItems"
:before-query-fn="beforeQueryFn"
v-model:query-form="query"
:columns="columns"
>
<template #dbSelect>
<el-select v-model="query.dbName" placeholder="请选择数据库" style="width: 200px" filterable clearable>
<el-option v-for="item in props.dbNames" :key="item" :label="`${item}`" :value="item"> </el-option>
</el-select>
</template>
<template #tableHeader>
<el-button type="primary" icon="back" @click="restoreDbBackupHistory(null)">立即恢复</el-button>
<el-button type="danger" icon="delete" @click="deleteDbBackupHistory(null)">删除</el-button>
</template>
<template #action="{ data }">
<div>
<el-button @click="restoreDbBackupHistory(data)" type="primary" link>立即恢复</el-button>
<el-button @click="deleteDbBackupHistory(data)" type="danger" link>删除</el-button>
</div>
</template>
</page-table>
</div>
</template>
<script lang="ts" setup>
import { toRefs, reactive, Ref, ref } from 'vue';
import { dbApi } from './api';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { SearchItem } from '@/components/SearchForm';
import { ElMessage, ElMessageBox } from 'element-plus';
const pageTableRef: Ref<any> = ref(null);
const props = defineProps({
dbId: {
type: [Number],
required: true,
},
dbNames: {
type: [Array<String>],
required: true,
},
});
const searchItems = [SearchItem.slot('dbName', '数据库名称', 'dbSelect')];
const columns = [
TableColumn.new('dbName', '数据库名称'),
TableColumn.new('name', '备份名称'),
TableColumn.new('createTime', '创建时间').isTime(),
TableColumn.new('lastResult', '恢复结果'),
TableColumn.new('lastTime', '恢复时间').isTime(),
TableColumn.new('action', '操作').isSlot().setMinWidth(160).fixedRight(),
];
const emptyQuery = {
dbId: 0,
dbName: '',
pageNum: 1,
pageSize: 10,
};
const state = reactive({
data: [],
total: 0,
query: emptyQuery,
/**
* 选中的数据
*/
selectedData: [],
});
const { query } = toRefs(state);
const beforeQueryFn = (query: any) => {
query.dbId = props.dbId;
return query;
};
const search = async () => {
await pageTableRef.value.search();
};
const deleteDbBackupHistory = async (data: any) => {
let backupHistoryId: string;
if (data) {
backupHistoryId = data.id;
} else if (state.selectedData.length > 0) {
backupHistoryId = state.selectedData.map((x: any) => x.id).join(' ');
} else {
ElMessage.error('请选择需要删除的数据库备份历史');
return;
}
await ElMessageBox.confirm(`确定删除 “数据库备份历史” 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await dbApi.deleteDbBackupHistory.request({ dbId: props.dbId, backupHistoryId: backupHistoryId });
await search();
ElMessage.success('删除成功');
};
const restoreDbBackupHistory = async (data: any) => {
let backupHistoryId: string;
if (data) {
backupHistoryId = data.id;
} else if (state.selectedData.length > 0) {
const pluralDbNames: string[] = [];
const dbNames: Map<string, boolean> = new Map();
state.selectedData.forEach((item: any) => {
if (!dbNames.has(item.dbName)) {
dbNames.set(item.dbName, false);
return;
}
if (!dbNames.get(item.dbName)) {
dbNames.set(item.dbName, true);
pluralDbNames.push(item.dbName);
}
});
if (pluralDbNames.length > 0) {
ElMessage.error('多次选择相同数据库:' + pluralDbNames.join(', '));
return;
}
backupHistoryId = state.selectedData.map((x: any) => x.id).join(' ');
} else {
ElMessage.error('请选择需要恢复的数据库备份历史');
return;
}
await ElMessageBox.confirm(`确定从 “数据库备份历史” 中恢复数据库吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await dbApi.restoreDbBackupHistory.request({
dbId: props.dbId,
backupHistoryId: backupHistoryId,
});
await search();
ElMessage.success('成功创建数据库恢复任务');
};
</script>
<style lang="scss"></style>

View File

@@ -21,6 +21,7 @@
<el-button type="primary" icon="plus" @click="createDbBackup()">添加</el-button>
<el-button type="primary" icon="video-play" @click="enableDbBackup(null)">启用</el-button>
<el-button type="primary" icon="video-pause" @click="disableDbBackup(null)">禁用</el-button>
<el-button type="danger" icon="delete" @click="deleteDbBackup(null)">删除</el-button>
</template>
<template #action="{ data }">
@@ -29,6 +30,7 @@
<el-button v-if="!data.enabled" @click="enableDbBackup(data)" type="primary" link>启用</el-button>
<el-button v-if="data.enabled" @click="disableDbBackup(data)" type="primary" link>禁用</el-button>
<el-button v-if="data.enabled" @click="startDbBackup(data)" type="primary" link>立即备份</el-button>
<el-button @click="deleteDbBackup(data)" type="danger" link>删除</el-button>
</div>
</template>
</page-table>
@@ -49,7 +51,7 @@ import { dbApi } from './api';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { SearchItem } from '@/components/SearchForm';
import { ElMessage } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
const DbBackupEdit = defineAsyncComponent(() => import('./DbBackupEdit.vue'));
const pageTableRef: Ref<any> = ref(null);
@@ -72,10 +74,10 @@ const columns = [
TableColumn.new('name', '任务名称'),
TableColumn.new('startTime', '启动时间').isTime(),
TableColumn.new('intervalDay', '备份周期'),
TableColumn.new('enabled', '是否启用'),
TableColumn.new('enabledDesc', '是否启用'),
TableColumn.new('lastResult', '执行结果'),
TableColumn.new('lastTime', '执行时间').isTime(),
TableColumn.new('action', '操作').isSlot().setMinWidth(180).fixedRight(),
TableColumn.new('action', '操作').isSlot().setMinWidth(220).fixedRight(),
];
const emptyQuery = {
@@ -168,5 +170,25 @@ const startDbBackup = async (data: any) => {
await search();
ElMessage.success('备份任务启动成功');
};
const deleteDbBackup = async (data: any) => {
let backupId: string;
if (data) {
backupId = data.id;
} else if (state.selectedData.length > 0) {
backupId = state.selectedData.map((x: any) => x.id).join(' ');
} else {
ElMessage.error('请选择需要删除的数据库备份任务');
return;
}
await ElMessageBox.confirm(`确定删除 “数据库备份任务” 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await dbApi.deleteDbBackup.request({ dbId: props.dbId, backupId: backupId });
await search();
ElMessage.success('删除成功');
};
</script>
<style lang="scss"></style>

View File

@@ -10,48 +10,35 @@
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
:resource-code="form.code"
:resource-type="TagResourceTypeEnum.Db.value"
style="width: 100%"
/>
<el-form-item prop="code" label="编号" required>
<el-input
:disabled="form.id"
v-model.trim="form.code"
placeholder="请输入编号 (大小写字母、数字、_-.:), 不可修改"
auto-complete="off"
></el-input>
</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>
<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 @change="changeAuthCert" 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>
<el-form-item prop="database" label="数据库名">
<el-select
v-model="dbNamesSelected"
@@ -80,7 +67,7 @@
<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>
@@ -88,17 +75,25 @@
</template>
<script lang="ts" setup>
import { toRefs, reactive, watch, ref } from 'vue';
import { toRefs, reactive, watch, ref, watchEffect } from 'vue';
import { dbApi } from './api';
import { ElMessage } from 'element-plus';
import TagTreeSelect from '../component/TagTreeSelect.vue';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import type { CheckboxValueType } from 'element-plus';
import { DbType } from '@/views/ops/db/dialect';
import { ResourceCodePattern } from '@/common/pattern';
import EnumTag from '@/components/enumtag/EnumTag.vue';
import { AuthCertCiphertextTypeEnum } from '../tag/enums';
import { resourceAuthCertApi } from '../tag/api';
import { TagResourceTypeEnum } from '@/common/commonEnum';
const props = defineProps({
visible: {
type: Boolean,
},
instance: {
type: [Boolean, Object],
},
db: {
type: [Boolean, Object],
},
@@ -108,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: [
@@ -126,7 +121,18 @@ const rules = {
trigger: ['change', 'blur'],
},
],
code: [
{
required: true,
message: '请输入编码',
trigger: ['change', 'blur'],
},
{
pattern: ResourceCodePattern.pattern,
message: ResourceCodePattern.message,
trigger: ['blur'],
},
],
name: [
{
required: true,
@@ -134,6 +140,13 @@ const rules = {
trigger: ['change', 'blur'],
},
],
authCertName: [
{
required: true,
message: '请选择授权凭证',
trigger: ['change', 'blur'],
},
],
database: [
{
required: true,
@@ -147,7 +160,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,
@@ -155,80 +168,83 @@ const state = reactive({
dbNamesSelected: [] as any,
dbNamesFiltered: [] as any,
filterString: '',
selectInstalce: {} as any,
authCerts: [] as any,
form: {
id: null,
tagId: [],
// tagId: [],
name: null,
code: '',
database: '',
remark: '',
instanceId: null as any,
authCertName: '',
},
instances: [] as any,
});
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;
watchEffect(() => {
state.dialogVisible = props.visible;
if (!state.dialogVisible) {
return;
}
if (newValue.db) {
state.form = { ...newValue.db };
const db: any = props.db;
if (db.code) {
state.form = { ...db };
// state.form.tagId = newValue.db.tags.map((t: any) => t.tagId);
// 将数据库名使用空格切割,获取所有数据库列表
state.dbNamesSelected = newValue.db.database.split(' ');
state.dbNamesSelected = db.database.split(' ');
} else {
state.form = {} as any;
state.dbNamesSelected = [];
}
});
const changeInstance = () => {
state.dbNamesSelected = [];
getAllDatabase();
const changeAuthCert = (val: string) => {
getAllDatabase(val);
};
const getAllDatabase = async () => {
if (state.form.instanceId > 0) {
state.allDatabases = 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 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;
const getAllDatabase = async (authCertName: string) => {
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
let instance = state.instances.find((item: any) => item.id === state.form.instanceId);
if (instance && instance.type === DbType.oracle && dbs.length === 0) {
state.allDatabases = [instance.sid];
}
};
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

@@ -6,9 +6,8 @@
:before-query-fn="checkRouteTagPath"
:search-items="searchItems"
v-model:query-form="query"
:show-selection="true"
v-model:selection-data="state.selectionData"
:columns="columns"
lazy
>
<template #instanceSelect>
<el-select remote :remote-method="getInstances" v-model="query.instanceId" placeholder="输入并选择实例" filterable clearable>
@@ -23,11 +22,6 @@
</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" />
@@ -38,16 +32,26 @@
{{ `${data.host}:${data.port}` }}
</template>
<template #database="{ data }">
<el-popover placement="bottom" :width="200" trigger="click">
<template #reference>
<el-button @click="state.currentDbs = data.database" type="primary" link>查看库</el-button>
</template>
<el-table :data="filterDbs" 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 }">
<resource-tag :resource-code="data.code" :resource-type="TagResourceTypeEnum.Db.value" />
<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" />
@@ -61,30 +65,43 @@
<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: 'dbBackup', data }" v-if="supportAction('dbBackup', data.type)"> 备份 </el-dropdown-item>
<el-dropdown-item :command="{ type: 'dbRestore', data }" v-if="supportAction('dbRestore', data.type)"> 恢复 </el-dropdown-item>
<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-dialog width="720px" :title="`${db} 数据库导出`" v-model="exportDialog.visible">
<el-dialog width="750px" :title="`${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>
@@ -131,6 +148,16 @@
<db-backup-list :dbId="dbBackupDialog.dbId" :dbNames="dbBackupDialog.dbs" />
</el-dialog>
<el-dialog
width="80%"
:title="`${dbBackupHistoryDialog.title} - 数据库备份历史`"
:close-on-click-modal="false"
:destroy-on-close="true"
v-model="dbBackupHistoryDialog.visible"
>
<db-backup-history-list :dbId="dbBackupHistoryDialog.dbId" :dbNames="dbBackupHistoryDialog.dbs" />
</el-dialog>
<el-dialog
width="80%"
:title="`${dbRestoreDialog.title} - 数据库恢复`"
@@ -141,39 +168,44 @@
<db-restore-list :dbId="dbRestoreDialog.dbId" :dbNames="dbRestoreDialog.dbs" />
</el-dialog>
<el-dialog v-model="infoDialog.visible" :before-close="onBeforeCloseInfoDialog" :close-on-click-modal="false">
<el-dialog v-if="infoDialog.visible" v-model="infoDialog.visible" :before-close="onBeforeCloseInfoDialog">
<el-descriptions title="详情" :column="3" border>
<!-- <el-descriptions-item :span="3" label="标签路径">{{ infoDialog.data?.tagPath }}</el-descriptions-item> -->
<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="数据库">{{ infoDialog.data?.database }}</el-descriptions-item>
<el-descriptions-item :span="3" label="备注">{{ infoDialog.data?.remark }}</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-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="类型">{{ infoDialog.instance?.type }}</el-descriptions-item>
<el-descriptions-item :span="2" label="授权凭证">{{ infoDialog.instance.authCertName }}</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="2" label="创建时间">{{ formatDate(infoDialog.data?.createTime) }} </el-descriptions-item>
<el-descriptions-item :span="1" label="创建者">{{ infoDialog.data?.creator }}</el-descriptions-item>
<el-descriptions-item :span="2" label="更新时间">{{ formatDate(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 @val-change="search()" :title="dbEditDialog.title" v-model:visible="dbEditDialog.visible" 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, onMounted, 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 ResourceTag from '../component/ResourceTag.vue';
import { formatDate } from '@/common/utils/format';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { hasPerms } from '@/components/auth/auth';
@@ -185,31 +217,40 @@ 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';
const DbEdit = defineAsyncComponent(() => import('./DbEdit.vue'));
const perms = {
base: 'db',
saveDb: 'db:save',
delDb: 'db:del',
};
const searchItems = [getTagPathSearchItem(TagResourceTypeEnum.Db.value), SearchItem.slot('instanceId', '实例', 'instanceSelect')];
const searchItems = [
getTagPathSearchItem(TagResourceTypeEnum.DbName.value),
SearchItem.slot('instanceId', '实例', 'instanceSelect'),
SearchItem.input('code', '编号'),
];
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('tagPath', '关联标签').isSlot().setAddWidth(10).alignCenter(),
TableColumn.new('authCertName', '授权凭证'),
TableColumn.new('database', '').isSlot().setMinWidth(80),
TableColumn.new('remark', '备注'),
TableColumn.new('code', '编号'),
]);
const perms = {
backupDb: 'db:backup',
restoreDb: 'db:restore',
};
// 该用户拥有的的操作列按钮权限
const actionBtns = hasPerms([perms.base, perms.saveDb]);
const actionColumn = TableColumn.new('action', '操作').isSlot().setMinWidth(220).fixedRight().alignCenter();
// const actionBtns = hasPerms([perms.base, perms.saveDb]);
const actionBtns = hasPerms(Object.values(perms));
const actionColumn = TableColumn.new('action', '操作').isSlot().setMinWidth(180).fixedRight().alignCenter();
const route = useRoute();
const pageTableRef: Ref<any> = ref(null);
@@ -217,6 +258,8 @@ const state = reactive({
row: {} as any,
dbId: 0,
db: '',
currentDbs: '',
dbNameSearch: '',
instances: [] as any,
/**
* 选中的数据
@@ -253,6 +296,13 @@ const state = reactive({
dbs: [],
dbId: 0,
},
// 数据库备份历史弹框
dbBackupHistoryDialog: {
title: '',
visible: false,
dbs: [],
dbId: 0,
},
// 数据库恢复弹框
dbRestoreDialog: {
title: '',
@@ -285,12 +335,26 @@ const state = reactive({
},
});
const { db, selectionData, query, infoDialog, sqlExecLogDialog, exportDialog, dbEditDialog, dbBackupDialog, dbRestoreDialog } = toRefs(state);
const { db, query, infoDialog, sqlExecLogDialog, exportDialog, dbEditDialog, dbBackupDialog, dbBackupHistoryDialog, dbRestoreDialog } = toRefs(state);
onMounted(async () => {
if (Object.keys(actionBtns).length > 0) {
columns.value.push(actionColumn);
}
search();
});
const filterDbs = computed(() => {
const dbsStr = state.currentDbs;
if (!dbsStr) {
return [];
}
const dbs = dbsStr.split(' ').map((db: any) => {
return { dbName: db };
});
return dbs.filter((db: any) => {
return db.dbName.includes(state.dbNameSearch);
});
});
const checkRouteTagPath = (query: any) => {
@@ -300,7 +364,10 @@ const checkRouteTagPath = (query: any) => {
return query;
};
const search = async () => {
const search = async (tagPath: string = '') => {
if (tagPath) {
state.query.tagPath = tagPath;
}
pageTableRef.value.search();
};
@@ -337,51 +404,25 @@ const handleMoreActionCommand = (commond: any) => {
showInfo(data);
return;
}
case 'edit': {
editDb(data);
return;
}
case 'dumpDb': {
onDumpDbs(data);
return;
}
case 'dbBackup': {
case 'backupDb': {
onShowDbBackupDialog(data);
return;
}
case 'dbRestore': {
case 'backupHistory': {
onShowDbBackupHistoryDialog(data);
return;
}
case 'restoreDb': {
onShowDbRestoreDialog(data);
return;
}
}
};
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;
@@ -402,6 +443,13 @@ const onShowDbBackupDialog = async (row: any) => {
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(' ');
state.dbBackupHistoryDialog.visible = true;
};
const onShowDbRestoreDialog = async (row: any) => {
state.dbRestoreDialog.title = `${row.name}`;
state.dbRestoreDialog.dbId = row.id;
@@ -429,9 +477,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 == '结构') {
@@ -440,13 +487,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;
};
@@ -455,10 +504,12 @@ const supportAction = (action: string, dbType: string): boolean => {
switch (dbType) {
case DbType.mysql:
case DbType.mariadb:
actions = ['dumpDb', 'dbBackup', 'dbRestore'];
actions = ['dumpDb', 'backupDb', 'restoreDb'];
}
return actions.includes(action);
};
defineExpose({ search });
</script>
<style lang="scss">
.db-list {

View File

@@ -35,7 +35,13 @@
clearable
class="w100"
>
<el-option v-for="item in state.histories" :key="item.id" :label="item.name" :value="item"> </el-option>
<el-option
v-for="item in state.histories"
:key="item.id"
:label="item.name + (item.binlogFileName ? ' ' : ' 不') + '支持指定时间点恢复'"
:value="item"
>
</el-option>
</el-select>
</el-form-item>
<el-form-item prop="startTime" label="开始时间">
@@ -56,7 +62,7 @@
<script lang="ts" setup>
import { onMounted, reactive, ref, watch } from 'vue';
import { dbApi } from './api';
import { ElMessage } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
const props = defineProps({
data: {
@@ -83,20 +89,30 @@ const visible = defineModel<boolean>('visible', {
});
const validatePointInTime = (rule: any, value: any, callback: any) => {
if (!state.histories || state.histories.length == 0) {
callback(new Error('数据库没有备份记录'));
return;
}
const history = state.histories[state.histories.length - 1];
if (value < new Date(history.createTime)) {
callback(new Error('在此之前数据库没有备份记录'));
return;
}
if (value > new Date()) {
callback(new Error('恢复时间点晚于当前时间'));
return;
}
callback();
if (!state.histories || state.histories.length == 0) {
callback(new Error('数据库没有备份记录'));
return;
}
let last = null;
for (const history of state.histories) {
if (!history.binlogFileName || history.binlogFileName.length === 0) {
break;
}
if (new Date(history.createTime) < value) {
callback();
return;
}
last = history;
}
if (!last) {
callback(new Error('现有数据库备份不支持指定时间恢复'));
return;
}
callback(last.name + ' 之前的数据库备份不支持指定时间恢复');
};
const rules = {
@@ -110,7 +126,6 @@ const rules = {
pointInTime: [
{
required: true,
// message: '请选择恢复时间点',
validator: validatePointInTime,
trigger: ['change', 'blur'],
},
@@ -146,7 +161,7 @@ const state = reactive({
id: 0,
dbId: 0,
dbName: null as any,
intervalDay: 1,
intervalDay: 0,
startTime: null as any,
repeated: null as any,
dbBackupId: null as any,
@@ -218,7 +233,8 @@ const init = async (data: any) => {
} else {
state.form.dbName = '';
state.editOrCreate = false;
state.form.intervalDay = 1;
state.form.intervalDay = 0;
state.form.repeated = false;
state.form.pointInTime = new Date();
state.form.startTime = new Date();
state.histories = [];
@@ -237,6 +253,12 @@ const getDbNamesWithoutRestore = async () => {
const btnOk = async () => {
restoreForm.value.validate(async (valid: any) => {
if (valid) {
await ElMessageBox.confirm(`确定恢复数据库吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
if (state.restoreMode == 'point-in-time') {
state.form.dbBackupId = 0;
state.form.dbBackupHistoryId = 0;
@@ -245,13 +267,14 @@ const btnOk = async () => {
state.form.pointInTime = null;
}
state.form.repeated = false;
state.form.intervalDay = 0;
const reqForm = { ...state.form };
let api = dbApi.createDbRestore;
if (props.data) {
api = dbApi.saveDbRestore;
}
api.request(reqForm).then(() => {
ElMessage.success('保存成功');
ElMessage.success('成功创建数据库恢复任务');
emit('val-change', state.form);
state.btnLoading = true;
setTimeout(() => {

View File

@@ -21,12 +21,14 @@
<el-button type="primary" icon="plus" @click="createDbRestore()">添加</el-button>
<el-button type="primary" icon="video-play" @click="enableDbRestore(null)">启用</el-button>
<el-button type="primary" icon="video-pause" @click="disableDbRestore(null)">禁用</el-button>
<el-button type="danger" icon="delete" @click="deleteDbRestore(null)">删除</el-button>
</template>
<template #action="{ data }">
<el-button @click="showDbRestore(data)" type="primary" link>详情</el-button>
<el-button @click="enableDbRestore(data)" v-if="!data.enabled" type="primary" link>启用</el-button>
<el-button @click="disableDbRestore(data)" v-if="data.enabled" type="primary" link>禁用</el-button>
<el-button @click="deleteDbRestore(data)" type="danger" link>删除</el-button>
</template>
</page-table>
@@ -43,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="是否启用">{{ infoDialog.data.enabled }}</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.startTime) }}</el-descriptions-item>
<el-descriptions-item :span="1" label="是否启用">{{ infoDialog.data.enabledDesc }}</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>
@@ -63,8 +65,8 @@ import { dbApi } from './api';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { SearchItem } from '@/components/SearchForm';
import { ElMessage } from 'element-plus';
import { dateFormat } from '@/common/utils/date';
import { ElMessage, ElMessageBox } from 'element-plus';
import { formatDate } from '@/common/utils/format';
const DbRestoreEdit = defineAsyncComponent(() => import('./DbRestoreEdit.vue'));
const pageTableRef: Ref<any> = ref(null);
@@ -85,7 +87,7 @@ const searchItems = [SearchItem.slot('dbName', '数据库名称', 'dbSelect')];
const columns = [
TableColumn.new('dbName', '数据库名称'),
TableColumn.new('startTime', '启动时间').isTime(),
TableColumn.new('enabled', '是否启用'),
TableColumn.new('enabledDesc', '是否启用'),
TableColumn.new('lastTime', '执行时间').isTime(),
TableColumn.new('lastResult', '执行结果'),
TableColumn.new('action', '操作').isSlot().setMinWidth(220).fixedRight().alignCenter(),
@@ -135,19 +137,39 @@ const createDbRestore = async () => {
state.dbRestoreEditDialog.visible = true;
};
const deleteDbRestore = async (data: any) => {
let restoreId: string;
if (data) {
restoreId = data.id;
} else if (state.selectedData.length > 0) {
restoreId = state.selectedData.map((x: any) => x.id).join(' ');
} else {
ElMessage.error('请选择需要删除的数据库恢复任务');
return;
}
await ElMessageBox.confirm(`确定删除 “数据库恢复任务” 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await dbApi.deleteDbRestore.request({ dbId: props.dbId, restoreId: restoreId });
await search();
ElMessage.success('删除成功');
};
const showDbRestore = async (data: any) => {
state.infoDialog.data = data;
state.infoDialog.visible = true;
};
const enableDbRestore = async (data: any) => {
let restoreId: String;
let restoreId: string;
if (data) {
restoreId = data.id;
} else if (state.selectedData.length > 0) {
restoreId = state.selectedData.map((x: any) => x.id).join(' ');
} else {
ElMessage.error('请选择需要启用的恢复任务');
ElMessage.error('请选择需要启用的数据库恢复任务');
return;
}
await dbApi.enableDbRestore.request({ dbId: props.dbId, restoreId: restoreId });
@@ -156,13 +178,13 @@ const enableDbRestore = async (data: any) => {
};
const disableDbRestore = async (data: any) => {
let restoreId: String;
let restoreId: string;
if (data) {
restoreId = data.id;
} else if (state.selectedData.length > 0) {
restoreId = state.selectedData.map((x: any) => x.id).join(' ');
} else {
ElMessage.error('请选择需要禁用的恢复任务');
ElMessage.error('请选择需要禁用的数据库恢复任务');
return;
}
await dbApi.disableDbRestore.request({ dbId: props.dbId, restoreId: restoreId });

View File

@@ -17,7 +17,10 @@
<template #action="{ data }">
<el-link
v-if="data.type == DbSqlExecTypeEnum.Update.value || data.type == DbSqlExecTypeEnum.Delete.value"
v-if="
data.status == DbSqlExecStatusEnum.Success.value &&
(data.type == DbSqlExecTypeEnum.Update.value || data.type == DbSqlExecTypeEnum.Delete.value)
"
type="primary"
plain
size="small"
@@ -36,9 +39,9 @@
</template>
<script lang="ts" setup>
import { toRefs, watch, reactive, onMounted, Ref, ref } from 'vue';
import { onMounted, reactive, Ref, ref, toRefs, watch } from 'vue';
import { dbApi } from './api';
import { DbSqlExecTypeEnum } from './enums';
import { DbSqlExecTypeEnum, DbSqlExecStatusEnum } from './enums';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { SearchItem } from '@/components/SearchForm';
@@ -66,9 +69,11 @@ const columns = ref([
TableColumn.new('type', '类型').typeTag(DbSqlExecTypeEnum).setAddWidth(10),
TableColumn.new('creator', '执行人'),
TableColumn.new('sql', 'SQL').canBeautify(),
TableColumn.new('oldValue', '原值').canBeautify(),
TableColumn.new('createTime', '执行时间').isTime(),
TableColumn.new('remark', '备注'),
TableColumn.new('status', '执行状态').typeTag(DbSqlExecStatusEnum),
TableColumn.new('res', '执行结果'),
TableColumn.new('createTime', '执行时间').isTime(),
TableColumn.new('oldValue', '原值').canBeautify(),
TableColumn.new('action', '操作').isSlot().setMinWidth(90).fixedRight().alignCenter(),
]);
@@ -80,6 +85,7 @@ const state = reactive({
dbId: 0,
db: '',
table: '',
status: [DbSqlExecStatusEnum.Success.value, DbSqlExecStatusEnum.Fail.value].join(','),
type: null,
pageNum: 1,
pageSize: 10,
@@ -120,6 +126,12 @@ const onShowRollbackSql = async (sqlExecLog: any) => {
const primaryKey = getPrimaryKey(columns);
const oldValue = JSON.parse(sqlExecLog.oldValue);
let schema = '';
let dbArr = sqlExecLog.db.split('/');
if (dbArr.length == 2) {
schema = dbArr[1] + '.';
}
const rollbackSqls = [];
if (sqlExecLog.type == DbSqlExecTypeEnum.Update.value) {
for (let ov of oldValue) {
@@ -130,7 +142,7 @@ const onShowRollbackSql = async (sqlExecLog: any) => {
}
setItems.push(`${key} = ${wrapValue(ov[key])}`);
}
rollbackSqls.push(`UPDATE ${sqlExecLog.table} SET ${setItems.join(', ')} WHERE ${primaryKey} = ${wrapValue(ov[primaryKey])};`);
rollbackSqls.push(`UPDATE ${schema}${sqlExecLog.table} SET ${setItems.join(', ')} WHERE ${primaryKey} = ${wrapValue(ov[primaryKey])};`);
}
} else if (sqlExecLog.type == DbSqlExecTypeEnum.Delete.value) {
const columnNames = columns.map((c: any) => c.columnName);
@@ -139,7 +151,7 @@ const onShowRollbackSql = async (sqlExecLog: any) => {
for (let column of columnNames) {
values.push(wrapValue(ov[column]));
}
rollbackSqls.push(`INSERT INTO ${sqlExecLog.table} (${columnNames.join(', ')}) VALUES (${values.join(', ')});`);
rollbackSqls.push(`INSERT INTO ${schema}${sqlExecLog.table} (${columnNames.join(', ')}) VALUES (${values.join(', ')});`);
}
}
@@ -148,7 +160,7 @@ const onShowRollbackSql = async (sqlExecLog: any) => {
};
const getPrimaryKey = (columns: any) => {
const col = columns.find((c: any) => c.columnKey == 'PRI');
const col = columns.find((c: any) => c.isPrimaryKey);
if (col) {
return col.columnName;
}

View File

@@ -0,0 +1,332 @@
<template>
<div class="db-transfer-edit">
<el-dialog
:title="title"
v-model="dialogVisible"
:before-close="cancel"
:close-on-click-modal="false"
:close-on-press-escape="false"
:destroy-on-close="true"
width="850px"
>
<el-form :model="form" ref="dbForm" :rules="rules" label-width="auto">
<el-tabs v-model="tabActiveName">
<el-tab-pane label="基本信息" :name="basicTab">
<el-form-item prop="srcDbId" label="源数据库" required>
<db-select-tree
placeholder="请选择源数据库"
v-model:db-id="form.srcDbId"
v-model:inst-name="form.srcInstName"
v-model:db-name="form.srcDbName"
v-model:tag-path="form.srcTagPath"
v-model:db-type="form.srcDbType"
@select-db="onSelectSrcDb"
/>
</el-form-item>
<el-form-item prop="targetDbId" label="目标数据库" required>
<db-select-tree
placeholder="请选择目标数据库"
v-model:db-id="form.targetDbId"
v-model:inst-name="form.targetInstName"
v-model:db-name="form.targetDbName"
v-model:tag-path="form.targetTagPath"
v-model:db-type="form.targetDbType"
@select-db="onSelectTargetDb"
/>
</el-form-item>
<el-form-item prop="strategy" label="迁移策略" required>
<el-select v-model="form.strategy" filterable placeholder="迁移策略">
<el-option label="全量" :value="1" />
<el-option label="增量(暂不可用)" disabled :value="2" />
</el-select>
</el-form-item>
<el-form-item prop="nameCase" label="转换表、字段名" required>
<el-select v-model="form.nameCase">
<el-option label="无" :value="1" />
<el-option label="大写" :value="2" />
<el-option label="小写" :value="3" />
</el-select>
</el-form-item>
<el-form-item prop="deleteTable" label="创建前删除表" required>
<el-select v-model="form.deleteTable">
<el-option label="是" :value="1" />
<el-option label="否" :value="2" />
</el-select>
</el-form-item>
</el-tab-pane>
<el-tab-pane label="数据库对象" :name="tableTab" :disabled="!baseFieldCompleted">
<el-form-item>
<el-input v-model="state.filterSrcTableText" style="width: 240px" placeholder="过滤表" />
</el-form-item>
<el-form-item>
<el-tree
ref="srcTreeRef"
style="width: 760px; max-height: 400px; overflow-y: auto"
default-expand-all
:expand-on-click-node="false"
:data="state.srcTableTree"
node-key="id"
show-checkbox
@check-change="handleSrcTableCheckChange"
:filter-node-method="filterSrcTableTreeNode"
/>
</el-form-item>
</el-tab-pane>
</el-tabs>
</el-form>
<template #footer>
<div>
<el-button @click="cancel()"> </el-button>
<el-button type="primary" :loading="saveBtnLoading" @click="btnOk"> </el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { computed, nextTick, reactive, ref, toRefs, watch } from 'vue';
import { dbApi } from './api';
import { ElMessage } from 'element-plus';
import DbSelectTree from '@/views/ops/db/component/DbSelectTree.vue';
const props = defineProps({
data: {
type: [Boolean, Object],
},
title: {
type: String,
},
});
//定义事件
const emit = defineEmits(['update:visible', 'cancel', 'val-change']);
const dialogVisible = defineModel<boolean>('visible', { default: false });
const rules = {};
const dbForm: any = ref(null);
const basicTab = 'basic';
const tableTab = 'table';
type FormData = {
id?: number;
srcDbId?: number;
srcDbName?: string;
srcDbType?: string;
srcInstName?: string;
srcTagPath?: string;
srcTableNames?: string;
targetDbId?: number;
targetInstName?: string;
targetDbName?: string;
targetTagPath?: string;
targetDbType?: string;
strategy: 1 | 2;
nameCase: 1 | 2 | 3;
deleteTable?: 1 | 2;
checkedKeys: string;
runningState: 1 | 2;
};
const basicFormData = {
strategy: 1,
nameCase: 1,
deleteTable: 1,
checkedKeys: '',
runningState: 1,
} as FormData;
const srcTableList = ref<{ tableName: string; tableComment: string }[]>([]);
const srcTableListDisabled = ref(false);
const defaultKeys = ['tab-check', 'all', 'table-list'];
const state = reactive({
tabActiveName: 'basic',
form: basicFormData,
submitForm: {} as any,
srcTableFields: [] as string[],
targetColumnList: [] as any[],
filterSrcTableText: '',
srcTableTree: [
{
id: 'tab-check',
label: '表',
children: [
{ id: 'all', label: '全部表(*' },
{
id: 'table-list',
label: '自定义',
disabled: srcTableListDisabled,
children: [] as any[],
},
],
},
],
});
const { tabActiveName, form, submitForm } = toRefs(state);
const { isFetching: saveBtnLoading, execute: saveExec } = dbApi.saveDbTransferTask.useApi(submitForm);
// 基础字段信息是否填写完整
const baseFieldCompleted = computed(() => {
return state.form.srcDbId && state.form.targetDbId && state.form.targetDbName;
});
watch(dialogVisible, async (newValue: boolean) => {
if (!newValue) {
return;
}
state.tabActiveName = 'basic';
const propsData = props.data as any;
if (!propsData?.id) {
let d = {} as FormData;
Object.assign(d, basicFormData);
state.form = d;
await nextTick(() => {
srcTreeRef.value.setCheckedKeys([]);
});
return;
}
state.form = props.data as FormData;
let { srcDbId, targetDbId } = state.form;
// 初始化src数据源
if (srcDbId) {
// 通过tagPath查询实例列表
const dbInfoRes = await dbApi.dbs.request({ id: srcDbId });
const db = dbInfoRes.list[0];
// 初始化实例
db.databases = db.database?.split(' ').sort() || [];
if (srcDbId && state.form.srcDbName) {
await loadDbTables(srcDbId, state.form.srcDbName);
}
}
// 初始化target数据源
if (targetDbId) {
// 通过tagPath查询实例列表
const dbInfoRes = await dbApi.dbs.request({ id: targetDbId });
const db = dbInfoRes.list[0];
// 初始化实例
db.databases = db.database?.split(' ').sort() || [];
}
// 初始化勾选迁移表
srcTreeRef.value.setCheckedKeys(state.form.checkedKeys.split(','));
});
watch(
() => state.filterSrcTableText,
(val) => {
srcTreeRef.value!.filter(val);
}
);
const onSelectSrcDb = async (params: any) => {
// 初始化数据源
params.databases = params.dbs; // 数据源里需要这个值
await loadDbTables(params.id, params.db);
};
const onSelectTargetDb = async (params: any) => {
console.log(params);
};
const loadDbTables = async (dbId: number, db: string) => {
// 加载db下的表
srcTableList.value = await dbApi.tableInfos.request({ id: dbId, db });
handleLoadSrcTableTree();
};
const handleSrcTableCheckChange = (data: { id: string; name: string }, checked: boolean) => {
if (data.id === 'all') {
srcTableListDisabled.value = checked;
if (checked) {
state.form.checkedKeys = 'all';
} else {
state.form.checkedKeys = '';
}
}
if (data.id && (data.id + '').startsWith('list-item')) {
//
}
};
const filterSrcTableTreeNode = (value: string, data: any) => {
if (!value) return true;
return data.label.includes(value);
};
const handleLoadSrcTableTree = () => {
state.srcTableTree[0].children[1].children = srcTableList.value.map((item) => {
return {
id: item.tableName,
label: item.tableName + (item.tableComment && '-' + item.tableComment),
disabled: srcTableListDisabled,
};
});
};
const getReqForm = async () => {
return { ...state.form };
};
const srcTreeRef = ref();
const getCheckedKeys = () => {
let checks = srcTreeRef.value!.getCheckedKeys(false);
if (checks.indexOf('all') >= 0) {
return ['all'];
}
return checks.filter((item: any) => !defaultKeys.includes(item));
};
const btnOk = async () => {
dbForm.value.validate(async (valid: boolean) => {
if (!valid) {
ElMessage.error('请正确填写信息');
return false;
}
state.submitForm = await getReqForm();
let checkedKeys = getCheckedKeys();
if (checkedKeys.length > 0) {
state.submitForm.checkedKeys = checkedKeys.join(',');
}
if (!state.submitForm.checkedKeys) {
ElMessage.error('请选择需要迁移的表');
return false;
}
await saveExec();
ElMessage.success('保存成功');
emit('val-change', state.form);
cancel();
});
};
const cancel = () => {
dialogVisible.value = false;
emit('cancel');
};
</script>
<style lang="scss">
.db-transfer-edit {
.el-select {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,196 @@
<template>
<div class="db-list">
<page-table
ref="pageTableRef"
:page-api="dbApi.dbTransferTasks"
:searchItems="searchItems"
v-model:query-form="query"
:show-selection="true"
v-model:selection-data="state.selectionData"
:columns="columns"
>
<template #tableHeader>
<el-button v-auth="perms.save" type="primary" icon="plus" @click="edit(false)">添加</el-button>
<el-button v-auth="perms.del" :disabled="selectionData.length < 1" @click="del()" type="danger" icon="delete">删除</el-button>
</template>
<template #srcDb="{ data }">
<el-tooltip :content="`${data.srcTagPath} > ${data.srcInstName} > ${data.srcDbName}`">
<span>
<SvgIcon :name="getDbDialect(data.srcDbType).getInfo().icon" :size="18" />
{{ data.srcInstName }}
</span>
</el-tooltip>
</template>
<template #targetDb="{ data }">
<el-tooltip :content="`${data.targetTagPath} > ${data.targetInstName} > ${data.targetDbName}`">
<span>
<SvgIcon :name="getDbDialect(data.targetDbType).getInfo().icon" :size="18" />
{{ data.targetInstName }}
</span>
</el-tooltip>
</template>
<template #action="{ data }">
<!-- 删除启停用编辑 -->
<el-button v-if="actionBtns[perms.save]" @click="edit(data)" type="primary" link>编辑</el-button>
<el-button v-if="actionBtns[perms.log]" type="primary" link @click="log(data)">日志</el-button>
<el-button v-if="data.runningState === 1" @click="stop(data.id)" type="danger" link>停止</el-button>
<el-button v-if="actionBtns[perms.run] && data.runningState !== 1" type="primary" link @click="reRun(data)">运行</el-button>
</template>
</page-table>
<db-transfer-edit @val-change="search" :title="editDialog.title" v-model:visible="editDialog.visible" v-model:data="editDialog.data" />
<TerminalLog v-model:log-id="logsDialog.logId" v-model:visible="logsDialog.visible" :title="logsDialog.title" />
</div>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, onMounted, reactive, ref, Ref, toRefs } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { dbApi } from './api';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { hasPerms } from '@/components/auth/auth';
import { SearchItem } from '@/components/SearchForm';
import { getDbDialect } from '@/views/ops/db/dialect';
import { DbTransferRunningStateEnum } from './enums';
import TerminalLog from '@/components/terminal/TerminalLog.vue';
const DbTransferEdit = defineAsyncComponent(() => import('./DbTransferEdit.vue'));
const perms = {
save: 'db:transfer:save',
del: 'db:transfer:del',
status: 'db:transfer:status',
log: 'db:transfer:log',
run: 'db:transfer:run',
};
const searchItems = [SearchItem.input('name', '名称')];
const columns = ref([
TableColumn.new('srcDb', '源库').setMinWidth(200).isSlot(),
TableColumn.new('targetDb', '目标库').setMinWidth(200).isSlot(),
TableColumn.new('runningState', '执行状态').typeTag(DbTransferRunningStateEnum),
TableColumn.new('creator', '创建人'),
TableColumn.new('createTime', '创建时间').isTime(),
TableColumn.new('modifier', '修改人'),
TableColumn.new('updateTime', '修改时间').isTime(),
]);
// 该用户拥有的的操作列按钮权限
const actionBtns = hasPerms([perms.save, perms.del, perms.status, perms.log, perms.run]);
const actionWidth = ((actionBtns[perms.save] ? 1 : 0) + (actionBtns[perms.log] ? 1 : 0) + (actionBtns[perms.run] ? 1 : 0)) * 55;
const actionColumn = TableColumn.new('action', '操作').isSlot().setMinWidth(actionWidth).fixedRight().alignCenter();
const pageTableRef: Ref<any> = ref(null);
const state = reactive({
row: {},
dbId: 0,
db: '',
/**
* 选中的数据
*/
selectionData: [],
/**
* 查询条件
*/
query: {
name: null,
pageNum: 1,
pageSize: 0,
},
editDialog: {
visible: false,
data: null as any,
title: '新增数据数据迁移任务',
},
logsDialog: {
logId: 0,
title: '数据库迁移日志',
visible: false,
data: null as any,
running: false,
},
});
const { selectionData, query, editDialog, logsDialog } = toRefs(state);
onMounted(async () => {
if (Object.keys(actionBtns).length > 0) {
columns.value.push(actionColumn);
}
});
const search = () => {
pageTableRef.value.search();
};
const edit = async (data: any) => {
if (!data) {
state.editDialog.data = null;
state.editDialog.title = '新增数据库迁移任务';
} else {
state.editDialog.data = data;
state.editDialog.title = '修改数据库迁移任务';
}
state.editDialog.visible = true;
};
const stop = async (id: any) => {
await ElMessageBox.confirm(`确定停止?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await dbApi.stopDbTransferTask.request({ taskId: id });
ElMessage.success(`停止成功`);
search();
};
const log = (data: any) => {
state.logsDialog.logId = data.logId;
state.logsDialog.visible = true;
state.logsDialog.title = '数据库迁移日志';
state.logsDialog.running = data.state === 1;
};
const reRun = async (data: any) => {
await ElMessageBox.confirm(`确定运行?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
try {
let res = await dbApi.runDbTransferTask.request({ taskId: data.id });
console.log(res);
ElMessage.success('运行成功');
// 拿到日志id之后弹出日志弹窗
log({ logId: res, state: 1 });
} catch (e) {
//
}
// 延迟2秒执行后端异步执行
setTimeout(() => {
search();
}, 2000);
};
const del = async () => {
try {
await ElMessageBox.confirm(`确定删除任务?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await dbApi.deleteDbTransferTask.request({ taskId: state.selectionData.map((x: any) => x.id).join(',') });
ElMessage.success('删除成功');
search();
} catch (err) {
//
}
};
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,170 @@
<template>
<div>
<el-drawer :title="title" v-model="dialogVisible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="50%">
<template #header>
<DrawerHeader :header="title" :back="cancel" />
</template>
<el-table :data="state.dbs" stripe>
<el-table-column prop="name" label="名称" show-overflow-tooltip min-width="100"> </el-table-column>
<el-table-column prop="authCertName" label="授权凭证" min-width="120" show-overflow-tooltip> </el-table-column>
<el-table-column prop="database" label="库" min-width="80">
<template #default="scope">
<el-popover placement="bottom" :width="200" trigger="click">
<template #reference>
<el-button @click="state.currentDbs = scope.row.database" type="primary" link>查看库</el-button>
</template>
<el-table :data="filterDbs" 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>
</el-table-column>
<el-table-column prop="remark" label="备注" show-overflow-tooltip min-width="120"> </el-table-column>
<el-table-column prop="code" label="编号" show-overflow-tooltip min-width="120"> </el-table-column>
<el-table-column min-wdith="120px">
<template #header>
操作
<el-button v-auth="perms.saveDb" type="primary" circle size="small" icon="Plus" @click="editDb(null)"> </el-button>
</template>
<template #default="scope">
<el-button v-auth="perms.saveDb" @click="editDb(scope.row)" type="primary" icon="edit" link></el-button>
<el-button class="ml1" v-auth="perms.delDb" type="danger" @click="deleteDb(scope.row)" icon="delete" link></el-button>
</template>
</el-table-column>
</el-table>
<db-edit
@confirm="confirmEditDb"
@cancel="cancelEditDb"
:title="dbEditDialog.title"
v-model:visible="dbEditDialog.visible"
:instance="props.instance"
v-model:db="dbEditDialog.data"
></db-edit>
</el-drawer>
</div>
</template>
<script lang="ts" setup>
import { computed, reactive, toRefs, watchEffect } from 'vue';
import { dbApi } from './api';
import { ElMessage, ElMessageBox } from 'element-plus';
import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue';
import DbEdit from './DbEdit.vue';
const props = defineProps({
visible: {
type: Boolean,
},
instance: {
type: [Object],
required: true,
},
title: {
type: String,
},
});
const perms = {
base: 'db',
saveDb: 'db:save',
delDb: 'db:del',
};
//定义事件
const emit = defineEmits(['update:visible', 'cancel', 'val-change']);
const state = reactive({
dialogVisible: false,
dbs: [] as any,
currentDbs: '', // 当前数据库名,空格分割库名
dbNameSearch: '',
dbEditDialog: {
visible: false,
data: null as any,
title: '新增数据库',
},
});
const { dialogVisible, dbEditDialog } = toRefs(state);
watchEffect(() => {
state.dialogVisible = props.visible;
if (!state.dialogVisible) {
return;
}
getDbs();
});
const filterDbs = computed(() => {
const dbsStr = state.currentDbs;
if (!dbsStr) {
return [];
}
const dbs = dbsStr.split(' ').map((db: any) => {
return { dbName: db };
});
return dbs.filter((db: any) => {
return db.dbName.includes(state.dbNameSearch);
});
});
const cancel = () => {
emit('update:visible', false);
emit('cancel');
};
const getDbs = () => {
dbApi.dbs.request({ pageSize: 200, instanceId: props.instance.id }).then((res: any) => {
state.dbs = res.list || [];
});
};
const editDb = (data: any) => {
if (data) {
state.dbEditDialog.data = { ...data };
} else {
state.dbEditDialog.data = {
instanceId: props.instance.id,
};
}
state.dbEditDialog.title = data ? '编辑数据库' : '新增数据库';
state.dbEditDialog.visible = true;
};
const deleteDb = async (db: any) => {
try {
await ElMessageBox.confirm(`确定删除【${db.name}】库?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await dbApi.deleteDb.request({ id: db.id });
ElMessage.success('删除成功');
getDbs();
} catch (err) {
//
}
};
const confirmEditDb = async (db: any) => {
db.instanceId = props.instance.id;
await dbApi.saveDb.request(db);
ElMessage.success('保存成功');
getDbs();
cancelEditDb();
};
const cancelEditDb = () => {
state.dbEditDialog.visible = false;
state.dbEditDialog.data = {};
};
</script>
<style lang="scss"></style>

View File

@@ -1,97 +1,142 @@
<template>
<div>
<el-dialog :title="title" v-model="dialogVisible" :before-close="cancel" :close-on-click-modal="false" :destroy-on-close="true" width="38%">
<el-drawer :title="title" v-model="dialogVisible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<template #header>
<DrawerHeader :header="title" :back="cancel" />
</template>
<el-form :model="form" ref="dbForm" :rules="rules" label-width="auto">
<el-tabs v-model="tabActiveName">
<el-tab-pane label="基础信息" name="basic">
<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="type" label="类型" required>
<el-select @change="changeDbType" style="width: 100%" v-model="form.type" placeholder="请选择数据库类型">
<el-option v-for="dt in dbTypes" :key="dt.type" :value="dt.type" :label="dt.label">
<SvgIcon :name="getDbDialect(dt.type).getInfo().icon" :size="18" />
{{ dt.label }}
</el-option>
</el-select>
</el-form-item>
<el-form-item prop="host" label="host" required>
<el-col :span="18">
<el-input :disabled="form.id !== undefined" v-model.trim="form.host" placeholder="请输入主机ip" auto-complete="off"></el-input>
</el-col>
<el-col style="text-align: center" :span="1">:</el-col>
<el-col :span="5">
<el-input type="number" v-model.number="form.port" placeholder="端口"></el-input>
</el-col>
</el-form-item>
<el-form-item v-if="form.type === DbType.oracle" prop="sid" label="SID">
<el-input v-model.trim="form.sid" placeholder="请输入服务id"></el-input>
</el-form-item>
<el-form-item prop="username" label="用户名" required>
<el-input v-model.trim="form.username" placeholder="请输入用户名"></el-input>
</el-form-item>
<el-form-item prop="password" label="密码">
<el-input type="password" show-password v-model.trim="form.password" placeholder="请输入密码" autocomplete="new-password">
<template v-if="form.id && form.id != 0" #suffix>
<el-popover @hide="pwd = ''" placement="right" title="原密码" :width="200" trigger="click" :content="pwd">
<template #reference>
<el-link v-auth="'db:instance:save'" @click="getDbPwd" :underline="false" type="primary" class="mr5"
>原密码
</el-link>
</template>
</el-popover>
</template>
</el-input>
</el-form-item>
<el-divider content-position="left">基本</el-divider>
<el-form-item prop="remark" label="备注">
<el-input v-model="form.remark" auto-complete="off" type="textarea"></el-input>
</el-form-item>
</el-tab-pane>
<el-form-item ref="tagSelectRef" prop="tagCodePaths" label="标签">
<tag-tree-select
multiple
@change-tag="
(paths: any) => {
form.tagCodePaths = paths;
tagSelectRef.validate();
}
"
:select-tags="form.tagCodePaths"
style="width: 100%"
/>
</el-form-item>
<el-tab-pane label="其他配置" name="other">
<el-form-item prop="params" label="连接参数">
<el-input v-model.trim="form.params" placeholder="其他连接参数,形如: key1=value1&key2=value2">
<!-- <template #suffix>
<el-link
target="_blank"
href="https://github.com/go-sql-driver/mysql#parameters"
:underline="false"
type="primary"
class="mr5"
>参数参考</el-link
>
</template> -->
</el-input>
</el-form-item>
<el-form-item prop="code" label="编号" required>
<el-input
:disabled="form.id"
v-model.trim="form.code"
placeholder="请输入编号 (大小写字母数字_-.:), 不可修改"
auto-complete="off"
></el-input>
</el-form-item>
<el-form-item prop="sshTunnelMachineId" label="SSH隧道">
<ssh-tunnel-select v-model="form.sshTunnelMachineId" />
</el-form-item>
</el-tab-pane>
</el-tabs>
<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="type" label="类型" required>
<el-select @change="changeDbType" style="width: 100%" v-model="form.type" placeholder="请选择数据库类型">
<el-option
v-for="(dbTypeAndDialect, key) in getDbDialectMap()"
:key="key"
:value="dbTypeAndDialect[0]"
:label="dbTypeAndDialect[1].getInfo().name"
>
<SvgIcon :name="dbTypeAndDialect[1].getInfo().icon" :size="20" />
{{ dbTypeAndDialect[1].getInfo().name }}
</el-option>
<template #prefix>
<SvgIcon :name="getDbDialect(form.type).getInfo().icon" :size="20" />
</template>
</el-select>
</el-form-item>
<el-form-item v-if="form.type !== DbType.sqlite" prop="host" label="host" required>
<el-col :span="18">
<el-input v-model.trim="form.host" placeholder="请输入ip" auto-complete="off"></el-input>
</el-col>
<el-col style="text-align: center" :span="1">:</el-col>
<el-col :span="5">
<el-input type="number" v-model.number="form.port" placeholder="端口"></el-input>
</el-col>
</el-form-item>
<el-form-item v-if="form.type === DbType.sqlite" prop="host" label="sqlite地址">
<el-input v-model.trim="form.host" placeholder="请输入sqlite文件在服务器的绝对地址"></el-input>
</el-form-item>
<el-form-item v-if="form.type === DbType.oracle" label="SID|服务名">
<el-col :span="5">
<el-select
@change="
() => {
state.extra.serviceName = '';
state.extra.sid = '';
}
"
v-model="state.extra.stype"
placeholder="请选择"
>
<el-option label="服务名" :value="1" />
<el-option label="SID" :value="2" />
</el-select>
</el-col>
<el-col style="text-align: center" :span="1">:</el-col>
<el-col :span="18">
<el-input v-if="state.extra.stype == 1" v-model="state.extra.serviceName" placeholder="请输入服务名"> </el-input>
<el-input v-else v-model="state.extra.sid" placeholder="请输入SID"> </el-input>
</el-col>
</el-form-item>
<el-form-item prop="remark" label="备注">
<el-input v-model="form.remark" auto-complete="off" type="textarea"></el-input>
</el-form-item>
<el-divider content-position="left">账号</el-divider>
<div>
<ResourceAuthCertTableEdit
v-model="form.authCerts"
:resource-code="form.code"
:resource-type="TagResourceTypeEnum.Db.value"
:test-conn-btn-loading="testConnBtnLoading"
@test-conn="testConn"
:disable-ciphertext-type="[AuthCertCiphertextTypeEnum.PrivateKey.value]"
/>
</div>
<el-divider content-position="left">其他</el-divider>
<el-form-item prop="params" label="连接参数">
<el-input v-model.trim="form.params" placeholder="其他连接参数形如: key1=value1&key2=value2"> </el-input>
</el-form-item>
<el-form-item prop="sshTunnelMachineId" label="SSH隧道">
<ssh-tunnel-select v-model="form.sshTunnelMachineId" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="testConn" :loading="testConnBtnLoading" type="success">测试连接</el-button>
<el-button @click="cancel()"> </el-button>
<el-button type="primary" :loading="saveBtnLoading" @click="btnOk"> </el-button>
</div>
<el-button @click="cancel()">取 消</el-button>
<el-button type="primary" :loading="saveBtnLoading" @click="btnOk">确 定</el-button>
</template>
</el-dialog>
</el-drawer>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref, toRefs, watch } from 'vue';
import { reactive, ref, toRefs, watchEffect } from 'vue';
import { dbApi } from './api';
import { ElMessage } from 'element-plus';
import { notBlank } from '@/common/assert';
import { RsaEncrypt } from '@/common/rsa';
import SshTunnelSelect from '../component/SshTunnelSelect.vue';
import { DbType, getDbDialect } from './dialect';
import { DbType, getDbDialect, getDbDialectMap } from './dialect';
import SvgIcon from '@/components/svgIcon/index.vue';
import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue';
import { ResourceCodePattern } from '@/common/pattern';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import ResourceAuthCertTableEdit from '../component/ResourceAuthCertTableEdit.vue';
import { AuthCertCiphertextTypeEnum } from '../tag/enums';
import TagTreeSelect from '../component/TagTreeSelect.vue';
const props = defineProps({
visible: {
@@ -109,6 +154,25 @@ const props = defineProps({
const emit = defineEmits(['update:visible', 'cancel', 'val-change']);
const rules = {
tagCodePaths: [
{
required: true,
message: '请选择标签',
trigger: ['change'],
},
],
code: [
{
required: true,
message: '请输入编码',
trigger: ['change', 'blur'],
},
{
pattern: ResourceCodePattern.pattern,
message: ResourceCodePattern.message,
trigger: ['blur'],
},
],
name: [
{
required: true,
@@ -130,13 +194,6 @@ const rules = {
trigger: ['blur'],
},
],
username: [
{
required: true,
message: '请输入用户名',
trigger: ['change', 'blur'],
},
],
sid: [
{
required: true,
@@ -147,129 +204,109 @@ const rules = {
};
const dbForm: any = ref(null);
const tagSelectRef: any = ref(null);
const dbTypes = [
{
type: 'mysql',
label: 'mysql',
},
{
type: 'mariadb',
label: 'mariadb',
},
{
type: 'postgres',
label: 'postgres',
},
{
type: 'dm',
label: '达梦',
},
{
type: 'oracle',
label: 'oracle',
},
];
const DefaultForm = {
id: null,
type: DbType.mysql,
code: '',
name: null,
host: '',
port: getDbDialect(DbType.mysql).getInfo().defaultPort,
extra: '', // 连接需要的额外参数json字符串
params: null,
remark: '',
sshTunnelMachineId: null as any,
authCerts: [],
tagCodePaths: [],
};
const state = reactive({
dialogVisible: false,
tabActiveName: 'basic',
form: {
id: null,
type: null,
name: null,
host: '',
port: null,
username: null,
sid: null, // oracle类项目需要服务id
password: null,
params: null,
remark: '',
sshTunnelMachineId: null as any,
},
subimtForm: {},
// 原密码
pwd: '',
// 原用户名
oldUserName: null,
extra: {} as any, // 连接需要的额外参数json
form: DefaultForm,
submitForm: {} as any,
});
const { dialogVisible, tabActiveName, form, subimtForm, pwd } = toRefs(state);
const { dialogVisible, form, submitForm } = toRefs(state);
const { isFetching: saveBtnLoading, execute: saveInstanceExec } = dbApi.saveInstance.useApi(subimtForm);
const { isFetching: testConnBtnLoading, execute: testConnExec } = dbApi.testConn.useApi(subimtForm);
const { isFetching: saveBtnLoading, execute: saveInstanceExec, data: saveInstanceRes } = dbApi.saveInstance.useApi(submitForm);
const { isFetching: testConnBtnLoading, execute: testConnExec } = dbApi.testConn.useApi(submitForm);
watch(props, (newValue: any) => {
state.dialogVisible = newValue.visible;
watchEffect(() => {
state.dialogVisible = props.visible;
if (!state.dialogVisible) {
return;
}
state.tabActiveName = 'basic';
if (newValue.data) {
state.form = { ...newValue.data };
state.oldUserName = state.form.username;
const dbInst: any = props.data;
if (dbInst) {
state.form = { ...dbInst };
state.form.tagCodePaths = dbInst.tags.map((t: any) => t.codePath) || [];
try {
state.extra = JSON.parse(state.form.extra);
} catch (e) {
state.extra = {};
}
} else {
state.form = { port: null } as any;
state.oldUserName = null;
state.form = { ...DefaultForm };
state.form.authCerts = [];
}
});
const changeDbType = (val: string) => {
if (!state.form.id) {
state.form.port = getDbDialect(val).getInfo().defaultPort as any;
}
};
const getDbPwd = async () => {
state.pwd = await dbApi.getInstancePwd.request({ id: state.form.id });
};
const getReqForm = async () => {
const reqForm = { ...state.form };
reqForm.password = await RsaEncrypt(reqForm.password);
const reqForm: any = { ...state.form };
reqForm.selectAuthCert = null;
reqForm.tags = null;
if (!state.form.sshTunnelMachineId) {
reqForm.sshTunnelMachineId = -1;
}
if (Object.keys(state.extra).length > 0) {
reqForm.extra = JSON.stringify(state.extra);
}
return reqForm;
};
const testConn = async () => {
dbForm.value.validate(async (valid: boolean) => {
if (!valid) {
ElMessage.error('请正确填写信息');
return false;
}
const testConn = async (authCert: any) => {
try {
await dbForm.value.validate();
} catch (e: any) {
ElMessage.error('请正确填写信息');
return false;
}
state.subimtForm = await getReqForm();
await testConnExec();
ElMessage.success('连接成功');
});
state.submitForm = await getReqForm();
state.submitForm.authCerts = [authCert];
await testConnExec();
ElMessage.success('连接成功');
};
const btnOk = async () => {
if (!state.form.id) {
notBlank(state.form.password, '新增操作,密码不可为空');
} else if (state.form.username != state.oldUserName) {
notBlank(state.form.password, '已修改用户名,请输入密码');
try {
await dbForm.value.validate();
} catch (e: any) {
ElMessage.error('请正确填写信息');
return false;
}
dbForm.value.validate(async (valid: boolean) => {
if (!valid) {
ElMessage.error('请正确填写信息');
return false;
}
state.subimtForm = await getReqForm();
await saveInstanceExec();
ElMessage.success('保存成功');
emit('val-change', state.form);
cancel();
});
state.submitForm = await getReqForm();
await saveInstanceExec();
ElMessage.success('保存成功');
state.form.id = saveInstanceRes as any;
emit('val-change', state.form);
cancel();
};
const cancel = () => {
emit('update:visible', false);
emit('cancel');
state.extra = {};
};
const changeDbType = (val: string) => {
if (!state.form.id) {
state.form.port = getDbDialect(val).getInfo().defaultPort as any;
}
state.extra = {};
};
</script>
<style lang="scss"></style>

View File

@@ -3,11 +3,13 @@
<page-table
ref="pageTableRef"
:page-api="dbApi.instances"
:data-handler-fn="handleData"
:searchItems="searchItems"
v-model:query-form="query"
:show-selection="true"
v-model:selection-data="state.selectionData"
:columns="columns"
lazy
>
<template #tableHeader>
<el-button v-auth="perms.saveInstance" type="primary" icon="plus" @click="editInstance(false)">添加</el-button>
@@ -16,8 +18,16 @@
>
</template>
<template #tagPath="{ data }">
<ResourceTags :tags="data.tags" />
</template>
<template #authCert="{ data }">
<ResourceAuthCert v-model:select-auth-cert="data.selectAuthCert" :auth-certs="data.authCerts" />
</template>
<template #type="{ data }">
<el-tooltip :content="data.type" placement="top">
<el-tooltip :content="getDbDialect(data.type).getInfo().name" placement="top">
<SvgIcon :name="getDbDialect(data.type).getInfo().icon" :size="20" />
</el-tooltip>
</template>
@@ -25,6 +35,7 @@
<template #action="{ data }">
<el-button @click="showInfo(data)" link>详情</el-button>
<el-button v-if="actionBtns[perms.saveInstance]" @click="editInstance(data)" type="primary" link>编辑</el-button>
<el-button v-if="actionBtns[perms.saveDb]" @click="editDb(data)" type="primary" link>库配置</el-button>
</template>
</page-table>
@@ -35,7 +46,6 @@
<el-descriptions-item :span="2" label="主机">{{ infoDialog.data.host }}</el-descriptions-item>
<el-descriptions-item :span="1" label="端口">{{ infoDialog.data.port }}</el-descriptions-item>
<el-descriptions-item :span="2" label="用户名">{{ infoDialog.data.username }}</el-descriptions-item>
<el-descriptions-item :span="1" label="类型">{{ infoDialog.data.type }}</el-descriptions-item>
<el-descriptions-item :span="3" label="连接参数">{{ infoDialog.data.params }}</el-descriptions-item>
@@ -43,56 +53,73 @@
<el-descriptions-item :span="3" label="SSH隧道">{{ infoDialog.data.sshTunnelMachineId > 0 ? '是' : '否' }} </el-descriptions-item>
<el-descriptions-item :span="2" label="创建时间">{{ dateFormat(infoDialog.data.createTime) }} </el-descriptions-item>
<el-descriptions-item :span="2" label="创建时间">{{ formatDate(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="2" label="更新时间">{{ formatDate(infoDialog.data.updateTime) }} </el-descriptions-item>
<el-descriptions-item :span="1" label="修改者">{{ infoDialog.data.modifier }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
<instance-edit
@val-change="search"
@val-change="search()"
:title="instanceEditDialog.title"
v-model:visible="instanceEditDialog.visible"
v-model:data="instanceEditDialog.data"
></instance-edit>
<instance-db-conf :title="dbEditDialog.title" v-model:visible="dbEditDialog.visible" :instance="dbEditDialog.instance" />
</div>
</template>
<script lang="ts" setup>
import { ref, toRefs, reactive, onMounted, defineAsyncComponent, Ref } from 'vue';
import { defineAsyncComponent, onMounted, reactive, ref, Ref, toRefs } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { dbApi } from './api';
import { dateFormat } from '@/common/utils/date';
import { formatDate } from '@/common/utils/format';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { hasPerms } from '@/components/auth/auth';
import SvgIcon from '@/components/svgIcon/index.vue';
import { getDbDialect } from './dialect';
import { SearchItem } from '@/components/SearchForm';
import ResourceAuthCert from '../component/ResourceAuthCert.vue';
import ResourceTags from '../component/ResourceTags.vue';
import { getTagPathSearchItem } from '../component/tag';
import { TagResourceTypeEnum } from '@/common/commonEnum';
const InstanceEdit = defineAsyncComponent(() => import('./InstanceEdit.vue'));
const InstanceDbConf = defineAsyncComponent(() => import('./InstanceDbConf.vue'));
const props = defineProps({
lazy: {
type: [Boolean],
default: false,
},
});
const perms = {
saveInstance: 'db:instance:save',
delInstance: 'db:instance:del',
saveDb: 'db:save',
};
const searchItems = [SearchItem.input('name', '名称')];
const searchItems = [getTagPathSearchItem(TagResourceTypeEnum.Db.value), SearchItem.input('code', '编号'), SearchItem.input('name', '名称')];
const columns = ref([
TableColumn.new('tags[0].tagPath', '关联标签').isSlot('tagPath').setAddWidth(20),
TableColumn.new('name', '名称'),
TableColumn.new('type', '类型').isSlot().setAddWidth(-15).alignCenter(),
TableColumn.new('host', 'host:port').setFormatFunc((data: any) => `${data.host}:${data.port}`),
TableColumn.new('username', '用户名'),
TableColumn.new('authCerts[0].username', '授权凭证').isSlot('authCert').setAddWidth(10),
TableColumn.new('params', '连接参数'),
TableColumn.new('remark', '备注'),
TableColumn.new('code', '编号'),
]);
// 该用户拥有的的操作列按钮权限
const actionBtns = hasPerms([perms.saveInstance]);
const actionColumn = TableColumn.new('action', '操作').isSlot().setMinWidth(110).fixedRight().alignCenter();
const actionBtns = hasPerms(Object.values(perms));
const actionColumn = TableColumn.new('action', '操作').isSlot().setMinWidth(180).fixedRight().alignCenter();
const pageTableRef: Ref<any> = ref(null);
const state = reactive({
@@ -108,6 +135,7 @@ const state = reactive({
*/
query: {
name: null,
tagPath: '',
pageNum: 1,
pageSize: 0,
},
@@ -120,20 +148,40 @@ const state = reactive({
data: null as any,
title: '新增数据库实例',
},
dbEditDialog: {
visible: false,
instance: null as any,
title: '新增数据库实例',
},
});
const { selectionData, query, infoDialog, instanceEditDialog } = toRefs(state);
const { selectionData, query, infoDialog, instanceEditDialog, dbEditDialog } = toRefs(state);
onMounted(async () => {
if (Object.keys(actionBtns).length > 0) {
columns.value.push(actionColumn);
}
if (!props.lazy) {
search();
}
});
const search = () => {
const search = (tagPath: string = '') => {
if (tagPath) {
state.query.tagPath = tagPath;
}
pageTableRef.value.search();
};
const handleData = (res: any) => {
const dataList = res.list;
// 赋值授权凭证
for (let x of dataList) {
x.selectAuthCert = x.authCerts[0];
}
return res;
};
const showInfo = (info: any) => {
state.infoDialog.data = info;
state.infoDialog.visible = true;
@@ -164,5 +212,13 @@ const deleteInstance = async () => {
//
}
};
const editDb = (data: any) => {
state.dbEditDialog.instance = data;
state.dbEditDialog.title = `配置 "${data.name}" 数据库`;
state.dbEditDialog.visible = true;
};
defineExpose({ search });
</script>
<style lang="scss"></style>

View File

@@ -2,7 +2,12 @@
<div class="db-sql-exec">
<Splitpanes class="default-theme">
<Pane size="20" max-size="30">
<tag-tree :resource-type="TagResourceTypeEnum.Db.value" :tag-path-node-type="NodeTypeTagPath" ref="tagTreeRef">
<tag-tree
:default-expanded-keys="state.defaultExpendKey"
:resource-type="TagResourceTypeEnum.DbName.value"
:tag-path-node-type="NodeTypeTagPath"
ref="tagTreeRef"
>
<template #prefix="{ data }">
<span v-if="data.type.value == SqlExecNodeType.DbInst">
<el-popover
@@ -27,8 +32,8 @@
<el-descriptions-item label="数据库版本">
<span v-loading="loadingServerInfo"> {{ `${dbServerInfo?.version}` }}</span>
</el-descriptions-item>
<el-descriptions-item label="user">
{{ data.params.username }}
<el-descriptions-item label="授权凭证">
{{ data.params.authCertName }}
</el-descriptions-item>
<el-descriptions-item label="备注">
{{ data.params.remark }}
@@ -42,10 +47,8 @@
</template>
<template #suffix="{ data }">
<span class="db-table-size" v-if="data.type.value == SqlExecNodeType.Table && data.params.size">{{ ` ${data.params.size}` }}</span>
<span class="db-table-size" v-if="data.type.value == SqlExecNodeType.TableMenu && data.params.dbTableSize">{{
` ${data.params.dbTableSize}`
}}</span>
<span v-if="data.type.value == SqlExecNodeType.Table && data.params.size">{{ ` ${data.params.size}` }}</span>
<span v-if="data.type.value == SqlExecNodeType.TableMenu && data.params.dbTableSize">{{ ` ${data.params.dbTableSize}` }}</span>
</template>
</tag-tree>
</Pane>
@@ -71,7 +74,7 @@
<el-descriptions-item label-align="right">
<template #label>
<div>
<SvgIcon :name="getDbDialect(nowDbInst.type).getInfo().icon" :size="18" />
<SvgIcon :name="nowDbInst.getDialect().getInfo().icon" :size="18" />
实例
</div>
</template>
@@ -143,6 +146,7 @@
:db-id="dt.params.id"
:db="dt.params.db"
:db-type="dt.params.type"
:flow-procdef="dt.params.flowProcdef"
:height="state.tablesOpHeight"
/>
</el-tab-pane>
@@ -151,26 +155,42 @@
</div>
</Pane>
</Splitpanes>
<db-table-op
:title="tableCreateDialog.title"
:active-name="tableCreateDialog.activeName"
:dbId="tableCreateDialog.dbId"
:db="tableCreateDialog.db"
:dbType="tableCreateDialog.dbType"
:flow-procdef="tableCreateDialog.flowProcdef"
:data="tableCreateDialog.data"
v-model:visible="tableCreateDialog.visible"
@submit-sql="onSubmitEditTableSql"
/>
</div>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, onBeforeUnmount, onMounted, reactive, ref, toRefs } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { defineAsyncComponent, h, onBeforeUnmount, onMounted, reactive, ref, toRefs, watch } from 'vue';
import { ElCheckbox, ElMessage, ElMessageBox } from 'element-plus';
import { formatByteSize } from '@/common/utils/format';
import { DbInst, registerDbCompletionItemProvider, TabInfo, TabType } from './db';
import { NodeType, TagTreeNode } from '../component/tag';
import { NodeType, TagTreeNode, getTagTypeCodeByPath } from '../component/tag';
import TagTree from '../component/TagTree.vue';
import { dbApi } from './api';
import { dispposeCompletionItemProvider } from '@/components/monaco/completionItemProvider';
import SvgIcon from '@/components/svgIcon/index.vue';
import { ContextmenuItem } from '@/components/contextmenu';
import { DbType, getDbDialect } from './dialect/index';
import { getDbDialect, schemaDbTypes } from './dialect/index';
import { sleep } from '@/common/utils/loading';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { Pane, Splitpanes } from 'splitpanes';
import { useEventListener } from '@vueuse/core';
import SqlExecBox from '@/views/ops/db/component/sqleditor/SqlExecBox';
import { useAutoOpenResource } from '@/store/autoOpenResource';
import { storeToRefs } from 'pinia';
import { procdefApi } from '@/views/flow/api';
const DbTableOp = defineAsyncComponent(() => import('./component/table/DbTableOp.vue'));
const DbSqlEditor = defineAsyncComponent(() => import('./component/sqleditor/DbSqlEditor.vue'));
const DbTableDataOp = defineAsyncComponent(() => import('./component/table/DbTableDataOp.vue'));
const DbTablesOp = defineAsyncComponent(() => import('./component/table/DbTablesOp.vue'));
@@ -214,30 +234,47 @@ const SqlIcon = {
const nodeClickChangeDb = (nodeData: TagTreeNode) => {
const params = nodeData.params;
if (params.db) {
changeDb({ id: params.id, host: `${params.host}`, name: params.name, type: params.type, tagPath: params.tagPath, databases: params.dbs }, params.db);
changeDb(
{
id: params.id,
host: `${params.host}`,
name: params.name,
type: params.type,
tagPath: params.tagPath,
databases: params.dbs,
flowProcdef: params.flowProcdef,
},
params.db
);
}
};
// tagpath 节点类型
const NodeTypeTagPath = new NodeType(TagTreeNode.TagPath).withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const dbInfoRes = await dbApi.dbs.request({ tagPath: parentNode.key });
const dbInfos = dbInfoRes.list;
if (!dbInfos) {
return [];
}
const ContextmenuItemRefresh = new ContextmenuItem('refresh', '刷新').withIcon('RefreshRight').withOnClick((data: any) => reloadNode(data.key));
// 防止过快加载会出现一闪而过,对眼睛不好
await sleep(100);
return dbInfos?.map((x: any) => {
x.tagPath = parentNode.key;
return new TagTreeNode(`${parentNode.key}.${x.id}`, x.name, NodeTypeDbInst).withParams(x);
});
});
// tagpath 节点类型
const NodeTypeTagPath = new NodeType(TagTreeNode.TagPath)
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const dbInfoRes = await dbApi.dbs.request({ tagPath: parentNode.key });
const dbInfos = dbInfoRes.list;
if (!dbInfos) {
return [];
}
// 防止过快加载会出现一闪而过,对眼睛不好
await sleep(100);
return dbInfos?.map((x: any) => {
x.tagPath = parentNode.key;
return new TagTreeNode(`${x.code}`, x.name, NodeTypeDbInst).withParams(x);
});
})
.withContextMenuItems([ContextmenuItemRefresh]);
// 数据库实例节点类型
const NodeTypeDbInst = new NodeType(SqlExecNodeType.DbInst).withLoadNodesFunc((parentNode: TagTreeNode) => {
const NodeTypeDbInst = new NodeType(SqlExecNodeType.DbInst).withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
const dbs = params.database.split(' ')?.sort();
const flowProcdef = await procdefApi.getByResource.request({ resourceType: TagResourceTypeEnum.DbName.value, resourceCode: params.code });
return dbs.map((x: any) => {
return new TagTreeNode(`${parentNode.key}.${x}`, x, NodeTypeDb)
.withParams({
@@ -248,6 +285,7 @@ const NodeTypeDbInst = new NodeType(SqlExecNodeType.DbInst).withLoadNodesFunc((p
host: `${params.host}:${params.port}`,
dbs: dbs,
db: x,
flowProcdef: flowProcdef,
})
.withIcon(DbIcon);
});
@@ -255,12 +293,12 @@ const NodeTypeDbInst = new NodeType(SqlExecNodeType.DbInst).withLoadNodesFunc((p
// 数据库节点
const NodeTypeDb = new NodeType(SqlExecNodeType.Db)
.withContextMenuItems([new ContextmenuItem('reloadTables', '刷新').withIcon('RefreshRight').withOnClick((data: any) => reloadNode(data.key))])
.withContextMenuItems([ContextmenuItemRefresh])
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
params.parentKey = parentNode.key;
// pg类数据库会多一层schema
if (params.type == DbType.postgresql || params.type === DbType.dm || params.type === DbType.oracle) {
const params = parentNode.params;
if (schemaDbTypes.includes(params.type)) {
const { id, db } = params;
const schemaNames = await dbApi.pgSchemas.request({ id, db });
return schemaNames.map((sn: any) => {
@@ -269,33 +307,38 @@ const NodeTypeDb = new NodeType(SqlExecNodeType.Db)
nParams.schema = sn;
nParams.db = nParams.db + '/' + sn;
nParams.dbs = schemaNames;
return new TagTreeNode(`${params.id}.${params.db}.schema.${sn}`, sn, NodeTypePostgresScheam).withParams(nParams).withIcon(SchemaIcon);
return new TagTreeNode(`${params.id}.${params.db}.schema.${sn}`, sn, NodeTypePostgresSchema).withParams(nParams).withIcon(SchemaIcon);
});
}
return [
new TagTreeNode(`${params.id}.${params.db}.table-menu`, '表', NodeTypeTableMenu).withParams(params).withIcon(TableIcon),
new TagTreeNode(getSqlMenuNodeKey(params.id, params.db), 'SQL', NodeTypeSqlMenu).withParams(params).withIcon(SqlIcon),
];
return getNodeTypeTables(params);
})
.withNodeClickFunc(nodeClickChangeDb);
const getNodeTypeTables = (params: any) => {
let tableKey = `${params.id}.${params.db}.table-menu`;
let sqlKey = getSqlMenuNodeKey(params.id, params.db);
return [
new TagTreeNode(`${params.id}.${params.db}.table-menu`, '表', NodeTypeTableMenu).withParams({ ...params, key: tableKey }).withIcon(TableIcon),
new TagTreeNode(sqlKey, 'SQL', NodeTypeSqlMenu).withParams({ ...params, key: sqlKey }).withIcon(SqlIcon),
];
};
// postgres schema模式
const NodeTypePostgresScheam = new NodeType(SqlExecNodeType.PgSchema)
.withContextMenuItems([new ContextmenuItem('reloadTables', '刷新').withIcon('RefreshRight').withOnClick((data: any) => reloadNode(data.key))])
const NodeTypePostgresSchema = new NodeType(SqlExecNodeType.PgSchema)
.withContextMenuItems([ContextmenuItemRefresh])
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
return [
new TagTreeNode(`${params.id}.${params.db}.table-menu`, '表', NodeTypeTableMenu).withParams(params).withIcon(TableIcon),
new TagTreeNode(getSqlMenuNodeKey(params.id, params.db), 'SQL', NodeTypeSqlMenu).withParams(params).withIcon(SqlIcon),
];
params.parentKey = parentNode.key;
return getNodeTypeTables(params);
})
.withNodeClickFunc(nodeClickChangeDb);
// 数据库表菜单节点
const NodeTypeTableMenu = new NodeType(SqlExecNodeType.TableMenu)
.withContextMenuItems([
new ContextmenuItem('reloadTables', '刷新').withIcon('RefreshRight').withOnClick((data: any) => reloadNode(data.key)),
ContextmenuItemRefresh,
new ContextmenuItem('createTable', '创建表').withIcon('Plus').withOnClick((data: any) => onEditTable(data)),
new ContextmenuItem('tablesOp', '表操作').withIcon('Setting').withOnClick((data: any) => {
const params = data.params;
addTablesOpTab({ id: params.id, db: params.db, type: params.type, nodeKey: data.key });
@@ -303,30 +346,40 @@ const NodeTypeTableMenu = new NodeType(SqlExecNodeType.TableMenu)
])
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
let { id, db } = params;
let { id, db, type, flowProcdef, schema } = params;
// 获取当前库的所有表信息
let tables = await DbInst.getInst(id).loadTables(db, state.reloadStatus);
state.reloadStatus = false;
let dbTableSize = 0;
const tablesNode = tables.map((x: any) => {
dbTableSize += x.dataLength + x.indexLength;
return new TagTreeNode(`${id}.${db}.${x.tableName}`, x.tableName, NodeTypeTable)
const tableSize = x.dataLength + x.indexLength;
dbTableSize += tableSize;
const key = `${id}.${db}.${x.tableName}`;
return new TagTreeNode(key, x.tableName, NodeTypeTable)
.withIsLeaf(true)
.withParams({
id,
db,
type,
schema,
flowProcdef: flowProcdef,
key: key,
parentKey: parentNode.key,
tableName: x.tableName,
tableComment: x.tableComment,
size: formatByteSize(x.dataLength + x.indexLength, 1),
size: tableSize == 0 ? '' : formatByteSize(tableSize, 1),
})
.withIcon(TableIcon)
.withLabelRemark(`${x.tableName} ${x.tableComment ? '| ' + x.tableComment : ''}`);
});
// 设置父节点参数的表大小
parentNode.params.dbTableSize = formatByteSize(dbTableSize);
parentNode.params.dbTableSize = dbTableSize == 0 ? '' : formatByteSize(dbTableSize);
return tablesNode;
})
.withNodeClickFunc(nodeClickChangeDb);
.withNodeDblclickFunc((node: TagTreeNode) => {
const params = node.params;
addTablesOpTab({ id: params.id, db: params.db, type: params.type, nodeKey: node.key });
});
// 数据库sql模板菜单节点
const NodeTypeSqlMenu = new NodeType(SqlExecNodeType.SqlMenu)
@@ -340,22 +393,24 @@ const NodeTypeSqlMenu = new NodeType(SqlExecNodeType.SqlMenu)
return sqls.map((x: any) => {
return new TagTreeNode(`${id}.${db}.${x.name}`, x.name, NodeTypeSql)
.withIsLeaf(true)
.withParams({
id,
db,
dbs,
sqlName: x.name,
})
.withParams({ id, db, dbs, sqlName: x.name })
.withIcon(SqlIcon);
});
})
.withNodeClickFunc(nodeClickChangeDb);
// 表节点类型
const NodeTypeTable = new NodeType(SqlExecNodeType.Table).withNodeClickFunc((nodeData: TagTreeNode) => {
const params = nodeData.params;
loadTableData({ id: params.id, nodeKey: nodeData.key }, params.db, params.tableName);
});
const NodeTypeTable = new NodeType(SqlExecNodeType.Table)
.withContextMenuItems([
new ContextmenuItem('copyTable', '复制表').withIcon('copyDocument').withOnClick((data: any) => onCopyTable(data)),
new ContextmenuItem('renameTable', '重命名').withIcon('edit').withOnClick((data: any) => onRenameTable(data)),
new ContextmenuItem('editTable', '编辑表').withIcon('edit').withOnClick((data: any) => onEditTable(data)),
new ContextmenuItem('delTable', '删除表').withIcon('Delete').withOnClick((data: any) => onDeleteTable(data)),
])
.withNodeClickFunc((nodeData: TagTreeNode) => {
const params = nodeData.params;
loadTableData({ id: params.id, nodeKey: nodeData.key }, params.db, params.tableName);
});
// sql模板节点类型
const NodeTypeSql = new NodeType(SqlExecNodeType.Sql)
@@ -371,6 +426,7 @@ const tagTreeRef: any = ref(null);
const tabs: Map<string, TabInfo> = new Map();
const state = reactive({
defaultExpendKey: [] as any,
/**
* 当前操作的数据库实例
*/
@@ -385,16 +441,31 @@ const state = reactive({
loading: true,
version: '',
},
tableCreateDialog: {
visible: false,
title: '',
activeName: '',
dbId: 0,
db: '',
dbType: '',
flowProcdef: null as any,
data: {},
parentKey: '',
},
});
const { nowDbInst } = toRefs(state);
const { nowDbInst, tableCreateDialog } = toRefs(state);
const serverInfoReqParam = ref({
instanceId: 0,
});
const { execute: getDbServerInfo, isFetching: loadingServerInfo, data: dbServerInfo } = dbApi.getInstanceServerInfo.useApi<any>(serverInfoReqParam);
const autoOpenResourceStore = useAutoOpenResource();
const { autoOpenResource } = storeToRefs(autoOpenResourceStore);
onMounted(() => {
autoOpenDb(autoOpenResource.value.dbCodePath);
setHeight();
// 监听浏览器窗口大小变化,更新对应组件高度
useEventListener(window, 'resize', setHeight);
@@ -404,11 +475,36 @@ onBeforeUnmount(() => {
dispposeCompletionItemProvider('sql');
});
watch(
() => autoOpenResource.value.dbCodePath,
(codePath: any) => {
autoOpenDb(codePath);
}
);
const autoOpenDb = (codePath: string) => {
if (!codePath) {
return;
}
const typeAndCodes = getTagTypeCodeByPath(codePath);
const tagPath = typeAndCodes[TagResourceTypeEnum.Tag.value].join('/') + '/';
const dbCode = typeAndCodes[TagResourceTypeEnum.DbName.value][0];
state.defaultExpendKey = [tagPath, dbCode];
setTimeout(() => {
// 置空
autoOpenResourceStore.setDbCodePath('');
tagTreeRef.value.setCurrentKey(dbCode);
}, 1000);
};
/**
* 设置editor高度和数据表高度
*/
const setHeight = () => {
state.dataTabsTableHeight = window.innerHeight - 270 + 'px';
state.dataTabsTableHeight = window.innerHeight - 253 + 'px';
state.tablesOpHeight = window.innerHeight - 225 + 'px';
};
@@ -573,6 +669,8 @@ const onTabChange = () => {
// 注册sql提示
registerDbCompletionItemProvider(nowTab.dbId, nowTab.db, nowTab.params.dbs, nowDbInst.value.type);
}
tagTreeRef.value.setCurrentKey(nowTab?.treeNodeKey);
};
const reloadSqls = (dbId: number, db: string) => {
@@ -603,6 +701,127 @@ const reloadNode = (nodeKey: string) => {
tagTreeRef.value.reloadNode(nodeKey);
};
const onEditTable = async (data: any) => {
let { db, id, tableName, tableComment, type, parentKey, key, flowProcdef } = data.params;
// data.label就是表名
if (tableName) {
state.tableCreateDialog.title = '修改表';
let indexs = await dbApi.tableIndex.request({ id, db, tableName });
let columns = await dbApi.columnMetadata.request({ id, db, tableName });
let row = { tableName, tableComment };
state.tableCreateDialog.data = { edit: true, row, indexs, columns };
state.tableCreateDialog.parentKey = parentKey;
} else {
state.tableCreateDialog.title = '创建表';
state.tableCreateDialog.data = { edit: false, row: {} };
state.tableCreateDialog.parentKey = key;
}
state.tableCreateDialog.activeName = '1';
state.tableCreateDialog.dbId = id;
state.tableCreateDialog.db = db;
state.tableCreateDialog.dbType = type;
state.tableCreateDialog.flowProcdef = flowProcdef;
state.tableCreateDialog.visible = true;
};
const onDeleteTable = async (data: any) => {
let { db, id, tableName, parentKey, flowProcdef, schema } = data.params;
await ElMessageBox.confirm(`此操作是永久性且无法撤销,确定删除【${tableName}】? `, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
// 执行sql
let dialect = getDbDialect(state.nowDbInst.type);
let schemaStr = schema ? `${dialect.quoteIdentifier(schema)}.` : '';
dbApi.sqlExec.request({ id, db, sql: `drop table ${schemaStr + dialect.quoteIdentifier(tableName)}` }).then(() => {
if (flowProcdef) {
ElMessage.success('工单提交成功');
return;
}
ElMessage.success('删除成功');
setTimeout(() => {
parentKey && reloadNode(parentKey);
}, 1000);
});
};
const onRenameTable = async (data: any) => {
let { db, id, tableName, parentKey, flowProcdef } = data.params;
let tableData = { db, oldTableName: tableName, tableName };
let value = ref(tableName);
// 弹出确认框
const promptValue = await ElMessageBox.prompt('', `重命名表【${db}.${tableName}`, {
inputValue: value.value,
confirmButtonText: '确定',
cancelButtonText: '取消',
});
tableData.tableName = promptValue.value;
let sql = nowDbInst.value.getDialect().getModifyTableInfoSql(tableData);
if (!sql) {
ElMessage.warning('无更改');
return;
}
SqlExecBox({
sql: sql,
dbId: id as any,
db: db as any,
dbType: nowDbInst.value.getDialect().getInfo().formatSqlDialect,
flowProcdef: flowProcdef,
runSuccessCallback: () => {
setTimeout(() => {
parentKey && reloadNode(parentKey);
}, 1000);
},
});
};
const onCopyTable = async (data: any) => {
let { db, id, tableName, parentKey } = data.params;
let checked = ref(false);
// 弹出确认框,并选择是否复制数据
await ElMessageBox({
title: `复制表【${tableName}`,
type: 'warning',
// icon: markRaw(Delete),
message: () =>
h(ElCheckbox, {
label: '是否复制数据?',
modelValue: checked.value,
'onUpdate:modelValue': (val: boolean | string | number) => {
if (typeof val === 'boolean') {
checked.value = val;
}
},
}),
callback: (action: string) => {
if (action === 'confirm') {
// 执行sql
dbApi.copyTable.request({ id, db, tableName, copyData: checked.value }).then(() => {
ElMessage.success('复制成功');
setTimeout(() => {
parentKey && reloadNode(parentKey);
}, 1000);
});
}
},
});
};
const onSubmitEditTableSql = () => {
state.tableCreateDialog.visible = false;
state.tableCreateDialog.data = { edit: false, row: {} };
reloadNode(state.tableCreateDialog.parentKey);
};
/**
* 获取当前操作的数据库信息
*/
@@ -614,6 +833,7 @@ const getNowDbInfo = () => {
name: di.name,
type: di.type,
host: di.host,
flowProcdef: di.flowProcdef,
dbName: state.db,
};
};
@@ -621,13 +841,8 @@ const getNowDbInfo = () => {
<style lang="scss">
.db-sql-exec {
.db-table-size {
color: #c4c9c4;
font-size: 9px;
}
.db-op {
height: calc(100vh - 108px);
height: calc(100vh - 106px);
}
#data-exec {

View File

@@ -16,7 +16,7 @@
<el-row>
<el-col :span="11">
<el-form-item prop="taskName" label="任务名" required>
<el-input v-model.trim="form.taskName" placeholder="请输入数据库别名" auto-complete="off" />
<el-input v-model.trim="form.taskName" placeholder="请输入同步任务名" auto-complete="off" />
</el-form-item>
</el-col>
@@ -45,8 +45,10 @@
<db-select-tree
placeholder="请选择源数据库"
v-model:db-id="form.srcDbId"
v-model:inst-name="form.srcInstName"
v-model:db-name="form.srcDbName"
v-model:tag-path="form.srcTagPath"
v-model:db-type="form.srcDbType"
@select-db="onSelectSrcDb"
/>
</el-form-item>
@@ -55,8 +57,10 @@
<db-select-tree
placeholder="请选择目标数据库"
v-model:db-id="form.targetDbId"
v-model:inst-name="form.targetInstName"
v-model:db-name="form.targetDbName"
v-model:tag-path="form.targetTagPath"
v-model:db-type="form.targetDbType"
@select-db="onSelectTargetDb"
/>
</el-form-item>
@@ -85,9 +89,11 @@
</el-col>
<el-col :span="8">
<el-form-item prop="updField" label="更新字段" required>
<el-input v-model.trim="form.updField" placeholder="查询数据源的时候会带上这个字段当前最大值" auto-complete="off" />
</el-form-item>
<el-tooltip content="查询数据源的时候会带上这个字段当前最大值支持带别名t.create_time" placement="top">
<el-form-item prop="updField" label="更新字段" required>
<el-input v-model.trim="form.updField" placeholder="查询数据源的时候会带上这个字段当前最大值" auto-complete="off" />
</el-form-item>
</el-tooltip>
</el-col>
<el-col :span="8">
@@ -121,10 +127,17 @@
<el-tab-pane label="sql预览" :name="sqlPreviewTab" :disabled="!baseFieldCompleted">
<el-form-item prop="fieldMap" label="查询sql">
<el-input type="textarea" v-model="state.previewDataSql" readonly :input-style="{ height: '190px' }" />
<el-input type="textarea" v-model="state.previewDataSql" readonly :input-style="{ height: '170px' }" />
</el-form-item>
<el-form-item prop="fieldMap" label="插入sql">
<el-input type="textarea" v-model="state.previewInsertSql" readonly :input-style="{ height: '190px' }" />
<el-input type="textarea" v-model="state.previewInsertSql" readonly :input-style="{ height: '170px' }" />
</el-form-item>
<el-form-item prop="isReplace" v-if="compatibleDuplicateStrategy(form.targetDbType!)" label="键冲突策略">
<el-select v-model="form.duplicateStrategy" @change="handleDuplicateStrategy" style="width: 100px">
<el-option label="无" :value="DuplicateStrategy.NONE" />
<el-option label="忽略" :value="DuplicateStrategy.IGNORE" />
<el-option label="替换" :value="DuplicateStrategy.REPLACE" />
</el-select>
</el-form-item>
</el-tab-pane>
</el-tabs>
@@ -181,7 +194,7 @@ import { ElMessage } from 'element-plus';
import DbSelectTree from '@/views/ops/db/component/DbSelectTree.vue';
import MonacoEditor from '@/components/monaco/MonacoEditor.vue';
import { DbInst, registerDbCompletionItemProvider } from '@/views/ops/db/db';
import { getDbDialect } from '@/views/ops/db/dialect';
import { compatibleDuplicateStrategy, DbType, DuplicateStrategy, getDbDialect } from '@/views/ops/db/dialect';
import CrontabInput from '@/components/crontab/CrontabInput.vue';
const props = defineProps({
@@ -226,18 +239,23 @@ type FormData = {
taskName?: string;
taskCron: string;
srcDbId?: number;
srcInstName?: string;
srcDbName?: string;
srcDbType?: string;
srcTagPath?: string;
targetDbId?: number;
targetInstName?: string;
targetDbName?: string;
targetTagPath?: string;
targetTableName?: string;
targetDbType?: string;
dataSql?: string;
pageSize?: number;
updField?: string;
updFieldVal?: string;
fieldMap?: { src: string; target: string }[];
status?: 1 | 2;
duplicateStrategy?: -1 | 1 | 2;
};
const basicFormData = {
@@ -245,10 +263,11 @@ const basicFormData = {
targetDbId: -1,
dataSql: 'select * from',
pageSize: 1000,
updField: 'id',
updField: '',
updFieldVal: '0',
fieldMap: [{ src: 'a', target: 'b' }],
status: 1,
duplicateStrategy: -1,
} as FormData;
const state = reactive({
@@ -263,6 +282,7 @@ const state = reactive({
previewRes: {} as any,
previewDataSql: '',
previewInsertSql: '',
previewFieldArr: [] as string[],
});
const { tabActiveName, form, submitForm } = toRefs(state);
@@ -281,12 +301,17 @@ watch(dialogVisible, async (newValue: boolean) => {
state.tabActiveName = 'basic';
const propsData = props.data as any;
if (!propsData?.id) {
state.form = basicFormData;
let d = {} as FormData;
Object.assign(d, basicFormData);
state.form = d;
return;
}
let data = await dbApi.getDatasyncTask.request({ taskId: propsData?.id });
state.form = data;
if (!state.form.duplicateStrategy) {
state.form.duplicateStrategy = -1;
}
try {
state.form.fieldMap = JSON.parse(data.fieldMap);
} catch (e) {
@@ -302,6 +327,8 @@ watch(dialogVisible, async (newValue: boolean) => {
// 初始化实例
db.databases = db.database?.split(' ').sort() || [];
state.srcDbInst = DbInst.getOrNewInst(db);
state.form.srcDbType = state.srcDbInst.type;
state.form.srcInstName = db.instanceName;
}
// 初始化target数据源
@@ -312,6 +339,8 @@ watch(dialogVisible, async (newValue: boolean) => {
// 初始化实例
db.databases = db.database?.split(' ').sort() || [];
state.targetDbInst = DbInst.getOrNewInst(db);
state.form.targetDbType = state.targetDbInst.type;
state.form.targetInstName = db.instanceName;
}
if (targetDbId && state.form.targetDbName) {
@@ -331,13 +360,12 @@ watch(tabActiveName, async (newValue: string) => {
await handleGetTargetFields();
break;
case sqlPreviewTab:
let srcDbDialect = getDbDialect(state.srcDbInst.type);
let targetDbDialect = getDbDialect(state.targetDbInst.type);
let updField = state.form.updField!;
let updField = srcDbDialect.quoteIdentifier(state.form.updField!);
state.previewDataSql = `SELECT * FROM (\n ${state.form.dataSql?.trim() || '请输入数据sql'} \n ) t \n where ${updField} > '${
state.form.updFieldVal || ''
}'`;
// 判断sql是否以where .*结尾
let hasCondition = /where/i.test(state.form.dataSql!);
state.previewDataSql = `${state.form.dataSql?.trim() || '请输入数据sql'} \n ${hasCondition ? 'and' : 'where'} ${updField} > '${state.form.updFieldVal || ''}'`;
// 检查字段映射中是否存在重复的目标字段
let fields = new Set();
@@ -353,17 +381,19 @@ watch(tabActiveName, async (newValue: string) => {
}
let fieldArr = state.form.fieldMap?.map((a: any) => targetDbDialect.quoteIdentifier(a.target)) || [];
let placeholder = '?'.repeat(fieldArr.length).split('').join(',');
state.previewInsertSql = ` insert into ${targetDbDialect.quoteIdentifier(state.form.targetTableName!)}(${fieldArr.join(
','
)}) values (${placeholder});`;
state.previewFieldArr = fieldArr;
refreshPreviewInsertSql();
break;
default:
break;
}
});
const refreshPreviewInsertSql = () => {
let targetDbDialect = getDbDialect(state.targetDbInst.type);
state.previewInsertSql = targetDbDialect.getBatchInsertPreviewSql(state.form.targetTableName!, state.previewFieldArr, state.form.duplicateStrategy!);
};
const onSelectSrcDb = async (params: any) => {
// 初始化数据源
params.databases = params.dbs; // 数据源里需要这个值
@@ -396,8 +426,8 @@ const handleGetSrcFields = async () => {
}
// 判断sql是否是查询语句
if (!/^select/i.test(state.form.dataSql!)) {
let msg = 'sql语句错误请输入查询语句';
if (!/^select/i.test(state.form.dataSql.trim()!)) {
let msg = 'sql语句错误请输入select语句';
ElMessage.warning(msg);
return;
}
@@ -410,10 +440,24 @@ const handleGetSrcFields = async () => {
}
// 执行sql
let sql: string;
if (state.form.srcDbType === DbType.mssql) {
// mssql的分页语法不一样
let top1 = `select top 1`;
sql = `${top1} * from (${state.form.dataSql}) a`;
} else if (state.form.srcDbType === DbType.oracle) {
// oracle的分页关键字不一样
let hasCondition = /where/i.test(state.form.dataSql!);
sql = `${state.form.dataSql} ${hasCondition ? 'and' : 'where'} rownum <= 1`;
} else {
sql = `${state.form.dataSql} limit 1`;
}
const res = await dbApi.sqlExec.request({
id: state.form.srcDbId,
db: state.form.srcDbName,
sql: state.form.dataSql.trim() + ' limit 1',
sql,
});
if (!res.columns) {
@@ -487,6 +531,11 @@ const btnOk = async () => {
const cancel = () => {
dialogVisible.value = false;
emit('cancel');
state.form = basicFormData;
};
const handleDuplicateStrategy = () => {
refreshPreviewInsertSql();
};
</script>
<style lang="scss">

View File

@@ -71,11 +71,13 @@ const searchItems = [SearchItem.input('name', '名称')];
// 任务名、修改人、修改时间、最近一次任务执行状态、状态(停用启用)、操作
const columns = ref([
TableColumn.new('taskName', '任务名'),
TableColumn.new('runningState', '运行状态').alignCenter().typeTag(DbDataSyncRunningStateEnum),
TableColumn.new('recentState', '最近任务状态').alignCenter().typeTag(DbDataSyncRecentStateEnum),
TableColumn.new('status', '状态').alignCenter().isSlot(),
TableColumn.new('modifier', '修改人').alignCenter(),
TableColumn.new('updateTime', '修改时间').alignCenter().isTime(),
TableColumn.new('runningState', '运行状态').typeTag(DbDataSyncRunningStateEnum),
TableColumn.new('recentState', '最近任务状态').typeTag(DbDataSyncRecentStateEnum),
TableColumn.new('status', '状态').isSlot(),
TableColumn.new('creator', '创建人'),
TableColumn.new('createTime', '创建时间').isTime(),
TableColumn.new('modifier', '修改人'),
TableColumn.new('updateTime', '修改时间').isTime(),
]);
// 该用户拥有的的操作列按钮权限

View File

@@ -11,6 +11,7 @@ export const dbApi = {
tableInfos: Api.newGet('/dbs/{id}/t-infos'),
tableIndex: Api.newGet('/dbs/{id}/t-index'),
tableDdl: Api.newGet('/dbs/{id}/t-create-ddl'),
copyTable: Api.newPost('/dbs/{id}/copy-table'),
columnMetadata: Api.newGet('/dbs/{id}/c-metadata'),
pgSchemas: Api.newGet('/dbs/{id}/pg/schemas'),
// 获取表即列提示
@@ -34,30 +35,33 @@ export const dbApi = {
getSqlNames: Api.newGet('/dbs/{id}/sql-names'),
deleteDbSql: Api.newDelete('/dbs/{id}/sql'),
// 获取数据库sql执行记录
getSqlExecs: Api.newGet('/dbs/{dbId}/sql-execs'),
getSqlExecs: Api.newGet('/dbs/sql-execs'),
instances: Api.newGet('/instances'),
getInstance: Api.newGet('/instances/{instanceId}'),
getAllDatabase: Api.newGet('/instances/{instanceId}/databases'),
getAllDatabase: Api.newPost('/instances/databases'),
getInstanceServerInfo: Api.newGet('/instances/{instanceId}/server-info'),
testConn: Api.newPost('/instances/test-conn'),
saveInstance: Api.newPost('/instances'),
getInstancePwd: Api.newGet('/instances/{id}/pwd'),
deleteInstance: Api.newDelete('/instances/{id}'),
// 获取数据库备份列表
getDbBackups: Api.newGet('/dbs/{dbId}/backups'),
createDbBackup: Api.newPost('/dbs/{dbId}/backups'),
deleteDbBackup: Api.newDelete('/dbs/{dbId}/backups/{backupId}'),
getDbNamesWithoutBackup: Api.newGet('/dbs/{dbId}/db-names-without-backup'),
enableDbBackup: Api.newPut('/dbs/{dbId}/backups/{backupId}/enable'),
disableDbBackup: Api.newPut('/dbs/{dbId}/backups/{backupId}/disable'),
startDbBackup: Api.newPut('/dbs/{dbId}/backups/{backupId}/start'),
saveDbBackup: Api.newPut('/dbs/{dbId}/backups/{id}'),
getDbBackupHistories: Api.newGet('/dbs/{dbId}/backup-histories'),
restoreDbBackupHistory: Api.newPost('/dbs/{dbId}/backup-histories/{backupHistoryId}/restore'),
deleteDbBackupHistory: Api.newDelete('/dbs/{dbId}/backup-histories/{backupHistoryId}'),
// 获取数据库备份列表
// 获取数据库恢复列表
getDbRestores: Api.newGet('/dbs/{dbId}/restores'),
createDbRestore: Api.newPost('/dbs/{dbId}/restores'),
deleteDbRestore: Api.newDelete('/dbs/{dbId}/restores/{restoreId}'),
getDbNamesWithoutRestore: Api.newGet('/dbs/{dbId}/db-names-without-restore'),
enableDbRestore: Api.newPut('/dbs/{dbId}/restores/{restoreId}/enable'),
disableDbRestore: Api.newPut('/dbs/{dbId}/restores/{restoreId}/disable'),
@@ -78,4 +82,17 @@ export const dbApi = {
runDatasyncTask: Api.newPost('/datasync/tasks/{taskId}/run'),
stopDatasyncTask: Api.newPost('/datasync/tasks/{taskId}/stop'),
datasyncLogs: Api.newGet('/datasync/tasks/{taskId}/logs'),
// 数据库迁移相关
dbTransferTasks: Api.newGet('/dbTransfer'),
saveDbTransferTask: Api.newPost('/dbTransfer/save'),
deleteDbTransferTask: Api.newDelete('/dbTransfer/{taskId}/del'),
runDbTransferTask: Api.newPost('/dbTransfer/{taskId}/run'),
stopDbTransferTask: Api.newPost('/dbTransfer/{taskId}/stop'),
dbTransferTaskLogs: Api.newGet('/dbTransfer/{taskId}/logs'),
};
export const dbSqlExecApi = {
// 根据业务key获取sql执行信息
getSqlExecByBizKey: Api.newGet('/dbs/sql-execs'),
};

View File

@@ -6,6 +6,9 @@
:resource-type="TagResourceTypeEnum.Db.value"
:tag-path-node-type="NodeTypeTagPath"
>
<template #iconPrefix>
<SvgIcon v-if="dbType && getDbDialect(dbType)" :name="getDbDialect(dbType).getInfo().icon" :size="18" />
</template>
<template #prefix="{ data }">
<SvgIcon v-if="data.type.value == SqlExecNodeType.DbInst" :name="getDbDialect(data.params.type).getInfo().icon" :size="18" />
<SvgIcon v-if="data.icon" :name="data.icon.name" :color="data.icon.color" />
@@ -19,7 +22,7 @@ import { NodeType, TagTreeNode } from '@/views/ops/component/tag';
import { dbApi } from '@/views/ops/db/api';
import { sleep } from '@/common/utils/loading';
import SvgIcon from '@/components/svgIcon/index.vue';
import { DbType, getDbDialect } from '@/views/ops/db/dialect';
import { getDbDialect, noSchemaTypes } from '@/views/ops/db/dialect';
import TagTreeResourceSelect from '../../component/TagTreeResourceSelect.vue';
import { computed } from 'vue';
@@ -27,15 +30,21 @@ const props = defineProps({
dbId: {
type: Number,
},
instName: {
type: String,
},
dbName: {
type: String,
},
tagPath: {
type: String,
},
dbType: {
type: String,
},
});
const emits = defineEmits(['update:dbName', 'update:tagPath', 'update:dbId', 'selectDb']);
const emits = defineEmits(['update:dbName', 'update:tagPath', 'update:instName', 'update:dbId', 'update:dbType', 'selectDb']);
/**
* 树节点类型
@@ -53,7 +62,7 @@ class SqlExecNodeType {
const selectNode = computed({
get: () => {
return props.dbName ? `${props.tagPath} - ${props.dbId} - ${props.dbName}` : '';
return props.dbName ? `${props.tagPath} > ${props.instName} > ${props.dbName}` : '';
},
set: () => {
//
@@ -87,8 +96,8 @@ const NodeTypeTagPath = new NodeType(TagTreeNode.TagPath).withLoadNodesFunc(asyn
});
/** mysql类型的数据库没有schema层 */
const mysqlType = (type: string) => {
return type === DbType.mysql;
const noSchemaType = (type: string) => {
return noSchemaTypes.includes(type);
};
// 数据库实例节点类型
@@ -96,7 +105,7 @@ const NodeTypeDbInst = new NodeType(SqlExecNodeType.DbInst).withLoadNodesFunc((p
const params = parentNode.params;
const dbs = params.database.split(' ')?.sort();
let fn: NodeType;
if (mysqlType(params.type)) {
if (noSchemaType(params.type)) {
fn = MysqlNodeTypes;
} else {
fn = PgNodeTypes;
@@ -114,7 +123,7 @@ const NodeTypeDbInst = new NodeType(SqlExecNodeType.DbInst).withLoadNodesFunc((p
db: x,
})
.withIcon(DbIcon);
if (mysqlType(params.type)) {
if (noSchemaType(params.type)) {
tagTreeNode.isLeaf = true;
}
return tagTreeNode;
@@ -148,8 +157,10 @@ const changeNode = (nodeData: TagTreeNode) => {
const params = nodeData.params;
// postgres
emits('update:dbName', params.db);
emits('update:instName', params.name);
emits('update:dbId', params.id);
emits('update:tagPath', params.tagPath);
emits('update:dbType', params.type);
emits('selectDb', params);
};
</script>

View File

@@ -113,7 +113,7 @@
:loading="dt.loading"
:abort-fn="dt.abortFn"
:height="tableDataHeight"
empty-text="tips: select *开头的单表查询或点击表名默认查询的数据,可双击数据在线修改"
:empty-text="state.tableDataEmptyText"
@change-updated-field="changeUpdatedField($event, dt)"
@data-delete="onDeleteData($event, dt)"
></db-table-data>
@@ -128,12 +128,12 @@
</template>
<script lang="ts" setup>
import { h, nextTick, onMounted, reactive, toRefs, ref, unref } from 'vue';
import { h, nextTick, onMounted, reactive, ref, toRefs, unref } from 'vue';
import { getToken } from '@/common/utils/storage';
import { notBlank } from '@/common/assert';
import { format as sqlFormatter } from 'sql-formatter';
import config from '@/common/config';
import { ElMessage, ElMessageBox } from 'element-plus';
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { editor } from 'monaco-editor';
@@ -146,11 +146,9 @@ import MonacoEditor from '@/components/monaco/MonacoEditor.vue';
import { joinClientParams } from '@/common/request';
import { buildProgressProps } from '@/components/progress-notify/progress-notify';
import ProgressNotify from '@/components/progress-notify/progress-notify.vue';
import { ElNotification } from 'element-plus';
import syssocket from '@/common/syssocket';
import SvgIcon from '@/components/svgIcon/index.vue';
import { getDbDialect } from '../../dialect';
import { Splitpanes, Pane } from 'splitpanes';
import { Pane, Splitpanes } from 'splitpanes';
const emits = defineEmits(['saveSqlSuccess']);
@@ -223,6 +221,7 @@ const state = reactive({
activeTab: 1,
editorHeight: '500',
tableDataHeight: '250px',
tableDataEmptyText: 'tips: select *开头的单表查询或点击表名默认查询的数据,可双击数据在线修改',
});
const { tableDataHeight } = toRefs(state);
@@ -297,29 +296,37 @@ const onRunSql = async (newTab = false) => {
notBlank(sql && sql.trim(), '请选中需要执行的sql');
// 去除字符串前的空格、换行等
sql = sql.replace(/(^\s*)/g, '');
let execRemark = '';
let canRun = true;
if (
sql.startsWith('update') ||
sql.startsWith('UPDATE') ||
sql.startsWith('INSERT') ||
sql.startsWith('insert') ||
sql.startsWith('DELETE') ||
sql.startsWith('delete')
) {
// 简单截取前十个字符
const sqlPrefix = sql.slice(0, 10).toLowerCase();
const nonQuery =
sqlPrefix.startsWith('update') ||
sqlPrefix.startsWith('insert') ||
sqlPrefix.startsWith('delete') ||
sqlPrefix.startsWith('alert') ||
sqlPrefix.startsWith('drop') ||
sqlPrefix.startsWith('create');
// 启用工单审批
if (nonQuery && getNowDbInst().flowProcdef) {
try {
getNowDbInst().promptExeSql(props.dbName, sql, null, () => {
ElMessage.success('工单提交成功');
});
} catch (e) {
ElMessage.success('工单提交失败');
}
return;
}
let execRemark;
if (nonQuery) {
const res: any = await ElMessageBox.prompt('请输入备注', 'Tip', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPattern: /^[\s\S]*.*[^\s][\s\S]*$/,
inputErrorMessage: '请输入执行该sql的备注信息',
inputErrorMessage: '输入执行该sql的备注信息',
});
execRemark = res.value;
if (!execRemark) {
canRun = false;
}
}
if (!canRun) {
return;
}
let execRes: ExecResTab;
@@ -355,8 +362,8 @@ const onRunSql = async (newTab = false) => {
await execute();
const colAndData: any = data.value;
if (!colAndData.res || colAndData.res.length === 0) {
ElMessage.warning('未查询到结果集');
if (colAndData.res.length == 0) {
state.tableDataEmptyText = '查无数据';
}
// 要实时响应,故需要用索引改变数据才生效
@@ -453,7 +460,7 @@ const formatSql = () => {
return;
}
const formatDialect = getDbDialect(getNowDbInst().type).getInfo().formatSqlDialect;
const formatDialect: any = getNowDbInst().getDialect().getInfo().formatSqlDialect;
let sql = monacoEditor.getModel()?.getValueInRange(selection);
// 有选中sql则格式化并替换选中sql, 否则格式化编辑器所有内容

View File

@@ -1,4 +1,4 @@
import { h, render, VNode } from 'vue';
import { h, render } from 'vue';
import SqlExecDialog from './SqlExecDialog.vue';
export type SqlExecProps = {
@@ -6,31 +6,26 @@ export type SqlExecProps = {
dbId: number;
db: string;
dbType?: string;
flowProcdef?: any;
runSuccessCallback?: Function;
cancelCallback?: Function;
};
const boxId = 'sql-exec-dialog-id';
let boxInstance: VNode;
const SqlExecBox = (props: SqlExecProps): void => {
if (!boxInstance) {
const container = document.createElement('div');
container.id = boxId;
// 创建 虚拟dom
boxInstance = h(SqlExecDialog);
// 将虚拟dom渲染到 container dom 上
render(boxInstance, container);
// 最后将 container 追加到 body 上
document.body.appendChild(container);
}
const boxVue = boxInstance.component;
if (boxVue) {
// 调用open方法显示弹框注意不能使用boxVue.ctx来调用组件函数build打包后ctx会获取不到
boxVue.exposed?.open(props);
}
const propsCancelFn = props.cancelCallback;
// 包装取消回调函数,新增销毁组件代码
props.cancelCallback = () => {
propsCancelFn && propsCancelFn();
setTimeout(() => {
// 销毁组件
render(null, document.body);
}, 500);
};
const vnode = h(SqlExecDialog, {
...props,
visible: true,
});
render(vnode, document.body);
};
export default SqlExecBox;

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