44 Commits

Author SHA1 Message Date
meilin.huang
c4cb4234fd fix: 问题修复 2024-05-16 17:26:32 +08:00
meilin.huang
89e12678eb refactor: 引入dayjs、新增refreshToken无感刷新、团队新增有效期、数据库等问题修复 2024-05-13 19:55:43 +08:00
meilin.huang
137ebb8e9e fix: 数据库表新增数据表单全必填问题修复等 2024-05-11 12:09:55 +08:00
meilin.huang
05625bd8c1 feat: 1.8.3 2024-05-10 19:59:49 +08:00
meilin.huang
4afeac5fdd refactor: 代码优化与数据库表列显示配置优化 2024-05-09 21:29:34 +08:00
meilin.huang
1d0e91f1af refactor: 机器计划任务与流程定义关联至标签 2024-05-08 21:04:25 +08:00
Coder慌
cf5111a325 !118 修复空数组分隔异常
Merge pull request !118 from 蒋小小/N/A
2024-05-07 11:59:12 +00:00
meilin.huang
78957a8ebd refactor: base.repo与app重构优化 2024-05-05 14:53:30 +08:00
meilin.huang
4ed892a656 feat: 文档更新与sqlite文件更新 2024-04-29 17:09:41 +08:00
meilin.huang
3486b07003 fix: 修复机器列表查询与放开vnc 2024-04-29 12:50:49 +08:00
meilin.huang
a5cd7caf19 refactor: base.repo与base.app精简优化 2024-04-29 12:29:56 +08:00
meilin.huang
f2c7ef78c0 refactor: 精简base.repo与base.app等 2024-04-28 23:45:57 +08:00
meilin.huang
653953ee76 feat: 机器新增命令过滤配置、首页功能完善(操作记录与快捷操作) 2024-04-27 01:35:21 +08:00
meilin.huang
a831614d5a fix: sql脚本问题修复等 2024-04-23 11:35:45 +08:00
meilin.huang
ebe73e2f19 feat: 标签支持拖拽移动与机器支持执行命令查看 2024-04-21 19:35:58 +08:00
蒋小小
29fd5a25d2 修复空数组分隔异常
Signed-off-by: 蒋小小 <bwcx_jzy@163.com>
2024-04-20 17:33:19 +00:00
zongyangleo
44805ce580 !116 fix: 新版本问题修复
* fix: 新版本问题修复
2024-04-19 11:27:29 +00:00
meilin.huang
2a6d620830 fix: 问题修复 2024-04-18 20:50:14 +08:00
meilin.huang
01d3e1ad28 refactor: 数据库实例与凭证关联至标签&其他问题修复重构等 2024-04-17 21:28:28 +08:00
meilin.huang
f4162c38db fix: 问题修复与redis密码迁移至凭证 2024-04-13 17:01:12 +08:00
zongyangleo
1a4626c24d !115 fix: 新版本问题修复
* fix: 新版本问题修复
2024-04-12 14:39:08 +00:00
meilin.huang
d6eb9683d1 fix: 新版本问题修复 2024-04-12 20:30:28 +08:00
meilin.huang
e2b524dadb feat: release1.8.0 2024-04-12 17:07:28 +08:00
zongyangleo
8998a21626 !114 feat:rdp优化,mssql迁移优化,term支持trzsz
* fix: 合并代码
* refactor: rdp优化,mssql迁移优化,term支持trzsz
2024-04-12 07:53:42 +00:00
meilin.huang
abc015aec0 refactor: 数据库授权凭证迁移 2024-04-12 13:24:20 +08:00
meilin.huang
4ef8d27b1e refactor: 授权凭证优化 2024-04-10 23:17:20 +08:00
meilin.huang
40b6e603fc reafctor: 团队管理与授权凭证优化 2024-04-10 13:04:31 +08:00
meilin.huang
21498584b1 refactor: 初步提交全局授权凭证-资源多账号改造 2024-04-09 12:55:51 +08:00
meilin.huang
408bac09a1 refactor: 标签资源重构 2024-04-06 18:19:17 +08:00
zongyangleo
582d879a77 !112 feat: 机器管理支持ssh+rdp连接win服务器
* feat: rdp 文件管理
* feat: 机器管理支持ssh+rdp连接win服务器
2024-04-06 04:03:38 +00:00
meilin.huang
38ff5152e0 refactor: dbms优化 2024-03-29 21:40:26 +08:00
meilin.huang
d1d372e1bf feat: 数据迁移新增实时日志&数据库游标遍历查询问题修复 2024-03-28 22:20:39 +08:00
Coder慌
5e4793433b !111 refactor:获取表索引,默认过滤主键索引
Merge pull request !111 from zongyangleo/dev_0327_fix
2024-03-27 13:06:45 +00:00
zongyangleo
54ad19f97e refactor:获取表索引,默认过滤主键索引 2024-03-27 08:26:12 +08:00
meilin.huang
fc166650b3 refactor: dbm重构等 2024-03-26 21:46:03 +08:00
zongyangleo
2acc295259 !110 feat: 支持各源数据库导出sql,数据库迁移部分bug修复
* feat: 各源数据库导出
* fix: 数据库迁移 bug修复
2024-03-26 09:05:28 +00:00
meilin.huang
4b3ed1310d refactor: dbms 2024-03-21 20:28:24 +08:00
meilin.huang
b2cfd1517c refactor: dbms与标签管理优化 2024-03-21 17:15:52 +08:00
zongyangleo
b13d27ccd6 !109 refactor:ddl生成方式重构,数据类型和长度重构,所有数据库迁移调试
* feat:同步sqlite全量sql
* refactor:ddl生成方式重构,数据类型和长度重构,所有数据库迁移调试
2024-03-21 03:35:18 +00:00
meilin.huang
68e0088016 refactor: dbms优化 2024-03-18 12:25:40 +08:00
zongyangleo
bd1e83989d !108 feat:支持不同源数据库迁移
* feat:支持不同源数据库迁移
2024-03-15 09:01:51 +00:00
meilin.huang
263dfa6be7 refactor: dbm包重构 2024-03-15 13:31:53 +08:00
meilin.huang
eb55f93864 refactor: dbm包重构 2024-03-11 20:04:20 +08:00
meilin.huang
8589105e44 feat: oracle支持服务名、数据库执行超时时间配置等 2024-03-07 17:26:11 +08:00
443 changed files with 34508 additions and 9510 deletions

View File

@@ -22,7 +22,7 @@
### 介绍 ### 介绍
web 版 **linux(终端[终端回放] 文件 脚本 进程 计划任务)、数据库mysql postgres oracle sqlserver 达梦 高斯 sqlite、redis(单机 哨兵 集群)、mongo 等集工单流程审批于一体的统一管理操作平台** web 版 **linux(终端[终端回放、命令过滤] 文件 脚本 进程 计划任务)、数据库mysql postgres oracle sqlserver 达梦 高斯 sqlite数据同步 数据迁移、redis(单机 哨兵 集群)、mongo 等集工单流程审批于一体的统一管理操作平台**
### 开发语言与主要框架 ### 开发语言与主要框架
@@ -45,56 +45,61 @@ http://go.mayfly.run
### 系统核心功能截图 ### 系统核心功能截图
##### 记录操作记录 #### 首页
![记录操作记录](https://objs.gitee.io/mayfly-go-docs/home/log.jpg "屏幕截图.png") ![首页](https://foruda.gitee.com/images/1714378104294194769/149fd257_1240250.png "屏幕截图")
#### 机器操作 #### 机器操作
##### 状态查看 ##### 状态查看
![状态查看](https://objs.gitee.io/mayfly-go-docs/home/machine-status.jpg "屏幕截图.png") ![机器状态查看](https://foruda.gitee.com/images/1714378556642584686/93c46ec0_1240250.png "屏幕截图")
##### ssh 终端 ##### ssh 终端
![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://foruda.gitee.com/images/1714378417206086701/74a188d8_1240250.png "屏幕截图")
![文件操作](https://objs.gitee.io/mayfly-go-docs/home/file-content-update.jpg "屏幕截图.png")
![文件查看](https://foruda.gitee.com/images/1714378482611638688/7753faf6_1240250.png "屏幕截图")
#### 数据库操作 #### 数据库操作
##### sql 编辑器 ##### 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 操作 #### 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 操作 #### 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 **其他更多功能&操作指南可查看在线文档**: https://www.yuque.com/may-fly/mayfly-go

View File

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

View File

@@ -10,19 +10,19 @@
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"@vueuse/core": "^10.8.0", "@vueuse/core": "^10.9.0",
"asciinema-player": "^3.7.0", "asciinema-player": "^3.7.1",
"axios": "^1.6.2", "axios": "^1.6.2",
"clipboard": "^2.0.11", "clipboard": "^2.0.11",
"countup.js": "^2.8.0",
"cropperjs": "^1.6.1", "cropperjs": "^1.6.1",
"dayjs": "^1.11.11",
"echarts": "^5.5.0", "echarts": "^5.5.0",
"element-plus": "^2.6.0", "element-plus": "^2.7.3",
"js-base64": "^3.7.5", "js-base64": "^3.7.7",
"jsencrypt": "^3.3.2", "jsencrypt": "^3.3.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"monaco-editor": "^0.46.0", "monaco-editor": "^0.48.0",
"monaco-sql-languages": "^0.11.0", "monaco-sql-languages": "^0.11.0",
"monaco-themes": "^0.4.4", "monaco-themes": "^0.4.4",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
@@ -32,9 +32,10 @@
"sortablejs": "^1.15.2", "sortablejs": "^1.15.2",
"splitpanes": "^3.1.5", "splitpanes": "^3.1.5",
"sql-formatter": "^15.0.2", "sql-formatter": "^15.0.2",
"trzsz": "^1.1.5",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"vue": "^3.4.21", "vue": "^3.4.27",
"vue-router": "^4.3.0", "vue-router": "^4.3.2",
"xterm": "^5.3.0", "xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0", "xterm-addon-fit": "^0.8.0",
"xterm-addon-search": "^0.13.0", "xterm-addon-search": "^0.13.0",
@@ -48,15 +49,15 @@
"@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4", "@typescript-eslint/parser": "^6.7.4",
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
"@vue/compiler-sfc": "^3.4.21", "@vue/compiler-sfc": "^3.4.27",
"code-inspector-plugin": "^0.4.5", "code-inspector-plugin": "^0.4.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"eslint": "^8.35.0", "eslint": "^8.35.0",
"eslint-plugin-vue": "^9.21.1", "eslint-plugin-vue": "^9.25.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"sass": "^1.69.0", "sass": "^1.77.1",
"typescript": "^5.3.2", "typescript": "^5.4.5",
"vite": "^5.1.4", "vite": "^5.2.11",
"vue-eslint-parser": "^9.4.2" "vue-eslint-parser": "^9.4.2"
}, },
"browserslist": [ "browserslist": [

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -1,9 +1,24 @@
import EnumValue from './Enum'; 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 = { export const TagResourceTypeEnum = {
Machine: EnumValue.of(1, '机器'), AuthCert: EnumValue.of(-2, '公共凭证').setExtra({ icon: 'Ticket' }),
Db: EnumValue.of(2, '数据库'), Tag: EnumValue.of(-1, '标签').setExtra({ icon: 'CollectionTag' }),
Redis: EnumValue.of(3, 'redis'),
Mongo: EnumValue.of(4, 'mongo'), 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`, baseWsUrl: `${(window as any).globalConfig.BaseWsUrl || `${location.protocol == 'https:' ? 'wss:' : 'ws:'}//${getBaseApiUrl()}`}/api`,
// 系统版本 // 系统版本
version: 'v1.7.4', version: 'v1.8.5',
}; };
export default config; export default config;

View File

@@ -2,6 +2,7 @@ import request from './request';
export default { export default {
login: (param: any) => request.post('/auth/accounts/login', param), 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), otpVerify: (param: any) => request.post('/auth/accounts/otp-verify', param),
getPublicKey: () => request.get('/common/public-key'), getPublicKey: () => request.get('/common/public-key'),
getConfigValue: (params: any) => request.get('/sys/configs/value', params), getConfigValue: (params: any) => request.get('/sys/configs/value', params),

View File

@@ -1,4 +1,9 @@
export const AccountUsernamePattern = { export const AccountUsernamePattern = {
pattern: /^[a-zA-Z0-9_]{5,20}$/g, 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, PARAM_ERROR = 405,
SERVER_ERROR = 500, SERVER_ERROR = 500,
NO_PERMISSION = 501, NO_PERMISSION = 501,
ACCESS_TOKEN_INVALID = 502, // accessToken失效
} }
export const baseUrl: string = config.baseApiUrl; 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

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

View File

@@ -1,4 +1,10 @@
// 根据对象访问路径,获取对应的值 /**
* 根据对象访问路径,获取对应的值
*
* @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) { export function getValueByPath(obj: any, path: string) {
const keys = path.split('.'); const keys = path.split('.');
let result = obj; let result = obj;

View File

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

View File

@@ -40,7 +40,7 @@ onMounted(() => {
}); });
const convert = (value: any) => { 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) { if (!enumValue) {
state.enumLabel = '-'; state.enumLabel = '-';
state.type = 'danger'; state.type = 'danger';

View File

@@ -159,7 +159,7 @@
@current-change="handlePageNumChange" @current-change="handlePageNumChange"
@size-change="handlePageSizeChange" @size-change="handlePageSizeChange"
style="text-align: right" style="text-align: right"
layout="prev, pager, next, total, sizes, jumper" layout="prev, pager, next, total, sizes"
:total="total" :total="total"
v-model:current-page="queryForm.pageNum" v-model:current-page="queryForm.pageNum"
v-model:page-size="queryForm.pageSize" v-model:page-size="queryForm.pageSize"
@@ -185,7 +185,7 @@ import SvgIcon from '@/components/svgIcon/index.vue';
import { usePageTable } from '@/hooks/usePageTable'; import { usePageTable } from '@/hooks/usePageTable';
import { ElTable } from 'element-plus'; import { ElTable } from 'element-plus';
const emit = defineEmits(['update:queryForm', 'update:selectionData', 'pageChange']); const emit = defineEmits(['update:selectionData', 'pageChange']);
export interface PageTableProps { export interface PageTableProps {
size?: string; size?: string;

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,7 @@ import { debounce } from 'lodash';
import { TerminalStatus } from './common'; import { TerminalStatus } from './common';
import { useEventListener } from '@vueuse/core'; import { useEventListener } from '@vueuse/core';
import themes from './themes'; import themes from './themes';
import { TrzszFilter } from 'trzsz';
const props = defineProps({ const props = defineProps({
// mounted时是否执行init方法 // mounted时是否执行init方法
@@ -101,7 +102,6 @@ function init() {
} }
nextTick(() => { nextTick(() => {
initTerm(); initTerm();
initSocket();
}); });
} }
@@ -124,16 +124,12 @@ function initTerm() {
state.addon.fit = fitAddon; state.addon.fit = fitAddon;
term.loadAddon(fitAddon); term.loadAddon(fitAddon);
fitTerminal(); fitTerminal();
// 注册窗口大小监听器
useEventListener('resize', debounce(fitTerminal, 400));
// 注册搜索组件 initSocket();
const searchAddon = new SearchAddon(); // 注册其他插件
state.addon.search = searchAddon; loadAddon();
term.loadAddon(searchAddon);
// 注册 url link组件
const weblinks = new WebLinksAddon();
state.addon.weblinks = weblinks;
term.loadAddon(weblinks);
// 注册自定义快捷键 // 注册自定义快捷键
term.attachCustomKeyEventHandler((event: KeyboardEvent) => { term.attachCustomKeyEventHandler((event: KeyboardEvent) => {
@@ -148,22 +144,16 @@ function initTerm() {
} }
function initSocket() { function initSocket() {
if (props.socketUrl) { if (!props.socketUrl) {
socket = new WebSocket(`${props.socketUrl}&rows=${term?.rows}&cols=${term?.cols}`); return;
} }
socket = new WebSocket(`${props.socketUrl}&rows=${term?.rows}&cols=${term?.cols}`);
// 监听socket连接 // 监听socket连接
socket.onopen = () => { socket.onopen = () => {
// 注册心跳 // 注册心跳
pingInterval = setInterval(sendPing, 15000); pingInterval = setInterval(sendPing, 15000);
state.status = TerminalStatus.Connected; state.status = TerminalStatus.Connected;
// 注册 terminal 事件
term.onResize((event) => sendResize(event.cols, event.rows));
term.onData((event) => sendCmd(event));
// // 注册窗口大小监听器
useEventListener('resize', debounce(fitTerminal, 400));
focus(); focus();
// 如果有初始要执行的命令,则发送执行命令 // 如果有初始要执行的命令,则发送执行命令
@@ -183,14 +173,63 @@ function initSocket() {
console.log('terminal socket close...', e.reason); console.log('terminal socket close...', e.reason);
state.status = TerminalStatus.Disconnected; state.status = TerminalStatus.Disconnected;
}; };
// 监听socket消息
socket.onmessage = (msg: any) => {
// msg.data是真正后端返回的数据
term.write(msg.data);
};
} }
function loadAddon() {
// 注册搜索组件
const searchAddon = new SearchAddon();
state.addon.search = searchAddon;
term.loadAddon(searchAddon);
// 注册 url link组件
const weblinks = new WebLinksAddon();
state.addon.weblinks = weblinks;
term.loadAddon(weblinks);
// 注册 trzsz
// initialize trzsz filter
const trzsz = new TrzszFilter({
// write the server output to the terminal
writeToTerminal: (data: any) => term.write(typeof data === 'string' ? data : new Uint8Array(data)),
// send the user input to the server
sendToServer: sendCmd,
// the terminal columns
terminalColumns: term.cols,
// there is a windows shell
isWindowsShell: false,
});
// let trzsz process the server output
socket?.addEventListener('message', (e) => trzsz.processServerOutput(e.data));
// let trzsz process the user input
term.onData((data) => trzsz.processTerminalInput(data));
term.onBinary((data) => trzsz.processBinaryInput(data));
term.onResize((size) => {
sendResize(size.cols, size.rows);
// tell trzsz the terminal columns has been changed
trzsz.setTerminalColumns(size.cols);
});
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 getTerminalTheme = () => {
const terminalTheme = themeConfig.value.terminalTheme; const terminalTheme = themeConfig.value.terminalTheme;
// 如果不是自定义主题,则返回内置主题 // 如果不是自定义主题,则返回内置主题
@@ -229,7 +268,7 @@ enum MsgType {
} }
const send = (msg: any) => { const send = (msg: any) => {
state.status == TerminalStatus.Connected && socket.send(msg); state.status == TerminalStatus.Connected && socket?.send(msg);
}; };
const sendResize = (cols: number, rows: number) => { const sendResize = (cols: number, rows: number) => {
@@ -266,7 +305,7 @@ const getStatus = (): TerminalStatus => {
return state.status; return state.status;
}; };
defineExpose({ init, fitTerminal, focus, clear, close, getStatus, sendResize }); defineExpose({ init, fitTerminal, focus, clear, close, getStatus, sendResize, write2Term, writeln2Term });
</script> </script>
<style lang="scss"> <style lang="scss">
#terminal-body { #terminal-body {
@@ -276,9 +315,9 @@ defineExpose({ init, fitTerminal, focus, clear, close, getStatus, sendResize });
width: 100%; width: 100%;
height: 100%; height: 100%;
.xterm .xterm-viewport { // .xterm .xterm-viewport {
overflow-y: hidden; // overflow-y: hidden;
} // }
} }
} }
</style> </style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<el-drawer @open="initSort" :title="title" v-model="visible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false"> <el-drawer @open="initSort" :title="title" v-model="visible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<template #header> <template #header>
<DrawerHeader :header="title" :back="cancel" /> <DrawerHeader :header="title" :back="cancel" />
</template> </template>
@@ -21,6 +21,10 @@
<el-input v-model.trim="form.remark" placeholder="备注" auto-complete="off" clearable></el-input> <el-input v-model.trim="form.remark" placeholder="备注" auto-complete="off" clearable></el-input>
</el-form-item> </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-divider content-position="left">审批节点</el-divider>
<el-table ref="taskTableRef" :data="tasks" row-key="taskKey" stripe style="width: 100%"> <el-table ref="taskTableRef" :data="tasks" row-key="taskKey" stripe style="width: 100%">
@@ -70,6 +74,8 @@ import AccountSelectFormItem from '@/views/system/account/components/AccountSele
import Sortable from 'sortablejs'; import Sortable from 'sortablejs';
import { randomUuid } from '../../common/utils/string'; import { randomUuid } from '../../common/utils/string';
import { ProcdefStatus } from './enums'; import { ProcdefStatus } from './enums';
import TagTreeCheck from '../ops/component/TagTreeCheck.vue';
import { TagResourceTypeEnum } from '@/common/commonEnum';
const props = defineProps({ const props = defineProps({
data: { data: {
@@ -115,6 +121,7 @@ const state = reactive({
remark: null, remark: null,
// 流程的审批节点任务 // 流程的审批节点任务
tasks: '', tasks: '',
codePaths: [],
}, },
sortable: '' as any, sortable: '' as any,
}); });
@@ -126,6 +133,7 @@ const { isFetching: saveBtnLoading, execute: saveFlowDefExec } = procdefApi.save
watch(props, (newValue: any) => { watch(props, (newValue: any) => {
if (newValue.data) { if (newValue.data) {
state.form = { ...newValue.data }; state.form = { ...newValue.data };
state.form.codePaths = newValue.data.tags?.map((tag: any) => tag.codePath);
const tasks = JSON.parse(state.form.tasks); const tasks = JSON.parse(state.form.tasks);
tasks.forEach((t: any) => { tasks.forEach((t: any) => {
t.userId = Number.parseInt(t.userId); t.userId = Number.parseInt(t.userId);
@@ -160,25 +168,26 @@ const deleteTask = (idx: any) => {
}; };
const btnOk = async () => { const btnOk = async () => {
formRef.value.validate(async (valid: boolean) => { try {
if (!valid) { await formRef.value.validate();
ElMessage.error('表单填写有误'); } catch (e: any) {
return false; ElMessage.error('请正确填写信息');
} return false;
const checkRes = checkTasks(); }
if (checkRes.err) {
ElMessage.error(checkRes.err);
return false;
}
state.form.tasks = JSON.stringify(checkRes.tasks); const checkRes = checkTasks();
await saveFlowDefExec(); if (checkRes.err) {
ElMessage.success('操作成功'); ElMessage.error(checkRes.err);
emit('val-change', state.form); return false;
//重置表单域 }
formRef.value.resetFields();
state.form = {} as any; 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 = () => { const checkTasks = () => {

View File

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

View File

@@ -17,11 +17,11 @@
<AccountInfo :account-id="procinst.creatorId" :username="procinst.creator" /> <AccountInfo :account-id="procinst.creatorId" :username="procinst.creator" />
<!-- {{ procinst.creator }} --> <!-- {{ procinst.creator }} -->
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="发起时间">{{ dateFormat(procinst.createTime) }}</el-descriptions-item> <el-descriptions-item label="发起时间">{{ formatDate(procinst.createTime) }}</el-descriptions-item>
<div v-if="procinst.duration"> <div v-if="procinst.duration">
<el-descriptions-item label="持续时间">{{ formatTime(procinst.duration) }}</el-descriptions-item> <el-descriptions-item label="持续时间">{{ formatTime(procinst.duration) }}</el-descriptions-item>
<el-descriptions-item label="结束时间">{{ dateFormat(procinst.endTime) }}</el-descriptions-item> <el-descriptions-item label="结束时间">{{ formatDate(procinst.endTime) }}</el-descriptions-item>
</div> </div>
<el-descriptions-item label="流程状态"> <el-descriptions-item label="流程状态">
@@ -86,11 +86,11 @@ import { procinstApi } from './api';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue'; import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue';
import { FlowBizType, ProcinstBizStatus, ProcinstTaskStatus, ProcinstStatus } from './enums'; import { FlowBizType, ProcinstBizStatus, ProcinstTaskStatus, ProcinstStatus } from './enums';
import { dateFormat } from '@/common/utils/date';
import ProcdefTasks from './components/ProcdefTasks.vue'; import ProcdefTasks from './components/ProcdefTasks.vue';
import { formatTime } from '@/common/utils/format'; import { formatTime } from '@/common/utils/format';
import EnumTag from '@/components/enumtag/EnumTag.vue'; import EnumTag from '@/components/enumtag/EnumTag.vue';
import AccountInfo from '@/views/system/account/components/AccountInfo.vue'; import AccountInfo from '@/views/system/account/components/AccountInfo.vue';
import { formatDate } from '@/common/utils/format';
const DbSqlExecBiz = defineAsyncComponent(() => import('./flowbiz/DbSqlExecBiz.vue')); const DbSqlExecBiz = defineAsyncComponent(() => import('./flowbiz/DbSqlExecBiz.vue'));
const RedisRunWriteCmdBiz = defineAsyncComponent(() => import('./flowbiz/RedisRunWriteCmdBiz.vue')); const RedisRunWriteCmdBiz = defineAsyncComponent(() => import('./flowbiz/RedisRunWriteCmdBiz.vue'));

View File

@@ -50,11 +50,17 @@ import { FlowBizType, ProcinstBizStatus, ProcinstStatus } from './enums';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { formatTime } from '@/common/utils/format'; import { formatTime } from '@/common/utils/format';
const searchItems = [SearchItem.select('status', '流程状态').withEnum(ProcinstStatus), SearchItem.select('bizType', '业务类型').withEnum(FlowBizType)]; const searchItems = [
SearchItem.select('status', '流程状态').withEnum(ProcinstStatus),
SearchItem.select('bizType', '业务类型').withEnum(FlowBizType),
SearchItem.input('bizKey', '业务key'),
];
const columns = [ const columns = [
TableColumn.new('bizType', '业务').typeTag(FlowBizType), TableColumn.new('bizType', '业务').typeTag(FlowBizType),
TableColumn.new('remark', '备注'), TableColumn.new('remark', '备注'),
TableColumn.new('creator', '发起人'), TableColumn.new('creator', '发起人'),
TableColumn.new('bizKey', '业务key'),
TableColumn.new('procdefName', '流程名'), TableColumn.new('procdefName', '流程名'),
TableColumn.new('status', '流程状态').typeTag(ProcinstStatus), TableColumn.new('status', '流程状态').typeTag(ProcinstStatus),
TableColumn.new('bizStatus', '业务状态').typeTag(ProcinstBizStatus), TableColumn.new('bizStatus', '业务状态').typeTag(ProcinstBizStatus),

View File

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

View File

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

View File

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

View File

@@ -1,137 +1,541 @@
<template> <template>
<div class="home-container"> <div class="home-container personal">
<el-row :gutter="15"> <el-row :gutter="15">
<el-col :sm="6" class="mb15"> <!-- 个人信息 -->
<div @click="toPage({ id: 'personal' })" class="home-card-item home-card-first"> <el-col :xs="24" :sm="16">
<div class="flex-margin flex"> <el-card shadow="hover" header="个人信息">
<img :src="userInfo.photo" /> <div class="personal-user">
<div class="home-card-first-right ml15"> <div class="personal-user-left">
<div class="flex-margin"> <el-upload class="h100 personal-user-left-upload" action="" multiple :limit="1">
<div class="home-card-first-right-title">{{ `${currentTime}, ${userInfo.username}` }}</div> <img :src="userInfo.photo" />
</div> </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> </div>
</div> </el-card>
</el-col> </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"> <el-col :xs="24" :sm="8" class="pl15 personal-info">
<div class="home-card-item-title pb3">{{ v.title }}</div> <el-card shadow="hover">
<div class="home-card-item-title-num pb6" :id="v.id"></div> <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> </div>
<i :class="v.icon" :style="{ color: v.iconColor }"></i> </el-card>
</div>
</el-col> </el-col>
</el-row> </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> </div>
</template> </template>
<script lang="ts" setup> <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 * as echarts from 'echarts';
import { CountUp } from 'countup.js';
import { formatAxis } from '@/common/utils/format'; import { formatAxis } from '@/common/utils/format';
import { indexApi } from './api'; import { indexApi } from './api';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useUserInfo } from '@/store/userInfo'; 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 router = useRouter();
const { userInfo } = storeToRefs(useUserInfo()); const { userInfo } = storeToRefs(useUserInfo());
const state = reactive({ const state = reactive({
topCardItemList: [ accountInfo: {
{ roles: [],
title: 'Linux机器', },
id: 'machineNum', msgs: [],
color: '#F95959', msgDialog: {
visible: false,
query: {
pageSize: 10,
pageNum: 1,
}, },
{ msgs: {
title: '数据库', list: [],
id: 'dbNum', total: null,
color: '#8595F4',
}, },
{ },
title: 'redis', resourceOpTableHeight: 180,
id: 'redisNum', defaultLogSize: 5,
color: '#1abc9c', machine: {
}, num: 0,
{ opLogs: [],
title: 'Mongo', },
id: 'mongoNum', db: {
color: '#FEBB50', 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(() => { const currentTime = computed(() => {
return formatAxis(new Date()); 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 initData = async () => {
indexApi.machineDashbord.request().then((res: any) => { resourceOpLogApi.getAccountResourceOpLogs
nextTick(() => { .request({ resourceType: TagResourceTypeEnum.MachineAuthCert.value, pageSize: state.defaultLogSize })
new CountUp('machineNum', res.machineNum).start(); .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) => { indexApi.dbDashbord.request().then((res: any) => {
nextTick(() => { state.db.num = res.dbNum;
new CountUp('dbNum', res.dbNum).start();
});
}); });
indexApi.redisDashbord.request().then((res: any) => { indexApi.redisDashbord.request().then((res: any) => {
nextTick(() => { state.redis.num = res.redisNum;
new CountUp('redisNum', res.redisNum).start();
});
}); });
indexApi.mongoDashbord.request().then((res: any) => { indexApi.mongoDashbord.request().then((res: any) => {
nextTick(() => { state.mongo.num = res.mongoNum;
new CountUp('mongoNum', res.mongoNum).start();
});
}); });
}; };
const toPage = (item: any) => { const toPage = (item: any, codePath = '') => {
switch (item.id) { let path;
switch (item) {
case 'personal': { case 'personal': {
router.push('/personal'); router.push('/personal');
break; break;
} }
case 'mongoNum': { case 'mongo': {
router.push('/mongo/mongo-data-operation'); useAutoOpenResource().setMongoCodePath(codePath);
path = '/mongo/mongo-data-operation';
break; break;
} }
case 'machineNum': { case 'machine': {
router.push('/machine/machines-op'); useAutoOpenResource().setMachineCodePath(codePath);
path = '/machine/machines-op';
break; break;
} }
case 'dbNum': { case 'db': {
router.push('/dbms/sql-exec'); useAutoOpenResource().setDbCodePath(codePath);
path = '/dbms/sql-exec';
break; break;
} }
case 'redisNum': { case 'redis': {
router.push('/redis/data-operation'); useAutoOpenResource().setRedisCodePath(codePath);
path = '/redis/data-operation';
break; break;
} }
} }
};
// 页面加载时 router.push({ path });
onMounted(() => { };
initNumCountUp();
// initHomeLaboratory();
// initHomeOvertime();
});
</script> </script>
<style scoped lang="scss"> <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 { .home-container {
overflow-x: hidden; overflow-x: hidden;
@@ -182,7 +586,7 @@ onMounted(() => {
} }
.home-card-item-title-num { .home-card-item-title-num {
font-size: 18px; font-size: 2vw;
} }
.home-card-item-tip-num { .home-card-item-tip-num {
@@ -190,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> </style>

View File

@@ -132,7 +132,7 @@ import { nextTick, onMounted, ref, toRefs, reactive, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { initRouter } from '@/router/index'; 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 { formatAxis } from '@/common/utils/format';
import openApi from '@/common/openApi'; import openApi from '@/common/openApi';
import { RsaEncrypt } from '@/common/rsa'; import { RsaEncrypt } from '@/common/rsa';
@@ -279,19 +279,20 @@ const login = () => {
}; };
const otpVerify = async () => { const otpVerify = async () => {
otpFormRef.value.validate(async (valid: boolean) => { try {
if (!valid) { await otpFormRef.value.validate();
return false; } catch (e: any) {
} return false;
try { }
state.loading.otpConfirm = true;
const accessToken = await openApi.otpVerify(state.otpDialog.form); try {
await signInSuccess(accessToken); state.loading.otpConfirm = true;
state.otpDialog.visible = false; const res = await openApi.otpVerify(state.otpDialog.form);
} finally { await signInSuccess(res.token, res.refresh_token);
state.loading.otpConfirm = false; state.otpDialog.visible = false;
} } finally {
}); state.loading.otpConfirm = false;
}
}; };
// 登录 // 登录
@@ -327,22 +328,23 @@ const onSignIn = async () => {
}; };
const updateUserInfo = async () => { const updateUserInfo = async () => {
baseInfoFormRef.value.validate(async (valid: boolean) => { try {
if (!valid) { await baseInfoFormRef.value.validate();
return false; } catch (e: any) {
} return false;
try { }
state.loading.updateUserConfirm = true;
const form = state.baseInfoDialog.form; try {
await personApi.updateAccount.request(state.baseInfoDialog.form); state.loading.updateUserConfirm = true;
state.baseInfoDialog.visible = false; const form = state.baseInfoDialog.form;
useUserInfo().userInfo.username = form.username; await personApi.updateAccount.request(state.baseInfoDialog.form);
useUserInfo().userInfo.name = form.name; state.baseInfoDialog.visible = false;
await toIndex(); useUserInfo().userInfo.username = form.username;
} finally { useUserInfo().userInfo.name = form.name;
state.loading.updateUserConfirm = false; await toIndex();
} } finally {
}); state.loading.updateUserConfirm = false;
}
}; };
const loginResDeal = (loginRes: any) => { const loginResDeal = (loginRes: any) => {
@@ -366,7 +368,7 @@ const loginResDeal = (loginRes: any) => {
const token = loginRes.token; const token = loginRes.token;
// 如果不需要 otp校验则该token即为accessToken否则为otp校验token // 如果不需要 otp校验则该token即为accessToken否则为otp校验token
if (loginRes.otp == -1) { if (loginRes.otp == -1) {
signInSuccess(token); signInSuccess(token, loginRes.refresh_token);
return; return;
} }
@@ -379,12 +381,16 @@ const loginResDeal = (loginRes: any) => {
}; };
// 登录成功后的跳转 // 登录成功后的跳转
const signInSuccess = async (accessToken: string = '') => { const signInSuccess = async (accessToken: string = '', refreshToken = '') => {
if (!accessToken) { if (!accessToken) {
accessToken = getToken(); accessToken = getToken();
} }
if (!refreshToken) {
refreshToken = getRefreshToken();
}
// 存储 token 到浏览器缓存 // 存储 token 到浏览器缓存
saveToken(accessToken); saveToken(accessToken);
saveRefreshToken(refreshToken);
// 初始化路由 // 初始化路由
await initRouter(); await initRouter();
@@ -415,26 +421,27 @@ const toIndex = async () => {
}, 300); }, 300);
}; };
const changePwd = () => { const changePwd = async () => {
changePwdFormRef.value.validate(async (valid: boolean) => { try {
if (!valid) { await changePwdFormRef.value.validate();
return false; } catch (e: any) {
} return false;
try { }
state.loading.changePwd = true;
const form = state.changePwdDialog.form; try {
const changePwdReq: any = { ...form }; state.loading.changePwd = true;
changePwdReq.oldPassword = await RsaEncrypt(form.oldPassword); const form = state.changePwdDialog.form;
changePwdReq.newPassword = await RsaEncrypt(form.newPassword); const changePwdReq: any = { ...form };
await openApi.changePwd(changePwdReq); changePwdReq.oldPassword = await RsaEncrypt(form.oldPassword);
ElMessage.success('密码修改成功, 新密码已填充至登录密码框'); changePwdReq.newPassword = await RsaEncrypt(form.newPassword);
state.loginForm.password = state.changePwdDialog.form.newPassword; await openApi.changePwd(changePwdReq);
state.changePwdDialog.visible = false; ElMessage.success('密码修改成功, 新密码已填充至登录密码框');
getCaptcha(); state.loginForm.password = state.changePwdDialog.form.newPassword;
} finally { state.changePwdDialog.visible = false;
state.loading.changePwd = false; getCaptcha();
} } finally {
}); state.loading.changePwd = false;
}
}; };
const cancelChangePwd = () => { const cancelChangePwd = () => {

View File

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

View File

@@ -0,0 +1,271 @@
<template>
<div class="auth-cert-edit">
<el-dialog :title="props.title" v-model="dialogVisible" :show-close="false" width="500px" :destroy-on-close="true" :close-on-click-modal="false">
<el-form ref="acForm" :model="state.form" label-width="auto" :rules="rules">
<el-form-item prop="type" label="凭证类型" required>
<el-select @change="changeType" v-model="form.type" placeholder="请选择凭证类型">
<el-option
v-for="item in AuthCertTypeEnum"
:key="item.value"
:label="item.label"
:value="item.value"
v-show="!props.disableType?.includes(item.value)"
>
</el-option>
</el-select>
</el-form-item>
<el-form-item prop="ciphertextType" label="密文类型" required>
<el-select v-model="form.ciphertextType" placeholder="请选择密文类型" @change="changeCiphertextType">
<el-option
v-for="item in AuthCertCiphertextTypeEnum"
:key="item.value"
:label="item.label"
:value="item.value"
v-show="!props.disableCiphertextType?.includes(item.value)"
:disabled="item.value == AuthCertCiphertextTypeEnum.Public.value && form.type == AuthCertTypeEnum.Public.value"
>
</el-option>
</el-select>
</el-form-item>
<template v-if="showResourceEdit">
<el-form-item prop="type" label="资源类型" required>
<el-select :disabled="form.id" v-model="form.resourceType" placeholder="请选择资源类型">
<el-option
:key="TagResourceTypeEnum.Machine.value"
:label="TagResourceTypeEnum.Machine.label"
:value="TagResourceTypeEnum.Machine.value"
/>
<el-option :key="TagResourceTypeEnum.Db.value" :label="TagResourceTypeEnum.Db.label" :value="TagResourceTypeEnum.Db.value" />
<el-option
:key="TagResourceTypeEnum.Redis.value"
:label="TagResourceTypeEnum.Redis.label"
:value="TagResourceTypeEnum.Redis.value"
/>
</el-select>
</el-form-item>
<el-form-item prop="resourceCode" label="资源编号" required>
<el-input :disabled="form.id" v-model="form.resourceCode" placeholder="请输入资源编号"></el-input>
</el-form-item>
</template>
<el-form-item 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,8 +1,8 @@
<template> <template>
<div v-if="props.tags"> <div v-if="props.tags">
<el-row v-for="(tag, idx) in props.tags?.slice(0, 1)" :key="idx"> <el-row v-for="(tag, idx) in props.tags?.slice(0, 1)" :key="idx">
<TagInfo :tag-path="tag.tagPath" /> <TagInfo :tag-path="tag.codePath" />
<span class="ml3">{{ tag.tagPath }}</span> <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"> <el-popover :show-after="300" v-if="props.tags.length > 1 && idx == 0" placement="top-start" width="230" trigger="hover">
@@ -11,8 +11,8 @@
</template> </template>
<el-row v-for="i in props.tags.slice(1)" :key="i"> <el-row v-for="i in props.tags.slice(1)" :key="i">
<TagInfo :tag-path="i.tagPath" /> <TagInfo :tag-path="i.codePath" />
<span class="ml3">{{ i.tagPath }}</span> <span class="ml3">{{ i.codePath }}</span>
</el-row> </el-row>
</el-popover> </el-popover>
</el-row> </el-row>

View File

@@ -17,6 +17,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { toRefs, reactive, onMounted } from 'vue'; import { toRefs, reactive, onMounted } from 'vue';
import { machineApi } from '../machine/api'; import { machineApi } from '../machine/api';
import { MachineProtocolEnum } from '../machine/enums';
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -46,7 +47,7 @@ onMounted(async () => {
const getSshTunnelMachines = async () => { const getSshTunnelMachines = async () => {
if (state.sshTunnelMachineList.length == 0) { 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; 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> <template>
<div class="tag-tree card pd5"> <div class="card pd5">
<el-scrollbar> <el-input v-model="filterText" placeholder="输入关键字->搜索已展开节点信息" clearable size="small" class="mb5 w100" />
<el-input v-model="filterText" placeholder="输入关键字->搜索已展开节点信息" clearable size="small" class="mb5 w100" /> <el-scrollbar class="tag-tree">
<el-tree <el-tree
ref="treeRef" ref="treeRef"
:highlight-current="true" :highlight-current="true"
@@ -10,11 +10,12 @@
:props="treeProps" :props="treeProps"
lazy lazy
node-key="key" node-key="key"
:expand-on-click-node="true" :expand-on-click-node="false"
:filter-node-method="filterNode" :filter-node-method="filterNode"
@node-click="treeNodeClick" @node-click="treeNodeClick"
@node-expand="treeNodeClick" @node-expand="treeNodeClick"
@node-contextmenu="nodeContextmenu" @node-contextmenu="nodeContextmenu"
:default-expanded-keys="props.defaultExpandedKeys"
> >
<template #default="{ node, data }"> <template #default="{ node, data }">
<span @dblclick="treeNodeDblclick(data)" :class="data.type.nodeDblclickFunc ? 'none-select' : ''"> <span @dblclick="treeNodeDblclick(data)" :class="data.type.nodeDblclickFunc ? 'none-select' : ''">
@@ -34,7 +35,9 @@
</slot> </slot>
</span> </span>
<slot :node="node" :data="data" name="suffix"></slot> <span class="label-suffix">
<slot :node="node" :data="data" name="suffix"></slot>
</span>
</span> </span>
</template> </template>
</el-tree> </el-tree>
@@ -56,6 +59,9 @@ const props = defineProps({
type: [Number], type: [Number],
required: true, required: true,
}, },
defaultExpandedKeys: {
type: [Array],
},
tagPathNodeType: { tagPathNodeType: {
type: [NodeType], type: [NodeType],
required: true, required: true,
@@ -140,8 +146,8 @@ const loadNode = async (node: any, resolve: any) => {
}; };
const treeNodeClick = (data: any) => { const treeNodeClick = (data: any) => {
emit('nodeClick', data);
if (!data.disabled && !data.type.nodeDblclickFunc && data.type.nodeClickFunc) { if (!data.disabled && !data.type.nodeDblclickFunc && data.type.nodeClickFunc) {
emit('nodeClick', data);
data.type.nodeClickFunc(data); data.type.nodeClickFunc(data);
} }
// 关闭可能存在的右击菜单 // 关闭可能存在的右击菜单
@@ -199,18 +205,32 @@ const getNode = (nodeKey: any) => {
return node; return node;
}; };
const setCurrentKey = (nodeKey: any) => {
treeRef.value.setCurrentKey(nodeKey);
};
defineExpose({ defineExpose({
reloadNode, reloadNode,
getNode,
setCurrentKey,
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.tag-tree { .tag-tree {
height: calc(100vh - 108px); height: calc(100vh - 148px);
.el-tree { .el-tree {
display: inline-block; display: inline-block;
min-width: 100%; min-width: 100%;
} }
.label-suffix {
position: absolute;
right: 10px;
color: #c4c9c4;
font-size: 10px;
margin-top: 2px;
}
} }
</style> </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

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

View File

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

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

View File

@@ -6,9 +6,8 @@
:before-query-fn="checkRouteTagPath" :before-query-fn="checkRouteTagPath"
:search-items="searchItems" :search-items="searchItems"
v-model:query-form="query" v-model:query-form="query"
:show-selection="true"
v-model:selection-data="state.selectionData"
:columns="columns" :columns="columns"
lazy
> >
<template #instanceSelect> <template #instanceSelect>
<el-select remote :remote-method="getInstances" v-model="query.instanceId" placeholder="输入并选择实例" filterable clearable> <el-select remote :remote-method="getInstances" v-model="query.instanceId" placeholder="输入并选择实例" filterable clearable>
@@ -23,11 +22,6 @@
</el-select> </el-select>
</template> </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 }"> <template #type="{ data }">
<el-tooltip :content="data.type" placement="top"> <el-tooltip :content="data.type" placement="top">
<SvgIcon :name="getDbDialect(data.type).getInfo().icon" :size="20" /> <SvgIcon :name="getDbDialect(data.type).getInfo().icon" :size="20" />
@@ -38,16 +32,26 @@
{{ `${data.host}:${data.port}` }} {{ `${data.host}:${data.port}` }}
</template> </template>
<template #database="{ data }">
<el-popover placement="bottom" :width="200" trigger="click">
<template #reference>
<el-button @click="getDbNames(data)" type="primary" link>查看库</el-button>
</template>
<el-table :data="filterDbs" v-loading="state.loadingDbNames" size="small">
<el-table-column prop="dbName" label="数据库">
<template #header>
<el-input v-model="state.dbNameSearch" size="small" placeholder="库名: 输入可过滤" clearable />
</template>
</el-table-column>
</el-table>
</el-popover>
</template>
<template #tagPath="{ data }"> <template #tagPath="{ data }">
<ResourceTags :tags="data.tags" /> <ResourceTags :tags="data.tags" />
</template> </template>
<template #action="{ data }"> <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-button type="primary" @click="onShowSqlExec(data)" link>SQL记录</el-button>
<el-divider direction="vertical" border-style="dashed" /> <el-divider direction="vertical" border-style="dashed" />
@@ -61,7 +65,7 @@
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item :command="{ type: 'detail', data }"> 详情 </el-dropdown-item> <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: 'dumpDb', data }"> 导出 </el-dropdown-item>
<el-dropdown-item :command="{ type: 'backupDb', data }" v-if="actionBtns[perms.backupDb] && supportAction('backupDb', data.type)"> <el-dropdown-item :command="{ type: 'backupDb', data }" v-if="actionBtns[perms.backupDb] && supportAction('backupDb', data.type)">
备份任务 备份任务
</el-dropdown-item> </el-dropdown-item>
@@ -88,16 +92,16 @@
<el-col :span="9"> <el-col :span="9">
<el-form-item label="导出内容: "> <el-form-item label="导出内容: ">
<el-checkbox-group v-model="exportDialog.contents" :min="1"> <el-checkbox-group v-model="exportDialog.contents" :min="1">
<el-checkbox label="结构" /> <el-checkbox label="结构" value="结构" />
<el-checkbox label="数据" /> <el-checkbox label="数据" value="数据" />
</el-checkbox-group> </el-checkbox-group>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="9"> <el-col :span="9">
<el-form-item label="扩展名: "> <el-form-item label="扩展名: ">
<el-radio-group v-model="exportDialog.extName"> <el-radio-group v-model="exportDialog.extName">
<el-radio label="sql" /> <el-radio label="sql" value="sql" />
<el-radio label="gzip" /> <el-radio label="gzip" value="gzip" />
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
</el-col> </el-col>
@@ -175,7 +179,7 @@
<el-descriptions-item :span="2" label="主机">{{ infoDialog.instance?.host }}</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="1" label="端口">{{ infoDialog.instance?.port }}</el-descriptions-item>
<el-descriptions-item :span="2" label="用户名">{{ infoDialog.instance?.username }}</el-descriptions-item> <el-descriptions-item :span="2" label="授权凭证">{{ infoDialog.instance.authCertName }}</el-descriptions-item>
<el-descriptions-item :span="1" label="类型"> <el-descriptions-item :span="1" label="类型">
<SvgIcon :name="getDbDialect(infoDialog.instance?.type).getInfo().icon" :size="20" />{{ infoDialog.instance?.type }} <SvgIcon :name="getDbDialect(infoDialog.instance?.type).getInfo().icon" :size="20" />{{ infoDialog.instance?.type }}
</el-descriptions-item> </el-descriptions-item>
@@ -183,28 +187,25 @@
<el-descriptions-item :span="3" label="数据库">{{ infoDialog.data?.database }}</el-descriptions-item> <el-descriptions-item :span="3" label="数据库">{{ infoDialog.data?.database }}</el-descriptions-item>
<el-descriptions-item :span="3" label="备注">{{ infoDialog.data?.remark }}</el-descriptions-item> <el-descriptions-item :span="3" label="备注">{{ infoDialog.data?.remark }}</el-descriptions-item>
<el-descriptions-item :span="3" label="工单流程key">{{ infoDialog.data?.flowProcdefKey }}</el-descriptions-item> <el-descriptions-item :span="2" label="创建时间">{{ formatDate(infoDialog.data?.createTime) }} </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="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-item :span="1" label="修改者">{{ infoDialog.data?.modifier }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-dialog> </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> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, toRefs, reactive, onMounted, defineAsyncComponent, Ref } from 'vue'; import { computed, defineAsyncComponent, onMounted, reactive, ref, Ref, toRefs } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { dbApi } from './api'; import { dbApi } from './api';
import config from '@/common/config'; import config from '@/common/config';
import { joinClientParams } from '@/common/request'; import { joinClientParams } from '@/common/request';
import { isTrue } from '@/common/assert'; import { isTrue } from '@/common/assert';
import { dateFormat } from '@/common/utils/date'; import { formatDate } from '@/common/utils/format';
import PageTable from '@/components/pagetable/PageTable.vue'; import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable'; import { TableColumn } from '@/components/pagetable';
import { hasPerms } from '@/components/auth/auth'; import { hasPerms } from '@/components/auth/auth';
@@ -219,18 +220,17 @@ import DbBackupList from './DbBackupList.vue';
import DbBackupHistoryList from './DbBackupHistoryList.vue'; import DbBackupHistoryList from './DbBackupHistoryList.vue';
import DbRestoreList from './DbRestoreList.vue'; import DbRestoreList from './DbRestoreList.vue';
import ResourceTags from '../component/ResourceTags.vue'; import ResourceTags from '../component/ResourceTags.vue';
import { sleep } from '@/common/utils/loading';
import { DbGetDbNamesMode } from './enums';
import { DbInst } from './db';
const DbEdit = defineAsyncComponent(() => import('./DbEdit.vue')); const DbEdit = defineAsyncComponent(() => import('./DbEdit.vue'));
const perms = { const searchItems = [
base: 'db', getTagPathSearchItem(TagResourceTypeEnum.DbName.value),
saveDb: 'db:save', SearchItem.slot('instanceId', '实例', 'instanceSelect'),
delDb: 'db:del', SearchItem.input('code', '编号'),
backupDb: 'db:backup', ];
restoreDb: 'db:restore',
};
const searchItems = [getTagPathSearchItem(TagResourceTypeEnum.Db.value), SearchItem.slot('instanceId', '实例', 'instanceSelect')];
const columns = ref([ const columns = ref([
TableColumn.new('tags[0].tagPath', '关联标签').isSlot('tagPath').setAddWidth(20), TableColumn.new('tags[0].tagPath', '关联标签').isSlot('tagPath').setAddWidth(20),
@@ -238,15 +238,22 @@ const columns = ref([
TableColumn.new('type', '类型').isSlot().setAddWidth(-15).alignCenter(), TableColumn.new('type', '类型').isSlot().setAddWidth(-15).alignCenter(),
TableColumn.new('instanceName', '实例名'), TableColumn.new('instanceName', '实例名'),
TableColumn.new('host', 'ip:port').isSlot().setAddWidth(40), TableColumn.new('host', 'ip:port').isSlot().setAddWidth(40),
TableColumn.new('username', 'username'), TableColumn.new('authCertName', '授权凭证'),
TableColumn.new('flowProcdefKey', '关联流程'), TableColumn.new('getDatabaseMode', '获库方式').typeTag(DbGetDbNamesMode),
TableColumn.new('database', '库').isSlot().setMinWidth(80),
TableColumn.new('remark', '备注'), TableColumn.new('remark', '备注'),
TableColumn.new('code', '编号'),
]); ]);
const perms = {
backupDb: 'db:backup',
restoreDb: 'db:restore',
};
// 该用户拥有的的操作列按钮权限 // 该用户拥有的的操作列按钮权限
// const actionBtns = hasPerms([perms.base, perms.saveDb]); // const actionBtns = hasPerms([perms.base, perms.saveDb]);
const actionBtns = hasPerms(Object.values(perms)); const actionBtns = hasPerms(Object.values(perms));
const actionColumn = TableColumn.new('action', '操作').isSlot().setMinWidth(220).fixedRight().alignCenter(); const actionColumn = TableColumn.new('action', '操作').isSlot().setMinWidth(180).fixedRight().alignCenter();
const route = useRoute(); const route = useRoute();
const pageTableRef: Ref<any> = ref(null); const pageTableRef: Ref<any> = ref(null);
@@ -254,6 +261,9 @@ const state = reactive({
row: {} as any, row: {} as any,
dbId: 0, dbId: 0,
db: '', db: '',
loadingDbNames: false,
currentDbNames: [],
dbNameSearch: '',
instances: [] as any, instances: [] as any,
/** /**
* 选中的数据 * 选中的数据
@@ -329,13 +339,37 @@ const state = reactive({
}, },
}); });
const { db, selectionData, query, infoDialog, sqlExecLogDialog, exportDialog, dbEditDialog, dbBackupDialog, dbBackupHistoryDialog, dbRestoreDialog } = const { db, query, infoDialog, sqlExecLogDialog, exportDialog, dbEditDialog, dbBackupDialog, dbBackupHistoryDialog, dbRestoreDialog } = toRefs(state);
toRefs(state);
onMounted(async () => { onMounted(async () => {
if (Object.keys(actionBtns).length > 0) { if (Object.keys(actionBtns).length > 0) {
columns.value.push(actionColumn); columns.value.push(actionColumn);
} }
search();
});
const getDbNames = async (db: any) => {
try {
state.loadingDbNames = true;
state.currentDbNames = await DbInst.getDbNames(db);
} finally {
state.loadingDbNames = false;
}
};
const filterDbs = computed(() => {
const dbNames = state.currentDbNames;
if (!dbNames) {
return [];
}
const dbNameObjs = dbNames.map((x) => {
return {
dbName: x,
};
});
return dbNameObjs.filter((db: any) => {
return db.dbName.includes(state.dbNameSearch);
});
}); });
const checkRouteTagPath = (query: any) => { const checkRouteTagPath = (query: any) => {
@@ -345,7 +379,10 @@ const checkRouteTagPath = (query: any) => {
return query; return query;
}; };
const search = async () => { const search = async (tagPath: string = '') => {
if (tagPath) {
state.query.tagPath = tagPath;
}
pageTableRef.value.search(); pageTableRef.value.search();
}; };
@@ -382,10 +419,6 @@ const handleMoreActionCommand = (commond: any) => {
showInfo(data); showInfo(data);
return; return;
} }
case 'edit': {
editDb(data);
return;
}
case 'dumpDb': { case 'dumpDb': {
onDumpDbs(data); onDumpDbs(data);
return; return;
@@ -405,32 +438,6 @@ const handleMoreActionCommand = (commond: any) => {
} }
}; };
const editDb = async (data: any) => {
if (!data) {
state.dbEditDialog.data = null;
state.dbEditDialog.title = '新增数据库资源';
} else {
state.dbEditDialog.data = data;
state.dbEditDialog.title = '修改数据库资源';
}
state.dbEditDialog.visible = true;
};
const deleteDb = async () => {
try {
await ElMessageBox.confirm(`确定删除【${state.selectionData.map((x: any) => x.name).join(', ')}】库?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await dbApi.deleteDb.request({ id: state.selectionData.map((x: any) => x.id).join(',') });
ElMessage.success('删除成功');
search();
} catch (err) {
//
}
};
const onShowSqlExec = async (row: any) => { const onShowSqlExec = async (row: any) => {
state.sqlExecLogDialog.title = `${row.name}`; state.sqlExecLogDialog.title = `${row.name}`;
state.sqlExecLogDialog.dbId = row.id; state.sqlExecLogDialog.dbId = row.id;
@@ -485,9 +492,8 @@ const onDumpDbs = async (row: any) => {
/** /**
* 数据库信息导出 * 数据库信息导出
*/ */
const dumpDbs = () => { const dumpDbs = async () => {
isTrue(state.exportDialog.value.length > 0, '请添加要导出的数据库'); isTrue(state.exportDialog.value.length > 0, '请添加要导出的数据库');
const a = document.createElement('a');
let type = 0; let type = 0;
for (let c of state.exportDialog.contents) { for (let c of state.exportDialog.contents) {
if (c == '结构') { if (c == '结构') {
@@ -496,13 +502,15 @@ const dumpDbs = () => {
type += 2; type += 2;
} }
} }
a.setAttribute( for (let db of state.exportDialog.value) {
'href', const a = document.createElement('a');
`${config.baseApiUrl}/dbs/${state.exportDialog.dbId}/dump?db=${state.exportDialog.value.join(',')}&type=${type}&extName=${ a.setAttribute(
state.exportDialog.extName 'href',
}&${joinClientParams()}` `${config.baseApiUrl}/dbs/${state.exportDialog.dbId}/dump?db=${db}&type=${type}&extName=${state.exportDialog.extName}&${joinClientParams()}`
); );
a.click(); a.click();
await sleep(500);
}
state.exportDialog.visible = false; state.exportDialog.visible = false;
}; };
@@ -515,6 +523,8 @@ const supportAction = (action: string, dbType: string): boolean => {
} }
return actions.includes(action); return actions.includes(action);
}; };
defineExpose({ search });
</script> </script>
<style lang="scss"> <style lang="scss">
.db-list { .db-list {

View File

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

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,190 @@
<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="getDatabaseMode" label="获库方式" min-width="80">
<template #default="scope">
<EnumTag :enums="DbGetDbNamesMode" :value="scope.row.getDatabaseMode" />
</template>
</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="getDbNames(scope.row)" type="primary" link>查看库</el-button>
</template>
<el-table :data="filterDbs" size="small" v-loading="state.loadingDbNames">
<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';
import EnumTag from '@/components/enumtag/EnumTag.vue';
import { DbGetDbNamesMode } from './enums';
import { DbInst } from './db';
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,
loadingDbNames: false,
currentDbNames: [], // 当前数据库名
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 getDbNames = async (db: any) => {
try {
state.loadingDbNames = true;
state.currentDbNames = await DbInst.getDbNames(db);
} finally {
state.loadingDbNames = false;
}
};
const filterDbs = computed(() => {
const dbNames = state.currentDbNames;
if (!dbNames) {
return [];
}
const dbNameObjs = dbNames.map((x) => {
return {
dbName: x,
};
});
return dbNameObjs.filter((db: any) => {
return db.dbName.includes(state.dbNameSearch);
});
});
const 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,111 +1,142 @@
<template> <template>
<div> <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-form :model="form" ref="dbForm" :rules="rules" label-width="auto">
<el-tabs v-model="tabActiveName"> <el-divider content-position="left">基本</el-divider>
<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="(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> <el-form-item ref="tagSelectRef" prop="tagCodePaths" label="标签">
<SvgIcon :name="getDbDialect(form.type).getInfo().icon" :size="20" /> <tag-tree-select
</template> multiple
</el-select> @change-tag="
</el-form-item> (paths: any) => {
<el-form-item v-if="form.type !== DbType.sqlite" prop="host" label="host" required> form.tagCodePaths = paths;
<el-col :span="18"> tagSelectRef.validate();
<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> :select-tags="form.tagCodePaths"
<el-col :span="5"> style="width: 100%"
<el-input type="number" v-model.number="form.port" placeholder="端口"></el-input> />
</el-col> </el-form-item>
</el-form-item>
<el-form-item v-if="form.type === DbType.sqlite" prop="host" label="sqlite地址"> <el-form-item prop="code" label="编号" required>
<el-input v-model.trim="form.host" placeholder="请输入sqlite文件在服务器的绝对地址"></el-input> <el-input
</el-form-item> :disabled="form.id"
v-model.trim="form.code"
placeholder="请输入编号 (大小写字母数字_-.:), 不可修改"
auto-complete="off"
></el-input>
</el-form-item>
<el-form-item v-if="form.type === DbType.oracle" prop="sid" label="SID"> <el-form-item prop="name" label="名称" required>
<el-input v-model.trim="form.sid" placeholder="请输入服务id"></el-input> <el-input v-model.trim="form.name" placeholder="请输入数据库别名" auto-complete="off"></el-input>
</el-form-item> </el-form-item>
<el-form-item v-if="form.type !== DbType.sqlite" prop="username" label="用户名" required>
<el-input v-model.trim="form.username" placeholder="请输入用户名"></el-input>
</el-form-item>
<el-form-item v-if="form.type !== DbType.sqlite" 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-form-item prop="remark" label="备注"> <el-form-item prop="type" label="类型" required>
<el-input v-model="form.remark" auto-complete="off" type="textarea"></el-input> <el-select @change="changeDbType" style="width: 100%" v-model="form.type" placeholder="请选择数据库类型">
</el-form-item> <el-option
</el-tab-pane> 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>
<el-tab-pane label="其他配置" name="other"> <template #prefix>
<el-form-item prop="params" label="连接参数"> <SvgIcon :name="getDbDialect(form.type).getInfo().icon" :size="20" />
<el-input v-model.trim="form.params" placeholder="其他连接参数,形如: key1=value1&key2=value2"> </template>
<!-- <template #suffix> </el-select>
<el-link </el-form-item>
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="sshTunnelMachineId" label="SSH隧道"> <el-form-item v-if="form.type !== DbType.sqlite" prop="host" label="host" required>
<ssh-tunnel-select v-model="form.sshTunnelMachineId" /> <el-col :span="18">
</el-form-item> <el-input v-model.trim="form.host" placeholder="请输入ip" auto-complete="off"></el-input>
</el-tab-pane> </el-col>
</el-tabs> <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> </el-form>
<template #footer> <template #footer>
<div class="dialog-footer"> <el-button @click="cancel()">取 消</el-button>
<el-button @click="testConn" :loading="testConnBtnLoading" type="success">测试连接</el-button> <el-button type="primary" :loading="saveBtnLoading" @click="btnOk">确 定</el-button>
<el-button @click="cancel()"> </el-button>
<el-button type="primary" :loading="saveBtnLoading" @click="btnOk"> </el-button>
</div>
</template> </template>
</el-dialog> </el-drawer>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { reactive, ref, toRefs, watch } from 'vue'; import { reactive, ref, toRefs, watchEffect } from 'vue';
import { dbApi } from './api'; import { dbApi } from './api';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { notBlank } from '@/common/assert';
import { RsaEncrypt } from '@/common/rsa';
import SshTunnelSelect from '../component/SshTunnelSelect.vue'; import SshTunnelSelect from '../component/SshTunnelSelect.vue';
import { DbType, getDbDialect, getDbDialectMap } from './dialect'; import { DbType, getDbDialect, getDbDialectMap } from './dialect';
import SvgIcon from '@/components/svgIcon/index.vue'; 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({ const props = defineProps({
visible: { visible: {
@@ -123,6 +154,25 @@ const props = defineProps({
const emit = defineEmits(['update:visible', 'cancel', 'val-change']); const emit = defineEmits(['update:visible', 'cancel', 'val-change']);
const rules = { const rules = {
tagCodePaths: [
{
required: true,
message: '请选择标签',
trigger: ['change'],
},
],
code: [
{
required: true,
message: '请输入编码',
trigger: ['change', 'blur'],
},
{
pattern: ResourceCodePattern.pattern,
message: ResourceCodePattern.message,
trigger: ['blur'],
},
],
name: [ name: [
{ {
required: true, required: true,
@@ -144,13 +194,6 @@ const rules = {
trigger: ['blur'], trigger: ['blur'],
}, },
], ],
username: [
{
required: true,
message: '请输入用户名',
trigger: ['change', 'blur'],
},
],
sid: [ sid: [
{ {
required: true, required: true,
@@ -161,108 +204,109 @@ const rules = {
}; };
const dbForm: any = ref(null); const dbForm: any = ref(null);
const tagSelectRef: any = ref(null);
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({ const state = reactive({
dialogVisible: false, dialogVisible: false,
tabActiveName: 'basic', extra: {} as any, // 连接需要的额外参数json
form: { form: DefaultForm,
id: null, submitForm: {} as any,
type: '',
name: null,
host: '',
port: null,
username: null,
sid: null, // oracle类项目需要服务id
password: null,
params: null,
remark: '',
sshTunnelMachineId: null as any,
},
submitForm: {},
// 原密码
pwd: '',
// 原用户名
oldUserName: null,
}); });
const { dialogVisible, tabActiveName, form, submitForm, pwd } = toRefs(state); const { dialogVisible, form, submitForm } = toRefs(state);
const { isFetching: saveBtnLoading, execute: saveInstanceExec } = dbApi.saveInstance.useApi(submitForm); const { isFetching: saveBtnLoading, execute: saveInstanceExec, data: saveInstanceRes } = dbApi.saveInstance.useApi(submitForm);
const { isFetching: testConnBtnLoading, execute: testConnExec } = dbApi.testConn.useApi(submitForm); const { isFetching: testConnBtnLoading, execute: testConnExec } = dbApi.testConn.useApi(submitForm);
watch(props, (newValue: any) => { watchEffect(() => {
state.dialogVisible = newValue.visible; state.dialogVisible = props.visible;
if (!state.dialogVisible) { if (!state.dialogVisible) {
return; return;
} }
state.tabActiveName = 'basic'; const dbInst: any = props.data;
if (newValue.data) { if (dbInst) {
state.form = { ...newValue.data }; state.form = { ...dbInst };
state.oldUserName = state.form.username; state.form.tagCodePaths = dbInst.tags.map((t: any) => t.codePath) || [];
try {
state.extra = JSON.parse(state.form.extra);
} catch (e) {
state.extra = {};
}
} else { } else {
state.form = { port: null, type: DbType.mysql } as any; state.form = { ...DefaultForm };
state.oldUserName = null; 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 getReqForm = async () => {
const reqForm = { ...state.form }; const reqForm: any = { ...state.form };
reqForm.password = await RsaEncrypt(reqForm.password); reqForm.selectAuthCert = null;
reqForm.tags = null;
if (!state.form.sshTunnelMachineId) { if (!state.form.sshTunnelMachineId) {
reqForm.sshTunnelMachineId = -1; reqForm.sshTunnelMachineId = -1;
} }
if (Object.keys(state.extra).length > 0) {
reqForm.extra = JSON.stringify(state.extra);
}
return reqForm; return reqForm;
}; };
const testConn = async () => { const testConn = async (authCert: any) => {
dbForm.value.validate(async (valid: boolean) => { try {
if (!valid) { await dbForm.value.validate();
ElMessage.error('请正确填写信息'); } catch (e: any) {
return false; ElMessage.error('请正确填写信息');
} return false;
}
state.submitForm = await getReqForm(); state.submitForm = await getReqForm();
await testConnExec(); state.submitForm.authCerts = [authCert];
ElMessage.success('连接成功'); await testConnExec();
}); ElMessage.success('连接成功');
}; };
const btnOk = async () => { const btnOk = async () => {
if (state.form.type !== DbType.sqlite) { try {
if (!state.form.id) { await dbForm.value.validate();
notBlank(state.form.password, '新增操作,密码不可为空'); } catch (e: any) {
} else if (state.form.username != state.oldUserName) { ElMessage.error('请正确填写信息');
notBlank(state.form.password, '已修改用户名,请输入密码'); return false;
}
} }
dbForm.value.validate(async (valid: boolean) => { state.submitForm = await getReqForm();
if (!valid) { await saveInstanceExec();
ElMessage.error('请正确填写信息'); ElMessage.success('保存成功');
return false; state.form.id = saveInstanceRes as any;
} emit('val-change', state.form);
cancel();
state.submitForm = await getReqForm();
await saveInstanceExec();
ElMessage.success('保存成功');
emit('val-change', state.form);
cancel();
});
}; };
const cancel = () => { const cancel = () => {
emit('update:visible', false); emit('update:visible', false);
emit('cancel'); emit('cancel');
state.extra = {};
};
const changeDbType = (val: string) => {
if (!state.form.id) {
state.form.port = getDbDialect(val).getInfo().defaultPort as any;
}
state.extra = {};
}; };
</script> </script>
<style lang="scss"></style> <style lang="scss"></style>

View File

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

View File

@@ -2,7 +2,12 @@
<div class="db-sql-exec"> <div class="db-sql-exec">
<Splitpanes class="default-theme"> <Splitpanes class="default-theme">
<Pane size="20" max-size="30"> <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 }"> <template #prefix="{ data }">
<span v-if="data.type.value == SqlExecNodeType.DbInst"> <span v-if="data.type.value == SqlExecNodeType.DbInst">
<el-popover <el-popover
@@ -27,8 +32,8 @@
<el-descriptions-item label="数据库版本"> <el-descriptions-item label="数据库版本">
<span v-loading="loadingServerInfo"> {{ `${dbServerInfo?.version}` }}</span> <span v-loading="loadingServerInfo"> {{ `${dbServerInfo?.version}` }}</span>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="user"> <el-descriptions-item label="授权凭证">
{{ data.params.username }} {{ data.params.authCertName }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="备注"> <el-descriptions-item label="备注">
{{ data.params.remark }} {{ data.params.remark }}
@@ -42,10 +47,8 @@
</template> </template>
<template #suffix="{ data }"> <template #suffix="{ data }">
<span class="db-table-size" v-if="data.type.value == SqlExecNodeType.Table && data.params.size">{{ ` ${data.params.size}` }}</span> <span 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">{{ <span v-if="data.type.value == SqlExecNodeType.TableMenu && data.params.dbTableSize">{{ ` ${data.params.dbTableSize}` }}</span>
` ${data.params.dbTableSize}`
}}</span>
</template> </template>
</tag-tree> </tag-tree>
</Pane> </Pane>
@@ -143,7 +146,7 @@
:db-id="dt.params.id" :db-id="dt.params.id"
:db="dt.params.db" :db="dt.params.db"
:db-type="dt.params.type" :db-type="dt.params.type"
:flow-procdef-key="dt.params.flowProcdefKey" :flow-procdef="dt.params.flowProcdef"
:height="state.tablesOpHeight" :height="state.tablesOpHeight"
/> />
</el-tab-pane> </el-tab-pane>
@@ -158,7 +161,7 @@
:dbId="tableCreateDialog.dbId" :dbId="tableCreateDialog.dbId"
:db="tableCreateDialog.db" :db="tableCreateDialog.db"
:dbType="tableCreateDialog.dbType" :dbType="tableCreateDialog.dbType"
:flow-procdef-key="tableCreateDialog.flowProcdefKey" :flow-procdef="tableCreateDialog.flowProcdef"
:data="tableCreateDialog.data" :data="tableCreateDialog.data"
v-model:visible="tableCreateDialog.visible" v-model:visible="tableCreateDialog.visible"
@submit-sql="onSubmitEditTableSql" @submit-sql="onSubmitEditTableSql"
@@ -167,11 +170,11 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent, h, onBeforeUnmount, onMounted, reactive, ref, toRefs } from 'vue'; import { defineAsyncComponent, h, onBeforeUnmount, onMounted, reactive, ref, toRefs, watch } from 'vue';
import { ElCheckbox, ElMessage, ElMessageBox } from 'element-plus'; import { ElCheckbox, ElMessage, ElMessageBox } from 'element-plus';
import { formatByteSize } from '@/common/utils/format'; import { formatByteSize } from '@/common/utils/format';
import { DbInst, registerDbCompletionItemProvider, TabInfo, TabType } from './db'; 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 TagTree from '../component/TagTree.vue';
import { dbApi } from './api'; import { dbApi } from './api';
import { dispposeCompletionItemProvider } from '@/components/monaco/completionItemProvider'; import { dispposeCompletionItemProvider } from '@/components/monaco/completionItemProvider';
@@ -183,6 +186,9 @@ import { TagResourceTypeEnum } from '@/common/commonEnum';
import { Pane, Splitpanes } from 'splitpanes'; import { Pane, Splitpanes } from 'splitpanes';
import { useEventListener } from '@vueuse/core'; import { useEventListener } from '@vueuse/core';
import SqlExecBox from '@/views/ops/db/component/sqleditor/SqlExecBox'; 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 DbTableOp = defineAsyncComponent(() => import('./component/table/DbTableOp.vue'));
const DbSqlEditor = defineAsyncComponent(() => import('./component/sqleditor/DbSqlEditor.vue')); const DbSqlEditor = defineAsyncComponent(() => import('./component/sqleditor/DbSqlEditor.vue'));
@@ -236,7 +242,7 @@ const nodeClickChangeDb = (nodeData: TagTreeNode) => {
type: params.type, type: params.type,
tagPath: params.tagPath, tagPath: params.tagPath,
databases: params.dbs, databases: params.dbs,
flowProcdefKey: params.flowProcdefKey, flowProcdef: params.flowProcdef,
}, },
params.db params.db
); );
@@ -258,15 +264,17 @@ const NodeTypeTagPath = new NodeType(TagTreeNode.TagPath)
await sleep(100); await sleep(100);
return dbInfos?.map((x: any) => { return dbInfos?.map((x: any) => {
x.tagPath = parentNode.key; x.tagPath = parentNode.key;
return new TagTreeNode(`${parentNode.key}.${x.id}`, x.name, NodeTypeDbInst).withParams(x); return new TagTreeNode(`${x.code}`, x.name, NodeTypeDbInst).withParams(x);
}); });
}) })
.withContextMenuItems([ContextmenuItemRefresh]); .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 params = parentNode.params;
const dbs = params.database.split(' ')?.sort(); const dbs = (await DbInst.getDbNames(params))?.sort();
const flowProcdef = await procdefApi.getByResource.request({ resourceType: TagResourceTypeEnum.DbName.value, resourceCode: params.code });
return dbs.map((x: any) => { return dbs.map((x: any) => {
return new TagTreeNode(`${parentNode.key}.${x}`, x, NodeTypeDb) return new TagTreeNode(`${parentNode.key}.${x}`, x, NodeTypeDb)
.withParams({ .withParams({
@@ -277,7 +285,7 @@ const NodeTypeDbInst = new NodeType(SqlExecNodeType.DbInst).withLoadNodesFunc((p
host: `${params.host}:${params.port}`, host: `${params.host}:${params.port}`,
dbs: dbs, dbs: dbs,
db: x, db: x,
flowProcdefKey: params.flowProcdefKey, flowProcdef: flowProcdef,
}) })
.withIcon(DbIcon); .withIcon(DbIcon);
}); });
@@ -302,11 +310,12 @@ const NodeTypeDb = new NodeType(SqlExecNodeType.Db)
return new TagTreeNode(`${params.id}.${params.db}.schema.${sn}`, sn, NodeTypePostgresSchema).withParams(nParams).withIcon(SchemaIcon); return new TagTreeNode(`${params.id}.${params.db}.schema.${sn}`, sn, NodeTypePostgresSchema).withParams(nParams).withIcon(SchemaIcon);
}); });
} }
return NodeTypeTables(params);
return getNodeTypeTables(params);
}) })
.withNodeClickFunc(nodeClickChangeDb); .withNodeClickFunc(nodeClickChangeDb);
const NodeTypeTables = (params: any) => { const getNodeTypeTables = (params: any) => {
let tableKey = `${params.id}.${params.db}.table-menu`; let tableKey = `${params.id}.${params.db}.table-menu`;
let sqlKey = getSqlMenuNodeKey(params.id, params.db); let sqlKey = getSqlMenuNodeKey(params.id, params.db);
return [ return [
@@ -321,7 +330,7 @@ const NodeTypePostgresSchema = new NodeType(SqlExecNodeType.PgSchema)
.withLoadNodesFunc(async (parentNode: TagTreeNode) => { .withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params; const params = parentNode.params;
params.parentKey = parentNode.key; params.parentKey = parentNode.key;
return NodeTypeTables(params); return getNodeTypeTables(params);
}) })
.withNodeClickFunc(nodeClickChangeDb); .withNodeClickFunc(nodeClickChangeDb);
@@ -337,7 +346,7 @@ const NodeTypeTableMenu = new NodeType(SqlExecNodeType.TableMenu)
]) ])
.withLoadNodesFunc(async (parentNode: TagTreeNode) => { .withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params; const params = parentNode.params;
let { id, db, type, flowProcdefKey } = params; let { id, db, type, flowProcdef, schema } = params;
// 获取当前库的所有表信息 // 获取当前库的所有表信息
let tables = await DbInst.getInst(id).loadTables(db, state.reloadStatus); let tables = await DbInst.getInst(id).loadTables(db, state.reloadStatus);
state.reloadStatus = false; state.reloadStatus = false;
@@ -352,7 +361,8 @@ const NodeTypeTableMenu = new NodeType(SqlExecNodeType.TableMenu)
id, id,
db, db,
type, type,
flowProcdefKey: flowProcdefKey, schema,
flowProcdef: flowProcdef,
key: key, key: key,
parentKey: parentNode.key, parentKey: parentNode.key,
tableName: x.tableName, tableName: x.tableName,
@@ -366,7 +376,10 @@ const NodeTypeTableMenu = new NodeType(SqlExecNodeType.TableMenu)
parentNode.params.dbTableSize = dbTableSize == 0 ? '' : formatByteSize(dbTableSize); parentNode.params.dbTableSize = dbTableSize == 0 ? '' : formatByteSize(dbTableSize);
return tablesNode; 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模板菜单节点 // 数据库sql模板菜单节点
const NodeTypeSqlMenu = new NodeType(SqlExecNodeType.SqlMenu) const NodeTypeSqlMenu = new NodeType(SqlExecNodeType.SqlMenu)
@@ -413,6 +426,7 @@ const tagTreeRef: any = ref(null);
const tabs: Map<string, TabInfo> = new Map(); const tabs: Map<string, TabInfo> = new Map();
const state = reactive({ const state = reactive({
defaultExpendKey: [] as any,
/** /**
* 当前操作的数据库实例 * 当前操作的数据库实例
*/ */
@@ -434,7 +448,7 @@ const state = reactive({
dbId: 0, dbId: 0,
db: '', db: '',
dbType: '', dbType: '',
flowProcdefKey: '', flowProcdef: null as any,
data: {}, data: {},
parentKey: '', parentKey: '',
}, },
@@ -447,7 +461,11 @@ const serverInfoReqParam = ref({
}); });
const { execute: getDbServerInfo, isFetching: loadingServerInfo, data: dbServerInfo } = dbApi.getInstanceServerInfo.useApi<any>(serverInfoReqParam); const { execute: getDbServerInfo, isFetching: loadingServerInfo, data: dbServerInfo } = dbApi.getInstanceServerInfo.useApi<any>(serverInfoReqParam);
const autoOpenResourceStore = useAutoOpenResource();
const { autoOpenResource } = storeToRefs(autoOpenResourceStore);
onMounted(() => { onMounted(() => {
autoOpenDb(autoOpenResource.value.dbCodePath);
setHeight(); setHeight();
// 监听浏览器窗口大小变化,更新对应组件高度 // 监听浏览器窗口大小变化,更新对应组件高度
useEventListener(window, 'resize', setHeight); useEventListener(window, 'resize', setHeight);
@@ -457,6 +475,31 @@ onBeforeUnmount(() => {
dispposeCompletionItemProvider('sql'); 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高度和数据表高度 * 设置editor高度和数据表高度
*/ */
@@ -626,6 +669,8 @@ const onTabChange = () => {
// 注册sql提示 // 注册sql提示
registerDbCompletionItemProvider(nowTab.dbId, nowTab.db, nowTab.params.dbs, nowDbInst.value.type); registerDbCompletionItemProvider(nowTab.dbId, nowTab.db, nowTab.params.dbs, nowDbInst.value.type);
} }
tagTreeRef.value.setCurrentKey(nowTab?.treeNodeKey);
}; };
const reloadSqls = (dbId: number, db: string) => { const reloadSqls = (dbId: number, db: string) => {
@@ -657,7 +702,7 @@ const reloadNode = (nodeKey: string) => {
}; };
const onEditTable = async (data: any) => { const onEditTable = async (data: any) => {
let { db, id, tableName, tableComment, type, parentKey, key, flowProcdefKey } = data.params; let { db, id, tableName, tableComment, type, parentKey, key, flowProcdef } = data.params;
// data.label就是表名 // data.label就是表名
if (tableName) { if (tableName) {
state.tableCreateDialog.title = '修改表'; state.tableCreateDialog.title = '修改表';
@@ -676,12 +721,12 @@ const onEditTable = async (data: any) => {
state.tableCreateDialog.dbId = id; state.tableCreateDialog.dbId = id;
state.tableCreateDialog.db = db; state.tableCreateDialog.db = db;
state.tableCreateDialog.dbType = type; state.tableCreateDialog.dbType = type;
state.tableCreateDialog.flowProcdefKey = flowProcdefKey; state.tableCreateDialog.flowProcdef = flowProcdef;
state.tableCreateDialog.visible = true; state.tableCreateDialog.visible = true;
}; };
const onDeleteTable = async (data: any) => { const onDeleteTable = async (data: any) => {
let { db, id, tableName, parentKey, flowProcdefKey } = data.params; let { db, id, tableName, parentKey, flowProcdef, schema } = data.params;
await ElMessageBox.confirm(`此操作是永久性且无法撤销,确定删除【${tableName}】? `, '提示', { await ElMessageBox.confirm(`此操作是永久性且无法撤销,确定删除【${tableName}】? `, '提示', {
confirmButtonText: '确定', confirmButtonText: '确定',
cancelButtonText: '取消', cancelButtonText: '取消',
@@ -689,8 +734,11 @@ const onDeleteTable = async (data: any) => {
}); });
// 执行sql // 执行sql
dbApi.sqlExec.request({ id, db, sql: `drop table ${getDbDialect(state.nowDbInst.type).quoteIdentifier(tableName)}` }).then(() => { let dialect = getDbDialect(state.nowDbInst.type);
if (flowProcdefKey) { let schemaStr = schema ? `${dialect.quoteIdentifier(schema)}.` : '';
dbApi.sqlExec.request({ id, db, sql: `drop table ${schemaStr + dialect.quoteIdentifier(tableName)}` }).then(() => {
if (flowProcdef) {
ElMessage.success('工单提交成功'); ElMessage.success('工单提交成功');
return; return;
} }
@@ -702,7 +750,7 @@ const onDeleteTable = async (data: any) => {
}; };
const onRenameTable = async (data: any) => { const onRenameTable = async (data: any) => {
let { db, id, tableName, parentKey, flowProcdefKey } = data.params; let { db, id, tableName, parentKey, flowProcdef } = data.params;
let tableData = { db, oldTableName: tableName, tableName }; let tableData = { db, oldTableName: tableName, tableName };
let value = ref(tableName); let value = ref(tableName);
@@ -725,7 +773,7 @@ const onRenameTable = async (data: any) => {
dbId: id as any, dbId: id as any,
db: db as any, db: db as any,
dbType: nowDbInst.value.getDialect().getInfo().formatSqlDialect, dbType: nowDbInst.value.getDialect().getInfo().formatSqlDialect,
flowProcdefKey: flowProcdefKey, flowProcdef: flowProcdef,
runSuccessCallback: () => { runSuccessCallback: () => {
setTimeout(() => { setTimeout(() => {
parentKey && reloadNode(parentKey); parentKey && reloadNode(parentKey);
@@ -785,7 +833,7 @@ const getNowDbInfo = () => {
name: di.name, name: di.name,
type: di.type, type: di.type,
host: di.host, host: di.host,
flowProcdefKey: di.flowProcdefKey, flowProcdef: di.flowProcdef,
dbName: state.db, dbName: state.db,
}; };
}; };
@@ -793,13 +841,8 @@ const getNowDbInfo = () => {
<style lang="scss"> <style lang="scss">
.db-sql-exec { .db-sql-exec {
.db-table-size {
color: #c4c9c4;
font-size: 9px;
}
.db-op { .db-op {
height: calc(100vh - 108px); height: calc(100vh - 106px);
} }
#data-exec { #data-exec {

View File

@@ -301,7 +301,9 @@ watch(dialogVisible, async (newValue: boolean) => {
state.tabActiveName = 'basic'; state.tabActiveName = 'basic';
const propsData = props.data as any; const propsData = props.data as any;
if (!propsData?.id) { if (!propsData?.id) {
state.form = basicFormData; let d = {} as FormData;
Object.assign(d, basicFormData);
state.form = d;
return; return;
} }

View File

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

View File

@@ -39,11 +39,11 @@ export const dbApi = {
instances: Api.newGet('/instances'), instances: Api.newGet('/instances'),
getInstance: Api.newGet('/instances/{instanceId}'), getInstance: Api.newGet('/instances/{instanceId}'),
getAllDatabase: Api.newGet('/instances/{instanceId}/databases'), getAllDatabase: Api.newPost('/instances/databases'),
getDbNamesByAc: Api.newGet('/instances/databases/{authCertName}'),
getInstanceServerInfo: Api.newGet('/instances/{instanceId}/server-info'), getInstanceServerInfo: Api.newGet('/instances/{instanceId}/server-info'),
testConn: Api.newPost('/instances/test-conn'), testConn: Api.newPost('/instances/test-conn'),
saveInstance: Api.newPost('/instances'), saveInstance: Api.newPost('/instances'),
getInstancePwd: Api.newGet('/instances/{id}/pwd'),
deleteInstance: Api.newDelete('/instances/{id}'), deleteInstance: Api.newDelete('/instances/{id}'),
// 获取数据库备份列表 // 获取数据库备份列表
@@ -83,6 +83,14 @@ export const dbApi = {
runDatasyncTask: Api.newPost('/datasync/tasks/{taskId}/run'), runDatasyncTask: Api.newPost('/datasync/tasks/{taskId}/run'),
stopDatasyncTask: Api.newPost('/datasync/tasks/{taskId}/stop'), stopDatasyncTask: Api.newPost('/datasync/tasks/{taskId}/stop'),
datasyncLogs: Api.newGet('/datasync/tasks/{taskId}/logs'), 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 = { export const dbSqlExecApi = {

View File

@@ -25,6 +25,7 @@ import SvgIcon from '@/components/svgIcon/index.vue';
import { getDbDialect, noSchemaTypes } from '@/views/ops/db/dialect'; import { getDbDialect, noSchemaTypes } from '@/views/ops/db/dialect';
import TagTreeResourceSelect from '../../component/TagTreeResourceSelect.vue'; import TagTreeResourceSelect from '../../component/TagTreeResourceSelect.vue';
import { computed } from 'vue'; import { computed } from 'vue';
import { DbInst } from '../db';
const props = defineProps({ const props = defineProps({
dbId: { dbId: {
@@ -101,9 +102,9 @@ const noSchemaType = (type: string) => {
}; };
// 数据库实例节点类型 // 数据库实例节点类型
const NodeTypeDbInst = new NodeType(SqlExecNodeType.DbInst).withLoadNodesFunc((parentNode: TagTreeNode) => { const NodeTypeDbInst = new NodeType(SqlExecNodeType.DbInst).withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params; const params = parentNode.params;
const dbs = params.database.split(' ')?.sort(); const dbs = (await DbInst.getDbNames(params))?.sort();
let fn: NodeType; let fn: NodeType;
if (noSchemaType(params.type)) { if (noSchemaType(params.type)) {
fn = MysqlNodeTypes; fn = MysqlNodeTypes;

View File

@@ -113,7 +113,7 @@
:loading="dt.loading" :loading="dt.loading"
:abort-fn="dt.abortFn" :abort-fn="dt.abortFn"
:height="tableDataHeight" :height="tableDataHeight"
empty-text="tips: select *开头的单表查询或点击表名默认查询的数据,可双击数据在线修改" :empty-text="state.tableDataEmptyText"
@change-updated-field="changeUpdatedField($event, dt)" @change-updated-field="changeUpdatedField($event, dt)"
@data-delete="onDeleteData($event, dt)" @data-delete="onDeleteData($event, dt)"
></db-table-data> ></db-table-data>
@@ -221,6 +221,7 @@ const state = reactive({
activeTab: 1, activeTab: 1,
editorHeight: '500', editorHeight: '500',
tableDataHeight: '250px', tableDataHeight: '250px',
tableDataEmptyText: 'tips: select *开头的单表查询或点击表名默认查询的数据,可双击数据在线修改',
}); });
const { tableDataHeight } = toRefs(state); const { tableDataHeight } = toRefs(state);
@@ -295,44 +296,37 @@ const onRunSql = async (newTab = false) => {
notBlank(sql && sql.trim(), '请选中需要执行的sql'); notBlank(sql && sql.trim(), '请选中需要执行的sql');
// 去除字符串前的空格、换行等 // 去除字符串前的空格、换行等
sql = sql.replace(/(^\s*)/g, ''); sql = sql.replace(/(^\s*)/g, '');
let execRemark = '';
let canRun = true;
// 简单截取前十个字符 // 简单截取前十个字符
const sqlPrefix = sql.slice(0, 10).toLowerCase(); const sqlPrefix = sql.slice(0, 10).toLowerCase();
if ( const nonQuery =
sqlPrefix.startsWith('update') || sqlPrefix.startsWith('update') ||
sqlPrefix.startsWith('insert') || sqlPrefix.startsWith('insert') ||
sqlPrefix.startsWith('delete') || sqlPrefix.startsWith('delete') ||
sqlPrefix.startsWith('alert') || sqlPrefix.startsWith('alert') ||
sqlPrefix.startsWith('drop') || sqlPrefix.startsWith('drop') ||
sqlPrefix.startsWith('create') sqlPrefix.startsWith('create');
) {
const res: any = await ElMessageBox.prompt('请输入备注', 'Tip', { // 启用工单审批
confirmButtonText: '确定', if (nonQuery && getNowDbInst().flowProcdef) {
cancelButtonText: '取消', try {
inputPattern: /^[\s\S]*.*[^\s][\s\S]*$/, getNowDbInst().promptExeSql(props.dbName, sql, null, () => {
inputErrorMessage: '请输入执行该sql的备注信息', ElMessage.success('工单提交成功');
}); });
execRemark = res.value; } catch (e) {
if (!execRemark) { ElMessage.success('工单提交失败');
canRun = false;
} }
}
if (!canRun) {
return; return;
} }
// 启用工单审批 let execRemark;
if (execRemark && getNowDbInst().flowProcdefKey) { if (nonQuery) {
try { const res: any = await ElMessageBox.prompt('请输入备注', 'Tip', {
await getNowDbInst().runSql(props.dbName, sql, execRemark); confirmButtonText: '确定',
ElMessage.success('工单提交成功'); cancelButtonText: '取消',
return; inputErrorMessage: '输入执行该sql的备注信息',
} catch (e) { });
ElMessage.success('工单提交失败'); execRemark = res.value;
return;
}
} }
let execRes: ExecResTab; let execRes: ExecResTab;
@@ -368,9 +362,8 @@ const onRunSql = async (newTab = false) => {
await execute(); await execute();
const colAndData: any = data.value; const colAndData: any = data.value;
if (!colAndData.res || colAndData.res.length === 0) { if (colAndData.res.length == 0) {
ElMessage.warning('未查询到结果集'); state.tableDataEmptyText = '查无数据';
return;
} }
// 要实时响应,故需要用索引改变数据才生效 // 要实时响应,故需要用索引改变数据才生效
@@ -467,7 +460,7 @@ const formatSql = () => {
return; return;
} }
const formatDialect = getNowDbInst().getDialect().getInfo().formatSqlDialect; const formatDialect: any = getNowDbInst().getDialect().getInfo().formatSqlDialect;
let sql = monacoEditor.getModel()?.getValueInRange(selection); let sql = monacoEditor.getModel()?.getValueInRange(selection);
// 有选中sql则格式化并替换选中sql, 否则格式化编辑器所有内容 // 有选中sql则格式化并替换选中sql, 否则格式化编辑器所有内容

View File

@@ -6,7 +6,7 @@ export type SqlExecProps = {
dbId: number; dbId: number;
db: string; db: string;
dbType?: string; dbType?: string;
flowProcdefKey?: string; flowProcdef?: any;
runSuccessCallback?: Function; runSuccessCallback?: Function;
cancelCallback?: Function; cancelCallback?: Function;
}; };

View File

@@ -1,12 +1,18 @@
<template> <template>
<div> <div>
<el-dialog title="待执行SQL" v-model="dialogVisible" :show-close="false" width="600px"> <el-dialog title="待执行SQL" v-model="dialogVisible" :show-close="false" width="600px" :close-on-click-modal="false">
<monaco-editor height="300px" class="codesql" language="sql" v-model="sqlValue" /> <monaco-editor height="300px" class="codesql" language="sql" v-model="sqlValue" />
<el-input @keyup.enter="runSql" ref="remarkInputRef" v-model="remark" placeholder="请输入执行备注" class="mt5" /> <el-input
@keyup.enter="runSql"
ref="remarkInputRef"
v-model="remark"
:placeholder="props.flowProcdef ? '执行备注(必填)' : '执行备注(选填)'"
class="mt5"
/>
<div v-if="props.flowProcdefKey"> <div v-if="props.flowProcdef">
<el-divider content-position="left">审批节点</el-divider> <el-divider content-position="left">审批节点</el-divider>
<procdef-tasks :procdef-key="props.flowProcdefKey" /> <procdef-tasks :procdef="props.flowProcdef" />
</div> </div>
<template #footer> <template #footer>
@@ -52,13 +58,16 @@ onMounted(() => {
* 执行sql * 执行sql
*/ */
const runSql = async () => { const runSql = async () => {
if (!state.remark) { // 存在流程审批,则备注为必填
if (!state.remark && props.flowProcdef) {
ElMessage.error('请输入执行的备注信息'); ElMessage.error('请输入执行的备注信息');
return; return;
} }
try { try {
state.btnLoading = true; state.btnLoading = true;
runSuccess = true;
const res = await dbApi.sqlExec.request({ const res = await dbApi.sqlExec.request({
id: props.dbId, id: props.dbId,
db: props.db, db: props.db,
@@ -67,8 +76,7 @@ const runSql = async () => {
}); });
// 存在流程审批 // 存在流程审批
if (props.flowProcdefKey) { if (props.flowProcdef) {
runSuccess = false;
ElMessage.success('工单提交成功'); ElMessage.success('工单提交成功');
return; return;
} }
@@ -80,7 +88,6 @@ const runSql = async () => {
} }
} }
runSuccess = true;
ElMessage.success('执行成功'); ElMessage.success('执行成功');
} catch (e) { } catch (e) {
runSuccess = false; runSuccess = false;
@@ -89,9 +96,9 @@ const runSql = async () => {
if (props.runSuccessCallback) { if (props.runSuccessCallback) {
props.runSuccessCallback(); props.runSuccessCallback();
} }
cancel();
} }
state.btnLoading = false; state.btnLoading = false;
cancel();
} }
}; };
@@ -106,7 +113,7 @@ const cancel = () => {
}; };
const open = () => { const open = () => {
state.sqlValue = sqlFormatter(props.sql, { language: props.dbType || 'mysql' }); state.sqlValue = sqlFormatter(props.sql, { language: (props.dbType || 'mysql') as any });
state.dialogVisible = true; state.dialogVisible = true;
setTimeout(() => { setTimeout(() => {
remarkInputRef.value?.focus(); remarkInputRef.value?.focus();

View File

@@ -1,7 +1,6 @@
<template> <template>
<div class="string-input-container w100" v-if="dataType == DataType.String"> <div class="string-input-container w100" v-if="dataType == DataType.String || dataType == DataType.Number">
<el-input <el-input
v-if="dataType == DataType.String"
:ref="(el: any) => focus && el?.focus()" :ref="(el: any) => focus && el?.focus()"
:disabled="disabled" :disabled="disabled"
@blur="handleBlur" @blur="handleBlur"
@@ -13,18 +12,6 @@
<SvgIcon v-if="showEditorIcon" @mousedown="openEditor" class="string-input-container-icon" name="FullScreen" :size="10" /> <SvgIcon v-if="showEditorIcon" @mousedown="openEditor" class="string-input-container-icon" name="FullScreen" :size="10" />
</div> </div>
<el-input
v-else-if="dataType == DataType.Number"
:ref="(el: any) => focus && el?.focus()"
:disabled="disabled"
@blur="handleBlur"
class="w100 mb4"
size="small"
v-model.number="itemValue"
:placeholder="placeholder"
type="number"
/>
<el-date-picker <el-date-picker
v-else-if="dataType == DataType.Date" v-else-if="dataType == DataType.Date"
:ref="(el: any) => focus && el?.focus()" :ref="(el: any) => focus && el?.focus()"
@@ -75,7 +62,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref, Ref } from 'vue'; import { computed, ref, Ref } from 'vue';
import { ElInput } from 'element-plus'; import { ElInput, ElMessage } from 'element-plus';
import { DataType } from '../../dialect/index'; import { DataType } from '../../dialect/index';
import SvgIcon from '@/components/svgIcon/index.vue'; import SvgIcon from '@/components/svgIcon/index.vue';
import MonacoEditorDialog from '@/components/monaco/MonacoEditorDialog'; import MonacoEditorDialog from '@/components/monaco/MonacoEditorDialog';
@@ -130,6 +117,10 @@ const handleBlur = () => {
if (editorOpening.value) { if (editorOpening.value) {
return; return;
} }
if (props.dataType == DataType.Number && itemValue.value && !/^-?\d*\.?\d+$/.test(itemValue.value)) {
ElMessage.error('输入内容与类型不匹配');
return;
}
emit('update:modelValue', itemValue.value); emit('update:modelValue', itemValue.value);
emit('blur'); emit('blur');
}; };

View File

@@ -36,7 +36,7 @@
<!-- 字段列的数据类型 --> <!-- 字段列的数据类型 -->
<div class="column-type"> <div class="column-type">
<span v-if="column.dataTypeSubscript === 'icon-clock'"> <span v-if="column.dataTypeSubscript === 'icon-clock'">
<SvgIcon :size="10" name="Clock" style="cursor: unset" /> <SvgIcon :size="9" name="Clock" style="cursor: unset" />
</span> </span>
<span class="font8" v-else>{{ column.dataTypeSubscript }}</span> <span class="font8" v-else>{{ column.dataTypeSubscript }}</span>
</div> </div>
@@ -121,7 +121,7 @@
<template #empty> <template #empty>
<div style="text-align: center"> <div style="text-align: center">
<el-empty class="h100" :description="state.emptyText" :image-size="100" /> <el-empty class="h100" :description="props.emptyText" :image-size="100" />
</div> </div>
</template> </template>
</el-table-v2> </el-table-v2>
@@ -161,7 +161,7 @@ import { DbInst } from '@/views/ops/db/db';
import { Contextmenu, ContextmenuItem } from '@/components/contextmenu'; import { Contextmenu, ContextmenuItem } from '@/components/contextmenu';
import SvgIcon from '@/components/svgIcon/index.vue'; import SvgIcon from '@/components/svgIcon/index.vue';
import { exportCsv, exportFile } from '@/common/utils/export'; import { exportCsv, exportFile } from '@/common/utils/export';
import { dateStrFormat } from '@/common/utils/date'; import { formatDate } from '@/common/utils/format';
import { useIntervalFn, useStorage } from '@vueuse/core'; import { useIntervalFn, useStorage } from '@vueuse/core';
import { ColumnTypeSubscript, compatibleMysql, DataType, DbDialect, getDbDialect } from '../../dialect/index'; import { ColumnTypeSubscript, compatibleMysql, DataType, DbDialect, getDbDialect } from '../../dialect/index';
import ColumnFormItem from './ColumnFormItem.vue'; import ColumnFormItem from './ColumnFormItem.vue';
@@ -342,8 +342,6 @@ const state = reactive({
columns: [] as any, columns: [] as any,
loading: false, loading: false,
tableHeight: '600px', tableHeight: '600px',
emptyText: '',
execTime: 0, execTime: 0,
contextmenu: { contextmenu: {
dropdown: { dropdown: {
@@ -434,7 +432,6 @@ onMounted(async () => {
console.log('in DbTable mounted'); console.log('in DbTable mounted');
state.tableHeight = props.height; state.tableHeight = props.height;
state.loading = props.loading; state.loading = props.loading;
state.emptyText = props.emptyText;
state.dbId = props.dbId; state.dbId = props.dbId;
state.dbType = getNowDbInst().type; state.dbType = getNowDbInst().type;
@@ -461,7 +458,7 @@ const formatDataValues = (datas: any) => {
for (let data of datas) { for (let data of datas) {
for (let column of props.columns!) { for (let column of props.columns!) {
data[column.columnName] = getFormatTimeValue(dbDialect.getDataType(column.columnType), data[column.columnName]); data[column.columnName] = getFormatTimeValue(dbDialect.getDataType(column.dataType), data[column.columnName]);
} }
} }
}; };
@@ -620,6 +617,10 @@ const onDeleteData = async () => {
const db = state.db; const db = state.db;
const dbInst = getNowDbInst(); const dbInst = getNowDbInst();
dbInst.promptExeSql(db, await dbInst.genDeleteByPrimaryKeysSql(db, state.table, deleteDatas as any), null, () => { dbInst.promptExeSql(db, await dbInst.genDeleteByPrimaryKeysSql(db, state.table, deleteDatas as any), null, () => {
// 存在流程则恢复原值,需工单流程审批完后自动执行
if (dbInst.flowProcdef) {
return;
}
emits('dataDelete', deleteDatas); emits('dataDelete', deleteDatas);
}); });
}; };
@@ -631,7 +632,7 @@ const onEditRowData = () => {
return; return;
} }
const data = selectionDatas[0]; const data = selectionDatas[0];
state.tableDataFormDialog.data = data; state.tableDataFormDialog.data = { ...data };
state.tableDataFormDialog.title = `编辑表'${props.table}'数据`; state.tableDataFormDialog.title = `编辑表'${props.table}'数据`;
state.tableDataFormDialog.visible = true; state.tableDataFormDialog.visible = true;
}; };
@@ -677,13 +678,13 @@ const onExportCsv = () => {
columnNames.push(column.columnName); columnNames.push(column.columnName);
} }
} }
exportCsv(`数据导出-${state.table}-${dateStrFormat('yyyyMMddHHmm', new Date().toString())}`, columnNames, dataList); exportCsv(`数据导出-${state.table}-${formatDate(new Date(), 'yyyyMMddHHmm')}`, columnNames, dataList);
}; };
const onExportSql = async () => { const onExportSql = async () => {
const selectionDatas = state.datas; const selectionDatas = state.datas;
exportFile( exportFile(
`数据导出-${state.table}-${dateStrFormat('yyyyMMddHHmm', new Date().toString())}.sql`, `数据导出-${state.table}-${formatDate(new Date(), 'yyyyMMddHHmm')}.sql`,
await getNowDbInst().genInsertSql(state.db, state.table, selectionDatas) await getNowDbInst().genInsertSql(state.db, state.table, selectionDatas)
); );
}; };
@@ -766,7 +767,12 @@ const submitUpdateFields = async () => {
res += await dbInst.genUpdateSql(db, state.table, updateColumnValue, rowData); res += await dbInst.genUpdateSql(db, state.table, updateColumnValue, rowData);
} }
dbInst.promptExeSql(db, res, cancelUpdateFields, () => { dbInst.promptExeSql(db, res, null, () => {
// 存在流程则恢复原值,需工单流程审批完后自动执行
if (dbInst.flowProcdef) {
cancelUpdateFields();
return;
}
triggerRefresh(); triggerRefresh();
cellUpdateMap.clear(); cellUpdateMap.clear();
changeUpdatedField(); changeUpdatedField();
@@ -813,11 +819,11 @@ const getFormatTimeValue = (dataType: DataType, originValue: string): string =>
switch (dataType) { switch (dataType) {
case DataType.Time: case DataType.Time:
return dateStrFormat('HH:mm:ss', originValue); return formatDate(originValue, 'HH:mm:ss');
case DataType.Date: case DataType.Date:
return dateStrFormat('yyyy-MM-dd', originValue); return formatDate(originValue, 'YYYY-MM-DD');
case DataType.DateTime: case DataType.DateTime:
return dateStrFormat('yyyy-MM-dd HH:mm:ss', originValue); return formatDate(originValue, 'YYYY-MM-DD HH:mm:ss');
default: default:
return originValue; return originValue;
} }
@@ -883,8 +889,8 @@ defineExpose({
color: var(--el-color-info-light-3); color: var(--el-color-info-light-3);
font-weight: bold; font-weight: bold;
position: absolute; position: absolute;
top: -5px; top: -7px;
padding: 2px; padding: 1px;
} }
.column-right { .column-right {

View File

@@ -6,7 +6,7 @@
:key="column.columnName" :key="column.columnName"
class="w100 mb5" class="w100 mb5"
:prop="column.columnName" :prop="column.columnName"
:required="column.nullable != 'YES' && !column.isPrimaryKey && !column.isIdentity" :required="!column.nullable && !column.isPrimaryKey && !column.isIdentity"
> >
<template #label> <template #label>
<span class="pointer" :title="`${column.columnType} | ${column.columnComment}`"> <span class="pointer" :title="`${column.columnType} | ${column.columnComment}`">
@@ -16,7 +16,7 @@
<ColumnFormItem <ColumnFormItem
v-model="modelValue[`${column.columnName}`]" v-model="modelValue[`${column.columnName}`]"
:data-type="dbInst.getDialect().getDataType(column.columnType)" :data-type="dbInst.getDialect().getDataType(column.dataType)"
:placeholder="`${column.columnType} ${column.columnComment}`" :placeholder="`${column.columnType} ${column.columnComment}`"
:column-name="column.columnName" :column-name="column.columnName"
:disabled="column.isIdentity" :disabled="column.isIdentity"
@@ -37,7 +37,6 @@ import { ref, watch, onMounted } from 'vue';
import ColumnFormItem from './ColumnFormItem.vue'; import ColumnFormItem from './ColumnFormItem.vue';
import { DbInst } from '../../db'; import { DbInst } from '../../db';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { getDbDialect } from '@/views/ops/db/dialect';
export interface ColumnFormItemProps { export interface ColumnFormItemProps {
dbInst: DbInst; dbInst: DbInst;
@@ -86,35 +85,35 @@ const closeDialog = () => {
}; };
const confirm = async () => { const confirm = async () => {
dataForm.value.validate(async (valid: boolean) => { try {
if (!valid) { await dataForm.value.validate();
ElMessage.error('请正确填写数据信息'); } catch (e: any) {
return false; ElMessage.error('请正确填写数据信息');
} return false;
}
const dbInst = props.dbInst; const dbInst = props.dbInst;
const data = modelValue.value; const data = modelValue.value;
const db = props.dbName; const db = props.dbName;
const tableName = props.tableName; const tableName = props.tableName;
let sql = ''; let sql = '';
if (oldValue) { if (oldValue) {
const updateColumnValue = {}; const updateColumnValue = {};
Object.keys(oldValue).forEach((key) => { Object.keys(oldValue).forEach((key) => {
// 如果新旧值不相等,则为需要更新的字段 // 如果新旧值不相等,则为需要更新的字段
if (oldValue[key] !== modelValue.value[key]) { if (oldValue[key] !== modelValue.value[key]) {
updateColumnValue[key] = modelValue.value[key]; updateColumnValue[key] = modelValue.value[key];
} }
});
sql = await dbInst.genUpdateSql(db, tableName, updateColumnValue, oldValue);
} else {
sql = await dbInst.genInsertSql(db, tableName, [data], true);
}
dbInst.promptExeSql(db, sql, null, () => {
closeDialog();
emit('submitSuccess');
}); });
sql = await dbInst.genUpdateSql(db, tableName, updateColumnValue, oldValue);
} else {
sql = await dbInst.genInsertSql(db, tableName, [data], true);
}
dbInst.promptExeSql(db, sql, null, () => {
closeDialog();
emit('submitSuccess');
}); });
}; };
</script> </script>

View File

@@ -12,15 +12,29 @@
width="auto" width="auto"
title="表格字段配置" title="表格字段配置"
trigger="click" trigger="click"
@hide="triggerCheckedColumns"
> >
<div v-for="(item, index) in columns" :key="index"> <div><el-input v-model="checkedShowColumns.searchKey" size="small" placeholder="输入列名或备注过滤" /></div>
<div>
<el-checkbox <el-checkbox
v-model="item.show" v-model="checkedShowColumns.checkedAllColumn"
:label="`${!item.columnComment ? item.columnName : item.columnName + ' [' + item.columnComment + ']'}`" :indeterminate="checkedShowColumns.isIndeterminate"
:true-value="true" @change="handleCheckAllColumnChange"
:false-value="false"
size="small" size="small"
/> >
选择所有
</el-checkbox>
<el-checkbox-group v-model="checkedShowColumns.columnNames" @change="handleCheckedColumnChange">
<div v-for="(item, index) in filterCheckedColumns" :key="index">
<el-checkbox
:key="index"
:label="`${!item.columnComment ? item.columnName : item.columnName + ' [' + item.columnComment + ']'}`"
:value="item.columnName"
size="small"
/>
</div>
</el-checkbox-group>
</div> </div>
<template #reference> <template #reference>
<el-link icon="Operation" size="small" :underline="false"></el-link> <el-link icon="Operation" size="small" :underline="false"></el-link>
@@ -36,17 +50,6 @@
</el-tooltip> </el-tooltip>
<el-divider direction="vertical" border-style="dashed" /> <el-divider direction="vertical" border-style="dashed" />
<el-tooltip :show-after="500" class="box-item" effect="dark" content="commit" placement="top">
<template #content>
1. 右击数据/表头可显示操作菜单 <br />
2. 按住Ctrl点击数据则为多选 <br />
3. 双击单元格可编辑数据 <br />
4. 鼠标悬停字段名或标签树的表名可提示相关备注
</template>
<el-link icon="QuestionFilled" :underline="false"> </el-link>
</el-tooltip>
<el-divider direction="vertical" border-style="dashed" />
<!-- 表数据展示配置 --> <!-- 表数据展示配置 -->
<el-popover <el-popover
popper-style="max-height: 550px; overflow: auto; max-width: 450px" popper-style="max-height: 550px; overflow: auto; max-width: 450px"
@@ -205,8 +208,8 @@
</el-col> </el-col>
</el-row> </el-row>
<el-dialog v-model="conditionDialog.visible" :title="conditionDialog.title" width="420px"> <el-dialog v-model="conditionDialog.visible" :title="conditionDialog.title" width="460px">
<el-row> <el-row gutter="5">
<el-col :span="5"> <el-col :span="5">
<el-select v-model="conditionDialog.condition"> <el-select v-model="conditionDialog.condition">
<el-option label="=" value="="> </el-option> <el-option label="=" value="="> </el-option>
@@ -329,9 +332,17 @@ const state = reactive({
tableHeight: '600px', tableHeight: '600px',
hasUpdatedFileds: false, hasUpdatedFileds: false,
dbDialect: {} as DbDialect, dbDialect: {} as DbDialect,
checkedShowColumns: {
searchKey: '',
checkedAllColumn: true,
isIndeterminate: false,
columnNames: [] as any,
},
}); });
const { datas, condition, loading, columns, pageNum, pageSize, pageSizes, sql, hasUpdatedFileds, conditionDialog, addDataDialog } = toRefs(state); const { datas, condition, loading, columns, checkedShowColumns, pageNum, pageSize, pageSizes, sql, hasUpdatedFileds, conditionDialog, addDataDialog } =
toRefs(state);
watch( watch(
() => props.tableHeight, () => props.tableHeight,
@@ -351,6 +362,8 @@ onMounted(async () => {
state.dbDialect = getNowDbInst().getDialect(); state.dbDialect = getNowDbInst().getDialect();
useEventListener('click', handlerWindowClick); useEventListener('click', handlerWindowClick);
state.checkedShowColumns.columnNames = state.columns.map((item: any) => item.columnName);
}); });
const handlerWindowClick = () => { const handlerWindowClick = () => {
@@ -414,6 +427,7 @@ const handleSetPageNum = async () => {
state.pageNum = state.setPageNum; state.pageNum = state.setPageNum;
await selectData(); await selectData();
}; };
const handleCount = async () => { const handleCount = async () => {
state.counting = true; state.counting = true;
@@ -431,6 +445,24 @@ const handleCount = async () => {
state.counting = false; state.counting = false;
}; };
const handleCheckAllColumnChange = (val: boolean) => {
state.checkedShowColumns.columnNames = val ? state.columns.map((x: any) => x.columnName) : [];
state.checkedShowColumns.isIndeterminate = false;
};
const handleCheckedColumnChange = (value: string[]) => {
const checkedCount = value.length;
state.checkedShowColumns.checkedAllColumn = checkedCount === state.columns.length;
state.checkedShowColumns.isIndeterminate = checkedCount > 0 && checkedCount < state.columns.length;
};
const triggerCheckedColumns = () => {
const checkedColumnNames = state.checkedShowColumns.columnNames;
for (let column of state.columns) {
column.show = checkedColumnNames.includes(column.columnName);
}
};
// 完整的条件,每次选中后会重置条件框内容,故需要这个变量在获取建议时将文本框内容保存 // 完整的条件,每次选中后会重置条件框内容,故需要这个变量在获取建议时将文本框内容保存
let completeCond = ''; let completeCond = '';
// 是否存在列建议 // 是否存在列建议
@@ -463,7 +495,7 @@ const handlerColumnSelect = (column: any) => {
// 默认拼接上 columnName = // 默认拼接上 columnName =
let value = column.columnName + ' = '; let value = column.columnName + ' = ';
// 不是数字类型默认拼接上'' // 不是数字类型默认拼接上''
if (!DbInst.isNumber(column.columnType)) { if (!DbInst.isNumber(column.dataType)) {
value = `${value} ''`; value = `${value} ''`;
} }
@@ -490,16 +522,23 @@ const chooseCondColumnName = () => {
* 过滤条件列名 * 过滤条件列名
*/ */
const filterCondColumns = computed(() => { const filterCondColumns = computed(() => {
return filterColumns(state.columnNameSearch);
});
const filterCheckedColumns = computed(() => {
return filterColumns(state.checkedShowColumns.searchKey);
});
const filterColumns = (searchKey: string) => {
const columns = state.columns; const columns = state.columns;
let columnNameSearch = state.columnNameSearch; if (!searchKey) {
if (!columnNameSearch) {
return columns; return columns;
} }
columnNameSearch = columnNameSearch.toLowerCase(); searchKey = searchKey.toLowerCase();
return columns.filter((data: any) => { return columns.filter((data: any) => {
return data.columnName.toLowerCase().includes(columnNameSearch) || data.columnComment.toLowerCase().includes(columnNameSearch); return data.columnName.toLowerCase().includes(searchKey) || data.columnComment.toLowerCase().includes(searchKey);
}); });
}); };
/** /**
* 条件查询,点击列信息后显示输入对应的值 * 条件查询,点击列信息后显示输入对应的值
@@ -524,7 +563,7 @@ const onConfirmCondition = () => {
} }
const row = conditionDialog.columnRow as any; const row = conditionDialog.columnRow as any;
condition += `${row.columnName} ${conditionDialog.condition} `; condition += `${row.columnName} ${conditionDialog.condition} `;
state.condition = condition + state.dbDialect.wrapValue(row.columnType, conditionDialog.value!); state.condition = condition + state.dbDialect.wrapValue(row.dataType, conditionDialog.value!);
onCancelCondition(); onCancelCondition();
condInputRef.value.focus(); condInputRef.value.focus();
}; };

View File

@@ -131,6 +131,7 @@ import { reactive, ref, toRefs, watch } from 'vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import SqlExecBox from '../sqleditor/SqlExecBox'; import SqlExecBox from '../sqleditor/SqlExecBox';
import { DbType, getDbDialect, IndexDefinition, RowDefinition } from '../../dialect/index'; import { DbType, getDbDialect, IndexDefinition, RowDefinition } from '../../dialect/index';
import { DbInst } from '../../db';
const props = defineProps({ const props = defineProps({
visible: { visible: {
@@ -151,8 +152,8 @@ const props = defineProps({
dbType: { dbType: {
type: String, type: String,
}, },
flowProcdefKey: { flowProcdef: {
type: String, type: Object,
}, },
}); });
@@ -192,7 +193,7 @@ const state = reactive({
}, },
{ {
prop: 'numScale', prop: 'numScale',
label: '小数', label: '小数精度',
width: 120, width: 120,
}, },
{ {
@@ -334,7 +335,7 @@ const submit = async () => {
dbId: props.dbId as any, dbId: props.dbId as any,
db: props.db as any, db: props.db as any,
dbType: dbDialect.getInfo().formatSqlDialect, dbType: dbDialect.getInfo().formatSqlDialect,
flowProcdefKey: props.flowProcdefKey, flowProcdef: props.flowProcdef,
runSuccessCallback: () => { runSuccessCallback: () => {
emit('submit-sql', { tableName: state.tableData.tableName }); emit('submit-sql', { tableName: state.tableData.tableName });
// cancel(); // cancel();
@@ -412,7 +413,6 @@ const filterChangedData = (oldArr: object[], nowArr: object[], key: string): { d
const genSql = () => { const genSql = () => {
let data = state.tableData; let data = state.tableData;
console.log(data);
// 创建表 // 创建表
if (!props.data?.edit) { if (!props.data?.edit) {
let createTable = dbDialect.getCreateTableSql(data); let createTable = dbDialect.getCreateTableSql(data);
@@ -501,12 +501,10 @@ watch(
state.tableData.indexs.res = []; state.tableData.indexs.res = [];
// 索引列下拉选 // 索引列下拉选
state.tableData.indexs.columns = []; state.tableData.indexs.columns = [];
DbInst.initColumns(columns);
// 回显列 // 回显列
if (columns && Array.isArray(columns) && columns.length > 0) { if (columns && Array.isArray(columns) && columns.length > 0) {
columns.forEach((a) => { columns.forEach((a) => {
let typeObj = a.columnType.replace(')', '').split('(');
let type = typeObj[0];
let length = (typeObj.length > 1 && typeObj[1]) || '';
let defaultValue = ''; let defaultValue = '';
if (a.columnDefault) { if (a.columnDefault) {
defaultValue = a.columnDefault.trim().replace(/^'|'$/g, ''); defaultValue = a.columnDefault.trim().replace(/^'|'$/g, '');
@@ -516,11 +514,11 @@ watch(
let data = { let data = {
name: a.columnName, name: a.columnName,
oldName: a.columnName, oldName: a.columnName,
type, type: a.dataType,
value: defaultValue, value: defaultValue,
length, length: a.showLength,
numScale: a.numScale, numScale: a.showScale,
notNull: a.nullable !== 'YES', notNull: !a.nullable,
pri: a.isPrimaryKey, pri: a.isPrimaryKey,
auto_increment: a.isIdentity /*a.extra?.indexOf('auto_increment') > -1*/, auto_increment: a.isIdentity /*a.extra?.indexOf('auto_increment') > -1*/,
remark: a.columnComment, remark: a.columnComment,

View File

@@ -1,28 +1,27 @@
<template> <template>
<div class="db-table"> <div class="db-table">
<el-row class="mb5"> <el-row class="mb5">
<el-popover v-model:visible="showDumpInfo" :width="470" placement="right" trigger="click"> <el-popover v-model:visible="state.dumpInfo.visible" trigger="click" :width="470" placement="right">
<template #reference> <template #reference>
<el-button class="ml5" type="success" size="small">导出</el-button> <el-button :disabled="state.dumpInfo.tables?.length == 0" class="ml5" type="success" size="small">导出</el-button>
</template> </template>
<el-form-item label="导出内容: "> <el-form-item label="导出内容: ">
<el-radio-group v-model="dumpInfo.type"> <el-radio-group v-model="dumpInfo.type">
<el-radio :label="1" size="small">结构</el-radio> <el-radio :value="1" size="small">结构</el-radio>
<el-radio :label="2" size="small">数据</el-radio> <el-radio :value="2" size="small">数据</el-radio>
<el-radio :label="3" size="small">结构数据</el-radio> <el-radio :value="3" size="small">结构数据</el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item label="导出表: "> <el-form-item>
<el-table @selection-change="handleDumpTableSelectionChange" max-height="300" size="small" :data="tables"> <el-table :data="state.dumpInfo.tables" empty-text="请先选择要导出的表" max-height="300" size="small">
<el-table-column type="selection" width="45" />
<el-table-column property="tableName" label="表名" min-width="150" show-overflow-tooltip> </el-table-column> <el-table-column property="tableName" label="表名" min-width="150" show-overflow-tooltip> </el-table-column>
<el-table-column property="tableComment" label="备注" min-width="150" show-overflow-tooltip> </el-table-column> <el-table-column property="tableComment" label="备注" min-width="150" show-overflow-tooltip> </el-table-column>
</el-table> </el-table>
</el-form-item> </el-form-item>
<div style="text-align: right"> <div style="text-align: right">
<el-button @click="showDumpInfo = false" size="small">取消</el-button> <el-button @click="state.dumpInfo.visible = false" size="small">取消</el-button>
<el-button @click="dump(db)" type="success" size="small">确定</el-button> <el-button @click="dump(db)" type="success" size="small">确定</el-button>
</div> </div>
</el-popover> </el-popover>
@@ -30,7 +29,9 @@
<el-button type="primary" size="small" @click="openEditTable(false)">创建表</el-button> <el-button type="primary" size="small" @click="openEditTable(false)">创建表</el-button>
</el-row> </el-row>
<el-table v-loading="loading" border stripe :data="filterTableInfos" size="small" :height="height"> <el-table v-loading="loading" @selection-change="handleDumpTableSelectionChange" border stripe :data="filterTableInfos" size="small" :height="height">
<el-table-column type="selection" width="30" />
<el-table-column property="tableName" label="表名" min-width="150" show-overflow-tooltip> <el-table-column property="tableName" label="表名" min-width="150" show-overflow-tooltip>
<template #header> <template #header>
<el-input v-model="tableNameSearch" size="small" placeholder="表名: 输入可过滤" clearable /> <el-input v-model="tableNameSearch" size="small" placeholder="表名: 输入可过滤" clearable />
@@ -98,8 +99,8 @@
</el-table> </el-table>
</el-dialog> </el-dialog>
<el-dialog width="55%" :title="`${chooseTableName} Create-DDL`" v-model="ddlDialog.visible"> <el-dialog width="55%" :title="`'${chooseTableName}' DDL`" v-model="ddlDialog.visible">
<el-input disabled type="textarea" :autosize="{ minRows: 15, maxRows: 30 }" v-model="ddlDialog.ddl" size="small"> </el-input> <monaco-editor height="400px" language="sql" v-model="ddlDialog.ddl" :options="{ readOnly: true }" />
</el-dialog> </el-dialog>
<db-table-op <db-table-op
@@ -108,7 +109,7 @@
:dbId="dbId" :dbId="dbId"
:db="db" :db="db"
:dbType="dbType" :dbType="dbType"
:flow-procdef-key="props.flowProcdefKey" :flow-procdef="props.flowProcdef"
:data="tableCreateDialog.data" :data="tableCreateDialog.data"
v-model:visible="tableCreateDialog.visible" v-model:visible="tableCreateDialog.visible"
@submit-sql="onSubmitSql" @submit-sql="onSubmitSql"
@@ -126,7 +127,10 @@ import SqlExecBox from '../sqleditor/SqlExecBox';
import config from '@/common/config'; import config from '@/common/config';
import { joinClientParams } from '@/common/request'; import { joinClientParams } from '@/common/request';
import { isTrue } from '@/common/assert'; import { isTrue } from '@/common/assert';
import { compatibleMysql, editDbTypes } from '../../dialect/index'; import { compatibleMysql, editDbTypes, getDbDialect } from '../../dialect/index';
import { DbInst } from '../../db';
import MonacoEditor from '@/components/monaco/MonacoEditor.vue';
import { format as sqlFormatter } from 'sql-formatter';
const DbTableOp = defineAsyncComponent(() => import('./DbTableOp.vue')); const DbTableOp = defineAsyncComponent(() => import('./DbTableOp.vue'));
@@ -147,8 +151,8 @@ const props = defineProps({
type: [String], type: [String],
required: true, required: true,
}, },
flowProcdefKey: { flowProcdef: {
type: [String], type: [Object],
}, },
}); });
@@ -158,8 +162,8 @@ const state = reactive({
tables: [], tables: [],
tableNameSearch: '', tableNameSearch: '',
tableCommentSearch: '', tableCommentSearch: '',
showDumpInfo: false,
dumpInfo: { dumpInfo: {
visible: false,
id: 0, id: 0,
db: '', db: '',
type: 3, type: 3,
@@ -198,19 +202,7 @@ const state = reactive({
}, },
}); });
const { const { loading, tableNameSearch, tableCommentSearch, dumpInfo, chooseTableName, columnDialog, indexDialog, ddlDialog, tableCreateDialog } = toRefs(state);
loading,
tables,
tableNameSearch,
tableCommentSearch,
showDumpInfo,
dumpInfo,
chooseTableName,
columnDialog,
indexDialog,
ddlDialog,
tableCreateDialog,
} = toRefs(state);
onMounted(async () => { onMounted(async () => {
getTables(); getTables();
@@ -256,30 +248,33 @@ const getTables = async () => {
* 选择导出数据库表 * 选择导出数据库表
*/ */
const handleDumpTableSelectionChange = (vals: any) => { const handleDumpTableSelectionChange = (vals: any) => {
state.dumpInfo.tables = vals.map((x: any) => x.tableName); state.dumpInfo.tables = vals;
}; };
/** /**
* 数据库信息导出 * 数据库信息导出
*/ */
const dump = (db: string) => { const dump = (db: string) => {
isTrue(state.dumpInfo.tables.length > 0, '请选择要导出的表'); isTrue(state.dumpInfo.tables.length > 0, '请选择要导出的表');
const tableNames = state.dumpInfo.tables.map((x: any) => x.tableName);
const a = document.createElement('a'); const a = document.createElement('a');
a.setAttribute( a.setAttribute(
'href', 'href',
`${config.baseApiUrl}/dbs/${props.dbId}/dump?db=${db}&type=${state.dumpInfo.type}&tables=${state.dumpInfo.tables.join(',')}&${joinClientParams()}` `${config.baseApiUrl}/dbs/${props.dbId}/dump?db=${db}&type=${state.dumpInfo.type}&tables=${tableNames.join(',')}&${joinClientParams()}`
); );
a.click(); a.click();
state.showDumpInfo = false; state.dumpInfo.visible = false;
}; };
const showColumns = async (row: any) => { const showColumns = async (row: any) => {
state.chooseTableName = row.tableName; state.chooseTableName = row.tableName;
state.columnDialog.columns = await dbApi.columnMetadata.request({ const columns = await dbApi.columnMetadata.request({
id: props.dbId, id: props.dbId,
db: props.db, db: props.db,
tableName: row.tableName, tableName: row.tableName,
}); });
DbInst.initColumns(columns);
state.columnDialog.columns = columns;
state.columnDialog.visible = true; state.columnDialog.visible = true;
}; };
@@ -302,7 +297,8 @@ const showCreateDdl = async (row: any) => {
db: props.db, db: props.db,
tableName: row.tableName, tableName: row.tableName,
}); });
state.ddlDialog.ddl = res;
state.ddlDialog.ddl = sqlFormatter(res, { language: getDbDialect(props.dbType).getInfo().formatSqlDialect as any });
state.ddlDialog.visible = true; state.ddlDialog.visible = true;
}; };
@@ -321,7 +317,7 @@ const dropTable = async (row: any) => {
sql: `DROP TABLE ${tableName}`, sql: `DROP TABLE ${tableName}`,
dbId: props.dbId as any, dbId: props.dbId as any,
db: props.db as any, db: props.db as any,
flowProcdefKey: props.flowProcdefKey, flowProcdef: props.flowProcdef,
runSuccessCallback: async () => { runSuccessCallback: async () => {
await getTables(); await getTables();
}, },

View File

@@ -8,6 +8,7 @@ import { editor, languages, Position } from 'monaco-editor';
import { registerCompletionItemProvider } from '@/components/monaco/completionItemProvider'; import { registerCompletionItemProvider } from '@/components/monaco/completionItemProvider';
import { DbDialect, EditorCompletionItem, getDbDialect } from './dialect'; import { DbDialect, EditorCompletionItem, getDbDialect } from './dialect';
import { type RemovableRef, useLocalStorage } from '@vueuse/core'; import { type RemovableRef, useLocalStorage } from '@vueuse/core';
import { DbGetDbNamesMode } from './enums';
const hintsStorage: RemovableRef<Map<string, any>> = useLocalStorage('db-table-hints', new Map()); const hintsStorage: RemovableRef<Map<string, any>> = useLocalStorage('db-table-hints', new Map());
const tableStorage: RemovableRef<Map<string, any>> = useLocalStorage('db-tables', new Map()); const tableStorage: RemovableRef<Map<string, any>> = useLocalStorage('db-tables', new Map());
@@ -41,9 +42,9 @@ export class DbInst {
type: string; type: string;
/** /**
* 流程定义key,若存在则需要审批执行 * 流程定义,若存在则需要审批执行
*/ */
flowProcdefKey: string; flowProcdef: any;
/** /**
* dbName -> db * dbName -> db
@@ -175,6 +176,9 @@ export class DbInst {
db: dbName, db: dbName,
tableName: table, tableName: table,
}); });
DbInst.initColumns(columns);
db.columnsMap.set(table, columns); db.columnsMap.set(table, columns);
return columns; return columns;
} }
@@ -317,7 +321,7 @@ export class DbInst {
let sql = `UPDATE ${schema}${this.wrapName(table)} SET `; let sql = `UPDATE ${schema}${this.wrapName(table)} SET `;
// 主键列信息 // 主键列信息
const primaryKey = await this.loadTableColumn(dbName, table); const primaryKey = await this.loadTableColumn(dbName, table);
let primaryKeyType = primaryKey.columnType; let primaryKeyType = primaryKey.dataType;
let primaryKeyName = primaryKey.columnName; let primaryKeyName = primaryKey.columnName;
let primaryKeyValue = rowData[primaryKeyName]; let primaryKeyValue = rowData[primaryKeyName];
const dialect = this.getDialect(); const dialect = this.getDialect();
@@ -325,7 +329,7 @@ export class DbInst {
const v = columnValue[k]; const v = columnValue[k];
// 更新字段列信息 // 更新字段列信息
const updateColumn = await this.loadTableColumn(dbName, table, k); const updateColumn = await this.loadTableColumn(dbName, table, k);
sql += ` ${this.wrapName(k)} = ${dialect.wrapValue(updateColumn.columnType, v)},`; sql += ` ${this.wrapName(k)} = ${dialect.wrapValue(updateColumn.dataType, v)},`;
} }
sql = sql.substring(0, sql.length - 1); sql = sql.substring(0, sql.length - 1);
@@ -341,7 +345,7 @@ export class DbInst {
async genDeleteByPrimaryKeysSql(db: string, table: string, datas: any[]) { async genDeleteByPrimaryKeysSql(db: string, table: string, datas: any[]) {
const primaryKey = await this.loadTableColumn(db, table); const primaryKey = await this.loadTableColumn(db, table);
const primaryKeyColumnName = primaryKey.columnName; const primaryKeyColumnName = primaryKey.columnName;
const ids = datas.map((d: any) => `${this.getDialect().wrapValue(primaryKey.columnType, d[primaryKeyColumnName])}`).join(','); const ids = datas.map((d: any) => `${this.getDialect().wrapValue(primaryKey.dataType, d[primaryKeyColumnName])}`).join(',');
return `DELETE FROM ${this.wrapName(table)} WHERE ${this.wrapName(primaryKeyColumnName)} IN (${ids})`; return `DELETE FROM ${this.wrapName(table)} WHERE ${this.wrapName(primaryKeyColumnName)} IN (${ids})`;
} }
@@ -356,7 +360,7 @@ export class DbInst {
dbType: this.getDialect().getInfo().formatSqlDialect, dbType: this.getDialect().getInfo().formatSqlDialect,
runSuccessCallback: successFunc, runSuccessCallback: successFunc,
cancelCallback: cancelFunc, cancelCallback: cancelFunc,
flowProcdefKey: this.flowProcdefKey, flowProcdef: this.flowProcdef,
}); });
}; };
@@ -380,6 +384,11 @@ export class DbInst {
} }
let dbInst = dbInstCache.get(inst.id); let dbInst = dbInstCache.get(inst.id);
if (dbInst) { if (dbInst) {
// 更新可能更改的流程定义
if (inst.flowProcdef !== undefined) {
dbInst.flowProcdef = inst.flowProcdef;
dbInstCache.set(dbInst.id, dbInst);
}
return dbInst; return dbInst;
} }
console.info(`new dbInst: ${inst.id}, tagPath: ${inst.tagPath}`); console.info(`new dbInst: ${inst.id}, tagPath: ${inst.tagPath}`);
@@ -390,7 +399,7 @@ export class DbInst {
dbInst.name = inst.name; dbInst.name = inst.name;
dbInst.type = inst.type; dbInst.type = inst.type;
dbInst.databases = inst.databases; dbInst.databases = inst.databases;
dbInst.flowProcdefKey = inst.flowProcdefKey; dbInst.flowProcdef = inst.flowProcdef;
dbInstCache.set(dbInst.id, dbInst); dbInstCache.set(dbInst.id, dbInst);
return dbInst; return dbInst;
@@ -426,7 +435,7 @@ export class DbInst {
* @returns * @returns
*/ */
static isNumber(columnType: string) { static isNumber(columnType: string) {
return columnType.match(/int|double|float|number|decimal|byte|bit/gi); return columnType && columnType.match(/int|double|float|number|decimal|byte|bit/gi);
} }
/** /**
@@ -466,6 +475,48 @@ export class DbInst {
const flexWidth: number = contentWidth > columnWidth ? contentWidth : columnWidth; const flexWidth: number = contentWidth > columnWidth ? contentWidth : columnWidth;
return flexWidth > 500 ? 500 : flexWidth; return flexWidth > 500 ? 500 : flexWidth;
}; };
// 初始化所有列信息完善需要显示的列类型包含长度等如varchar(20)
static initColumns(columns: any[]) {
if (!columns) {
return;
}
for (let col of columns) {
if (col.charMaxLength > 0) {
col.columnType = `${col.dataType}(${col.charMaxLength})`;
col.showLength = col.charMaxLength;
col.showScale = null;
continue;
}
if (col.numPrecision > 0) {
if (col.numScale > 0) {
col.columnType = `${col.dataType}(${col.numPrecision},${col.numScale})`;
col.showScale = col.numScale;
} else {
col.columnType = `${col.dataType}(${col.numPrecision})`;
col.showScale = null;
}
col.showLength = col.numPrecision;
continue;
}
col.columnType = col.dataType;
}
}
/**
* 根据数据库配置信息获取对应的库名列表
* @param db db配置信息
* @returns 库名列表
*/
static async getDbNames(db: any) {
if (db.getDatabaseMode == DbGetDbNamesMode.Assign.value) {
return db.database.split(' ');
}
return await dbApi.getDbNamesByAc.request({ authCertName: db.authCertName });
}
} }
/** /**

View File

@@ -7,6 +7,7 @@ import {
DuplicateStrategy, DuplicateStrategy,
EditorCompletion, EditorCompletion,
EditorCompletionItem, EditorCompletionItem,
QuoteEscape,
IndexDefinition, IndexDefinition,
RowDefinition, RowDefinition,
sqlColumnType, sqlColumnType,
@@ -35,7 +36,6 @@ const DM_TYPE_LIST: sqlColumnType[] = [
// 位串数据类型 BIT 用于存储整数数据 1、0 或 NULL只有 0 才转换为假,其他非空、非 0 值都会自动转换为真 // 位串数据类型 BIT 用于存储整数数据 1、0 或 NULL只有 0 才转换为假,其他非空、非 0 值都会自动转换为真
{ udtName: 'BIT', dataType: 'BIT', desc: '用于存储整数数据 1、0 或 NULL', space: '1', range: '1' }, { udtName: 'BIT', dataType: 'BIT', desc: '用于存储整数数据 1、0 或 NULL', space: '1', range: '1' },
// 一般日期时间数据类型 DATE TIME TIMESTAMP 默认精度 6 // 一般日期时间数据类型 DATE TIME TIMESTAMP 默认精度 6
// 多媒体数据类型 TEXT/LONG/LONGVARCHAR 类型:变长字符串类型 IMAGE/LONGVARBINARY 类型 BLOB CLOB BFILE 100G-1
{ udtName: 'DATE', dataType: 'DATE', desc: '年、月、日', space: '', range: '' }, { udtName: 'DATE', dataType: 'DATE', desc: '年、月、日', space: '', range: '' },
{ udtName: 'TIME', dataType: 'TIME', desc: '时、分、秒', space: '', range: '' }, { udtName: 'TIME', dataType: 'TIME', desc: '时、分、秒', space: '', range: '' },
{ {
@@ -45,6 +45,7 @@ const DM_TYPE_LIST: sqlColumnType[] = [
space: '', space: '',
range: '-4712-01-01 00:00:00.000000000 ~ 9999-12-31 23:59:59.999999999', range: '-4712-01-01 00:00:00.000000000 ~ 9999-12-31 23:59:59.999999999',
}, },
// 多媒体数据类型 TEXT/LONG/LONGVARCHAR 类型:变长字符串类型 IMAGE/LONGVARBINARY 类型 BLOB CLOB BFILE 100G-1
{ udtName: 'TEXT', dataType: 'TEXT', desc: '变长字符串', space: '', range: '100G-1' }, { udtName: 'TEXT', dataType: 'TEXT', desc: '变长字符串', space: '', range: '100G-1' },
{ udtName: 'LONG', dataType: 'LONG', desc: '同TEXT', space: '', range: '100G-1' }, { udtName: 'LONG', dataType: 'LONG', desc: '同TEXT', space: '', range: '100G-1' },
{ udtName: 'LONGVARCHAR', dataType: 'LONGVARCHAR', desc: '同TEXT', space: '', range: '100G-1' }, { udtName: 'LONGVARCHAR', dataType: 'LONGVARCHAR', desc: '同TEXT', space: '', range: '100G-1' },
@@ -523,7 +524,7 @@ class DMDialect implements DbDialect {
} }
// 列注释 // 列注释
if (item.remark) { if (item.remark) {
columCommentSql += ` comment on column "${data.tableName}"."${item.name}" is '${item.remark}'; `; columCommentSql += ` comment on column "${data.tableName}"."${item.name}" is '${QuoteEscape(item.remark)}'; `;
} }
}); });
// 建表 // 建表
@@ -534,7 +535,7 @@ class DMDialect implements DbDialect {
);`; );`;
// 表注释 // 表注释
if (data.tableComment) { if (data.tableComment) {
tableCommentSql = ` comment on table "${data.tableName}" is '${data.tableComment}'; `; tableCommentSql = ` comment on table "${data.tableName}" is '${QuoteEscape(data.tableComment)}'; `;
} }
return createSql + tableCommentSql + columCommentSql; return createSql + tableCommentSql + columCommentSql;
@@ -569,7 +570,7 @@ class DMDialect implements DbDialect {
changeData.add.forEach((a) => { changeData.add.forEach((a) => {
modifySql += `ALTER TABLE ${dbTable} add COLUMN ${this.genColumnBasicSql(a)};`; modifySql += `ALTER TABLE ${dbTable} add COLUMN ${this.genColumnBasicSql(a)};`;
if (a.remark) { if (a.remark) {
commentSql += `COMMENT ON COLUMN ${dbTable}.${this.quoteIdentifier(a.name)} IS '${a.remark}';`; commentSql += `COMMENT ON COLUMN ${dbTable}.${this.quoteIdentifier(a.name)} IS '${QuoteEscape(a.remark)}';`;
} }
if (a.pri) { if (a.pri) {
priArr.add(`"${a.name}"`); priArr.add(`"${a.name}"`);
@@ -579,7 +580,7 @@ class DMDialect implements DbDialect {
if (changeData.upd.length > 0) { if (changeData.upd.length > 0) {
changeData.upd.forEach((a) => { changeData.upd.forEach((a) => {
let cmtSql = `COMMENT ON COLUMN ${dbTable}.${this.quoteIdentifier(a.name)} IS '${a.remark}';`; let cmtSql = `COMMENT ON COLUMN ${dbTable}.${this.quoteIdentifier(a.name)} IS '${QuoteEscape(a.remark)}';`;
if (a.remark && a.oldName === a.name) { if (a.remark && a.oldName === a.name) {
commentSql += cmtSql; commentSql += cmtSql;
} }
@@ -675,7 +676,7 @@ class DMDialect implements DbDialect {
} }
if (tableData.oldTableComment !== tableData.tableComment) { if (tableData.oldTableComment !== tableData.tableComment) {
let baseTable = `${this.quoteIdentifier(schema)}.${this.quoteIdentifier(tableData.tableName)}`; let baseTable = `${this.quoteIdentifier(schema)}.${this.quoteIdentifier(tableData.tableName)}`;
sql += `COMMENT ON TABLE ${baseTable} IS '${tableData.tableComment}';`; sql += `COMMENT ON TABLE ${baseTable} IS '${QuoteEscape(tableData.tableComment)}';`;
} }
return sql; return sql;
} }

View File

@@ -262,6 +262,18 @@ export const getDbDialect = (dbType?: string): DbDialect => {
return dbType2DialectMap.get(dbType!) || mysqlDialect; return dbType2DialectMap.get(dbType!) || mysqlDialect;
}; };
/**
* 引号转义多用于sql注释转义防止拼接sql报错 comment xx is '注''释' 最终注释文本为: 注'释
* @author liuzongyang
* @since 2024/3/22 08:23
*/
export const QuoteEscape = (str: string): string => {
if (!str) {
return '';
}
return str.replace(/'/g, "''");
};
(function () { (function () {
console.log('init register db dialect'); console.log('init register db dialect');
registerDbDialect(DbType.mysql, mysqlDialect); registerDbDialect(DbType.mysql, mysqlDialect);

View File

@@ -7,6 +7,7 @@ import {
DuplicateStrategy, DuplicateStrategy,
EditorCompletion, EditorCompletion,
EditorCompletionItem, EditorCompletionItem,
QuoteEscape,
IndexDefinition, IndexDefinition,
RowDefinition, RowDefinition,
} from './index'; } from './index';
@@ -225,7 +226,7 @@ class MssqlDialect implements DbDialect {
item.name && fields.push(this.genColumnBasicSql(item)); item.name && fields.push(this.genColumnBasicSql(item));
item.remark && item.remark &&
fieldComments.push( fieldComments.push(
`EXECUTE sp_addextendedproperty N'MS_Description', N'${item.remark}', N'SCHEMA', N'${schema}', N'TABLE', N'${data.tableName}', N'COLUMN', N'${item.name}'` `EXECUTE sp_addextendedproperty N'MS_Description', N'${QuoteEscape(item.remark)}', N'SCHEMA', N'${schema}', N'TABLE', N'${data.tableName}', N'COLUMN', N'${item.name}'`
); );
if (item.pri) { if (item.pri) {
pks.push(`${this.quoteIdentifier(item.name)}`); pks.push(`${this.quoteIdentifier(item.name)}`);
@@ -244,7 +245,7 @@ class MssqlDialect implements DbDialect {
// 表注释 // 表注释
if (data.tableComment) { if (data.tableComment) {
createTable += ` EXECUTE sp_addextendedproperty N'MS_Description', N'${data.tableComment}', N'SCHEMA', N'${schema}', N'TABLE', N'${data.tableName}';`; createTable += ` EXECUTE sp_addextendedproperty N'MS_Description', N'${QuoteEscape(data.tableComment)}', N'SCHEMA', N'${schema}', N'TABLE', N'${data.tableName}';`;
} }
return createTable + createIndexSql + fieldComments.join(';'); return createTable + createIndexSql + fieldComments.join(';');
@@ -268,7 +269,7 @@ class MssqlDialect implements DbDialect {
sql.push(` CREATE ${a.unique ? 'UNIQUE' : ''} NONCLUSTERED INDEX ${this.quoteIdentifier(a.indexName)} on ${baseTable} (${columnNames.join(',')})`); sql.push(` CREATE ${a.unique ? 'UNIQUE' : ''} NONCLUSTERED INDEX ${this.quoteIdentifier(a.indexName)} on ${baseTable} (${columnNames.join(',')})`);
if (a.indexComment) { if (a.indexComment) {
indexComment.push( indexComment.push(
`EXECUTE sp_addextendedproperty N'MS_Description', N'${a.indexComment}', N'SCHEMA', N'${schema}', N'TABLE', N'${data.tableName}', N'INDEX', N'${a.indexName}'` `EXECUTE sp_addextendedproperty N'MS_Description', N'${QuoteEscape(a.indexComment)}', N'SCHEMA', N'${schema}', N'TABLE', N'${data.tableName}', N'INDEX', N'${a.indexName}'`
); );
} }
}); });
@@ -303,10 +304,10 @@ class MssqlDialect implements DbDialect {
} }
if (changeData.add.length > 0) { if (changeData.add.length > 0) {
changeData.add.forEach((a) => { changeData.add.forEach((a) => {
addArr.push(` ALTER TABLE ${baseTable} ADD COLUMN ${this.genColumnBasicSql(a)}`); addArr.push(` ALTER TABLE ${baseTable} ADD ${this.genColumnBasicSql(a)}`);
if (a.remark) { if (a.remark) {
addCommentArr.push( addCommentArr.push(
`EXECUTE sp_addextendedproperty N'MS_Description', N'${a.remark}', N'SCHEMA', N'${schema}', N'TABLE', N'${tableName}', N'COLUMN', N'${a.name}'` `EXECUTE sp_addextendedproperty N'MS_Description', N'${QuoteEscape(a.remark)}', N'SCHEMA', N'${schema}', N'TABLE', N'${tableName}', N'COLUMN', N'${a.name}'`
); );
} }
}); });
@@ -315,7 +316,7 @@ class MssqlDialect implements DbDialect {
if (changeData.upd.length > 0) { if (changeData.upd.length > 0) {
changeData.upd.forEach((a) => { changeData.upd.forEach((a) => {
if (a.oldName && a.name !== a.oldName) { if (a.oldName && a.name !== a.oldName) {
renameArr.push(` EXEC sp_rename '${baseTable}.${this.quoteIdentifier(a.oldName)}', '${a.name}', 'COLUMN' `); renameArr.push(` EXEC sp_rename '${baseTable}.${this.quoteIdentifier(a.oldName)}', '${QuoteEscape(a.name)}', 'COLUMN' `);
} else { } else {
updArr.push(` ALTER TABLE ${baseTable} ALTER COLUMN ${this.genColumnBasicSql(a)} `); updArr.push(` ALTER TABLE ${baseTable} ALTER COLUMN ${this.genColumnBasicSql(a)} `);
} }
@@ -325,13 +326,13 @@ class MssqlDialect implements DbDialect {
'TABLE', N'${tableName}', 'TABLE', N'${tableName}',
'COLUMN', N'${a.name}')) > 0) 'COLUMN', N'${a.name}')) > 0)
EXEC sp_updateextendedproperty EXEC sp_updateextendedproperty
'MS_Description', N'${a.remark}', 'MS_Description', N'${QuoteEscape(a.remark)}',
'SCHEMA', N'${schema}', 'SCHEMA', N'${schema}',
'TABLE', N'${tableName}', 'TABLE', N'${tableName}',
'COLUMN', N'${a.name}' 'COLUMN', N'${a.name}'
ELSE ELSE
EXEC sp_addextendedproperty EXEC sp_addextendedproperty
'MS_Description', N'${a.remark}', 'MS_Description', N'${QuoteEscape(a.remark)}',
'SCHEMA', N'${schema}', 'SCHEMA', N'${schema}',
'TABLE', N'${tableName}', 'TABLE', N'${tableName}',
'COLUMN',N'${a.name}'`); 'COLUMN',N'${a.name}'`);
@@ -367,7 +368,7 @@ ELSE
); );
if (a.indexComment) { if (a.indexComment) {
commentArr.push( commentArr.push(
` EXEC sp_addextendedproperty N'MS_Description', N'${a.indexComment}', N'SCHEMA', N'${schema}', N'TABLE', N'${tableName}', N'INDEX', N'${a.indexName}' ` ` EXEC sp_addextendedproperty N'MS_Description', N'${QuoteEscape(a.indexComment)}', N'SCHEMA', N'${schema}', N'TABLE', N'${tableName}', N'INDEX', N'${a.indexName}' `
); );
} }
}; };
@@ -413,7 +414,7 @@ ELSE
if (tableData.oldTableComment !== tableData.tableComment) { if (tableData.oldTableComment !== tableData.tableComment) {
// 转义注释中的单引号和换行符 // 转义注释中的单引号和换行符
let tableComment = tableData.tableComment.replaceAll(/'/g, '').replaceAll(/[\r\n]/g, ' '); let tableComment = tableData.tableComment.replaceAll(/'/g, "'").replaceAll(/[\r\n]/g, ' ');
sql += `IF ((SELECT COUNT(*) FROM fn_listextendedproperty('MS_Description', sql += `IF ((SELECT COUNT(*) FROM fn_listextendedproperty('MS_Description',
'SCHEMA', N'${schema}', 'SCHEMA', N'${schema}',
'TABLE', N'${tableData.tableName}', NULL, NULL)) > 0) 'TABLE', N'${tableData.tableName}', NULL, NULL)) > 0)

View File

@@ -7,6 +7,7 @@ import {
DuplicateStrategy, DuplicateStrategy,
EditorCompletion, EditorCompletion,
EditorCompletionItem, EditorCompletionItem,
QuoteEscape,
IndexDefinition, IndexDefinition,
RowDefinition, RowDefinition,
} from './index'; } from './index';
@@ -208,7 +209,7 @@ class MysqlDialect implements DbDialect {
let onUpdate = 'update_time' === cl.name ? ' ON UPDATE CURRENT_TIMESTAMP ' : ''; let onUpdate = 'update_time' === cl.name ? ' ON UPDATE CURRENT_TIMESTAMP ' : '';
return ` ${this.quoteIdentifier(cl.name)} ${cl.type}${length} ${cl.notNull ? 'NOT NULL' : 'NULL'} ${ return ` ${this.quoteIdentifier(cl.name)} ${cl.type}${length} ${cl.notNull ? 'NOT NULL' : 'NULL'} ${
cl.auto_increment ? 'AUTO_INCREMENT' : '' cl.auto_increment ? 'AUTO_INCREMENT' : ''
} ${defVal} ${onUpdate} comment '${cl.remark || ''}' `; } ${defVal} ${onUpdate} comment '${QuoteEscape(cl.remark)}' `;
} }
getCreateTableSql(data: any): string { getCreateTableSql(data: any): string {
// 创建表结构 // 创建表结构
@@ -224,14 +225,14 @@ class MysqlDialect implements DbDialect {
return `CREATE TABLE ${data.tableName} return `CREATE TABLE ${data.tableName}
( ${fields.join(',')} ( ${fields.join(',')}
${pks ? `, PRIMARY KEY (${pks.join(',')})` : ''} ${pks ? `, PRIMARY KEY (${pks.join(',')})` : ''}
) COMMENT='${data.tableComment}';`; ) COMMENT='${QuoteEscape(data.tableComment)}';`;
} }
getCreateIndexSql(data: any): string { getCreateIndexSql(data: any): string {
// 创建索引 // 创建索引
let sql = `ALTER TABLE ${data.tableName}`; let sql = `ALTER TABLE ${data.tableName}`;
data.indexs.res.forEach((a: any) => { data.indexs.res.forEach((a: any) => {
sql += ` ADD ${a.unique ? 'UNIQUE' : ''} INDEX ${a.indexName}(${a.columnNames.join(',')}) USING ${a.indexType} COMMENT '${a.indexComment}',`; sql += ` ADD ${a.unique ? 'UNIQUE' : ''} INDEX ${a.indexName}(${a.columnNames.join(',')}) USING ${a.indexType} COMMENT '${QuoteEscape(a.indexComment)}',`;
}); });
return sql.substring(0, sql.length - 1) + ';'; return sql.substring(0, sql.length - 1) + ';';
} }
@@ -312,9 +313,9 @@ class MysqlDialect implements DbDialect {
sql += ','; sql += ',';
} }
addIndexs.forEach((a) => { addIndexs.forEach((a) => {
sql += ` ADD ${a.unique ? 'UNIQUE' : ''} INDEX ${a.indexName}(${a.columnNames.join(',')}) USING ${a.indexType} COMMENT '${ sql += ` ADD ${a.unique ? 'UNIQUE' : ''} INDEX ${a.indexName}(${a.columnNames.join(',')}) USING ${a.indexType} COMMENT '${QuoteEscape(
a.indexComment a.indexComment
}',`; )}',`;
}); });
sql = sql.substring(0, sql.length - 1); sql = sql.substring(0, sql.length - 1);
} }
@@ -326,7 +327,7 @@ class MysqlDialect implements DbDialect {
getModifyTableInfoSql(tableData: any): string { getModifyTableInfoSql(tableData: any): string {
let sql = ''; let sql = '';
if (tableData.tableComment !== tableData.oldTableComment) { if (tableData.tableComment !== tableData.oldTableComment) {
sql += `ALTER TABLE ${this.quoteIdentifier(tableData.db)}.${this.quoteIdentifier(tableData.oldTableName)} COMMENT '${tableData.tableComment}';`; sql += `ALTER TABLE ${this.quoteIdentifier(tableData.db)}.${this.quoteIdentifier(tableData.oldTableName)} COMMENT '${QuoteEscape(tableData.tableComment)}';`;
} }
if (tableData.tableName !== tableData.oldTableName) { if (tableData.tableName !== tableData.oldTableName) {

View File

@@ -7,6 +7,7 @@ import {
DuplicateStrategy, DuplicateStrategy,
EditorCompletion, EditorCompletion,
EditorCompletionItem, EditorCompletionItem,
QuoteEscape,
IndexDefinition, IndexDefinition,
RowDefinition, RowDefinition,
sqlColumnType, sqlColumnType,
@@ -296,7 +297,12 @@ class OracleDialect implements DbDialect {
let length = this.getTypeLengthSql(cl); let length = this.getTypeLengthSql(cl);
// 默认值 // 默认值
let defVal = this.getDefaultValueSql(cl); let defVal = this.getDefaultValueSql(cl);
let incr = cl.auto_increment && create ? 'generated by default as IDENTITY' : ''; let incr = '';
if (cl.auto_increment && create) {
cl.type = 'number';
length = '';
incr = 'generated by default as IDENTITY';
}
// 如果有原名以原名为准 // 如果有原名以原名为准
let name = cl.oldName && cl.name !== cl.oldName ? cl.oldName : cl.name; let name = cl.oldName && cl.name !== cl.oldName ? cl.oldName : cl.name;
let baseSql = ` ${this.quoteIdentifier(name)} ${cl.type}${length} ${incr}`; let baseSql = ` ${this.quoteIdentifier(name)} ${cl.type}${length} ${incr}`;
@@ -319,7 +325,7 @@ class OracleDialect implements DbDialect {
item.name && fields.push(this.genColumnBasicSql(item, true)); item.name && fields.push(this.genColumnBasicSql(item, true));
// 列注释 // 列注释
if (item.remark) { if (item.remark) {
columCommentSql += ` COMMENT ON COLUMN ${dbTable}.${this.quoteIdentifier(item.name)} is '${item.remark}'; `; columCommentSql += ` COMMENT ON COLUMN ${dbTable}.${this.quoteIdentifier(item.name)} is '${QuoteEscape(item.remark)}'; `;
} }
// 主键 // 主键
if (item.pri) { if (item.pri) {
@@ -329,13 +335,13 @@ class OracleDialect implements DbDialect {
// 主键语句 // 主键语句
let prisql = ''; let prisql = '';
if (pris.length > 0) { if (pris.length > 0) {
prisql = ` CONSTRAINT "PK_${data.tableName}" PRIMARY KEY (${pris.join(',')});`; prisql = ` PRIMARY KEY (${pris.join(',')})`;
} }
// 建表 // 建表
createSql = `CREATE TABLE ${dbTable} ( ${fields.join(',')} ) ${prisql ? ',' + prisql : ''};`; createSql = `CREATE TABLE ${dbTable} ( ${fields.join(',')} ${prisql ? ',' + prisql : ''} ) ;`;
// 表注释 // 表注释
if (data.tableComment) { if (data.tableComment) {
tableCommentSql = ` COMMENT ON TABLE ${dbTable} is '${data.tableComment}'; `; tableCommentSql = ` COMMENT ON TABLE ${dbTable} is '${QuoteEscape(data.tableComment)}'; `;
} }
return createSql + tableCommentSql + columCommentSql; return createSql + tableCommentSql + columCommentSql;
@@ -374,7 +380,7 @@ class OracleDialect implements DbDialect {
if (changeData.upd.length > 0) { if (changeData.upd.length > 0) {
changeData.upd.forEach((a) => { changeData.upd.forEach((a) => {
let commentSql = `COMMENT ON COLUMN ${dbTable}.${this.quoteIdentifier(a.name)} IS '${a.remark}'`; let commentSql = `COMMENT ON COLUMN ${dbTable}.${this.quoteIdentifier(a.name)} IS '${QuoteEscape(a.remark)}'`;
if (a.remark && a.oldName === a.name) { if (a.remark && a.oldName === a.name) {
commentArr.push(commentSql); commentArr.push(commentSql);
} }
@@ -396,7 +402,7 @@ class OracleDialect implements DbDialect {
changeData.add.forEach((a) => { changeData.add.forEach((a) => {
modifyArr.push(` ADD (${this.genColumnBasicSql(a, false)})`); modifyArr.push(` ADD (${this.genColumnBasicSql(a, false)})`);
if (a.remark) { if (a.remark) {
commentArr.push(`COMMENT ON COLUMN ${dbTable}.${this.quoteIdentifier(a.name)} is '${a.remark}'`); commentArr.push(`COMMENT ON COLUMN ${dbTable}.${this.quoteIdentifier(a.name)} is '${QuoteEscape(a.remark)}'`);
} }
if (a.pri) { if (a.pri) {
priArr.add(`"${a.name}"`); priArr.add(`"${a.name}"`);
@@ -481,7 +487,7 @@ class OracleDialect implements DbDialect {
let sql = ''; let sql = '';
if (tableData.tableComment != tableData.oldTableComment) { if (tableData.tableComment != tableData.oldTableComment) {
let dbTable = `${this.quoteIdentifier(schema)}.${this.quoteIdentifier(tableData.oldTableName)}`; let dbTable = `${this.quoteIdentifier(schema)}.${this.quoteIdentifier(tableData.oldTableName)}`;
sql = `COMMENT ON TABLE ${dbTable} is '${tableData.tableComment}';`; sql = `COMMENT ON TABLE ${dbTable} is '${QuoteEscape(tableData.tableComment)}';`;
} }
if (tableData.tableName != tableData.oldTableName) { if (tableData.tableName != tableData.oldTableName) {
let dbTable = `${this.quoteIdentifier(schema)}.${this.quoteIdentifier(tableData.oldTableName)}`; let dbTable = `${this.quoteIdentifier(schema)}.${this.quoteIdentifier(tableData.oldTableName)}`;

View File

@@ -7,6 +7,7 @@ import {
DuplicateStrategy, DuplicateStrategy,
EditorCompletion, EditorCompletion,
EditorCompletionItem, EditorCompletionItem,
QuoteEscape,
IndexDefinition, IndexDefinition,
RowDefinition, RowDefinition,
sqlColumnType, sqlColumnType,
@@ -283,7 +284,7 @@ class PostgresqlDialect implements DbDialect {
} }
// 列注释 // 列注释
if (item.remark) { if (item.remark) {
columCommentSql += ` comment on column ${data.tableName}.${item.name} is '${item.remark}'; `; columCommentSql += ` comment on column ${data.tableName}.${item.name} is '${QuoteEscape(item.remark)}'; `;
} }
}); });
// 建表 // 建表
@@ -294,7 +295,7 @@ class PostgresqlDialect implements DbDialect {
);`; );`;
// 表注释 // 表注释
if (data.tableComment) { if (data.tableComment) {
tableCommentSql = ` comment on table ${data.tableName} is '${data.tableComment}'; `; tableCommentSql = ` comment on table ${data.tableName} is '${QuoteEscape(data.tableComment)}'; `;
} }
return createSql + tableCommentSql + columCommentSql; return createSql + tableCommentSql + columCommentSql;
@@ -312,7 +313,7 @@ class PostgresqlDialect implements DbDialect {
let colArr = a.columnNames.map((a: string) => `${this.quoteIdentifier(a)}`); let colArr = a.columnNames.map((a: string) => `${this.quoteIdentifier(a)}`);
sql.push(`CREATE ${a.unique ? 'UNIQUE' : ''} INDEX ${this.quoteIdentifier(a.indexName)} on ${dbTable} (${colArr.join(',')})`); sql.push(`CREATE ${a.unique ? 'UNIQUE' : ''} INDEX ${this.quoteIdentifier(a.indexName)} on ${dbTable} (${colArr.join(',')})`);
if (a.indexComment) { if (a.indexComment) {
sql.push(`COMMENT ON INDEX ${schema}.${this.quoteIdentifier(a.indexName)} IS '${a.indexComment}'`); sql.push(`COMMENT ON INDEX ${schema}.${this.quoteIdentifier(a.indexName)} IS '${QuoteEscape(a.indexComment)}'`);
} }
}); });
return sql.join(';'); return sql.join(';');
@@ -334,14 +335,14 @@ class PostgresqlDialect implements DbDialect {
changeData.add.forEach((a) => { changeData.add.forEach((a) => {
modifySql += `alter table ${dbTable} add ${this.genColumnBasicSql(a)};`; modifySql += `alter table ${dbTable} add ${this.genColumnBasicSql(a)};`;
if (a.remark) { if (a.remark) {
commentSql += `comment on column ${dbTable}.${this.quoteIdentifier(a.name)} is '${a.remark}';`; commentSql += `comment on column ${dbTable}.${this.quoteIdentifier(a.name)} is '${QuoteEscape(a.remark)}';`;
} }
}); });
} }
if (changeData.upd.length > 0) { if (changeData.upd.length > 0) {
changeData.upd.forEach((a) => { changeData.upd.forEach((a) => {
let cmtSql = `comment on column ${dbTable}.${this.quoteIdentifier(a.name)} is '${a.remark}';`; let cmtSql = `comment on column ${dbTable}.${this.quoteIdentifier(a.name)} is '${QuoteEscape(a.remark)}';`;
if (a.remark && a.oldName === a.name) { if (a.remark && a.oldName === a.name) {
commentSql += cmtSql; commentSql += cmtSql;
} }
@@ -412,7 +413,7 @@ class PostgresqlDialect implements DbDialect {
let colArr = a.columnNames.map((a: string) => `${this.quoteIdentifier(a)}`); let colArr = a.columnNames.map((a: string) => `${this.quoteIdentifier(a)}`);
sql.push(`CREATE ${a.unique ? 'UNIQUE' : ''} INDEX ${this.quoteIdentifier(a.indexName)} on ${dbTable} (${colArr.join(',')})`); sql.push(`CREATE ${a.unique ? 'UNIQUE' : ''} INDEX ${this.quoteIdentifier(a.indexName)} on ${dbTable} (${colArr.join(',')})`);
if (a.indexComment) { if (a.indexComment) {
sql.push(`COMMENT ON INDEX ${schema}.${this.quoteIdentifier(a.indexName)} IS '${a.indexComment}'`); sql.push(`COMMENT ON INDEX ${schema}.${this.quoteIdentifier(a.indexName)} IS '${QuoteEscape(a.indexComment)}'`);
} }
}); });
} }
@@ -428,7 +429,7 @@ class PostgresqlDialect implements DbDialect {
let sql = ''; let sql = '';
if (tableData.tableComment != tableData.oldTableComment) { if (tableData.tableComment != tableData.oldTableComment) {
let dbTable = `${this.quoteIdentifier(schema)}.${this.quoteIdentifier(tableData.oldTableName)}`; let dbTable = `${this.quoteIdentifier(schema)}.${this.quoteIdentifier(tableData.oldTableName)}`;
sql = `COMMENT ON TABLE ${dbTable} is '${tableData.tableComment}';`; sql = `COMMENT ON TABLE ${dbTable} is '${QuoteEscape(tableData.tableComment)}';`;
} }
if (tableData.tableName != tableData.oldTableName) { if (tableData.tableName != tableData.oldTableName) {
let dbTable = `${this.quoteIdentifier(schema)}.${this.quoteIdentifier(tableData.oldTableName)}`; let dbTable = `${this.quoteIdentifier(schema)}.${this.quoteIdentifier(tableData.oldTableName)}`;

View File

@@ -1,5 +1,10 @@
import { EnumValue } from '@/common/Enum'; import { EnumValue } from '@/common/Enum';
export const DbGetDbNamesMode = {
Auto: EnumValue.of(-1, '实时获取').setTagType('warning'),
Assign: EnumValue.of(1, '指定库名').setTagType('primary'),
};
// 数据库sql执行类型 // 数据库sql执行类型
export const DbSqlExecTypeEnum = { export const DbSqlExecTypeEnum = {
Update: EnumValue.of(1, 'UPDATE').setTagColor('#E4F5EB'), Update: EnumValue.of(1, 'UPDATE').setTagColor('#E4F5EB'),
@@ -30,3 +35,10 @@ export const DbDataSyncRunningStateEnum = {
Wait: EnumValue.of(2, '待运行').setTagType('primary'), Wait: EnumValue.of(2, '待运行').setTagType('primary'),
Fail: EnumValue.of(3, '已停止').setTagType('danger'), Fail: EnumValue.of(3, '已停止').setTagType('danger'),
}; };
export const DbTransferRunningStateEnum = {
Success: EnumValue.of(2, '成功').setTagType('success'),
Wait: EnumValue.of(1, '执行中').setTagType('primary'),
Fail: EnumValue.of(-1, '失败').setTagType('danger'),
Stop: EnumValue.of(-2, '手动终止').setTagType('warning'),
};

View File

@@ -1,90 +1,97 @@
<template> <template>
<div> <div>
<el-dialog :title="title" v-model="dialogVisible" :close-on-click-modal="false" :destroy-on-close="true" :before-close="cancel" width="650px"> <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="machineForm" :rules="rules" label-width="auto"> <el-form :model="form" ref="machineForm" :rules="rules" label-width="auto">
<el-tabs v-model="tabActiveName"> <el-divider content-position="left">基本</el-divider>
<el-tab-pane label="基础信息" name="basic"> <el-form-item ref="tagSelectRef" prop="tagCodePaths" label="标签">
<el-form-item ref="tagSelectRef" prop="tagId" label="标签"> <tag-tree-select
<tag-tree-select multiple
multiple @change-tag="
@change-tag=" (paths) => {
(tagIds) => { form.tagCodePaths = paths;
form.tagId = tagIds; tagSelectRef.validate();
tagSelectRef.validate(); }
} "
" :select-tags="form.tagCodePaths"
:tag-path="form.tagPath" style="width: 100%"
:select-tags="form.tagId" />
style="width: 100%" </el-form-item>
/> <el-form-item prop="code" label="编号" required>
</el-form-item> <el-input
<el-form-item prop="name" label="名称" required> :disabled="form.id"
<el-input v-model.trim="form.name" placeholder="请输入机器别名" auto-complete="off"></el-input> v-model.trim="form.code"
</el-form-item> placeholder="请输入编号 (大小写字母数字_-.:), 不可修改"
<el-form-item prop="ip" label="ip" required> auto-complete="off"
<el-col :span="18"> ></el-input>
<el-input :disabled="form.id" v-model.trim="form.ip" placeholder="主机ip" auto-complete="off"> </el-input> </el-form-item>
</el-col> <el-form-item prop="name" label="名称" required>
<el-col style="text-align: center" :span="1">:</el-col> <el-input v-model.trim="form.name" placeholder="请输入机器别名" auto-complete="off"></el-input>
<el-col :span="5"> </el-form-item>
<el-input type="number" v-model.number="form.port" placeholder="端口"></el-input> <el-form-item prop="protocol" label="协议" required>
</el-col> <el-radio-group v-model="form.protocol" @change="handleChangeProtocol">
</el-form-item> <el-radio v-for="item in MachineProtocolEnum" :key="item.value" :label="item.label" :value="item.value"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item prop="ip" label="ip" required>
<el-col :span="18">
<el-input v-model.trim="form.ip" 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 prop="username" label="用户名"> <el-form-item prop="remark" label="备注">
<el-input v-model.trim="form.username" placeholder="请输授权用户名" autocomplete="new-password"> </el-input> <el-input type="textarea" v-model="form.remark"></el-input>
</el-form-item> </el-form-item>
<el-form-item label="认证方式" required> <el-divider content-position="left">账号</el-divider>
<el-select @change="changeAuthMethod" style="width: 100%" v-model="state.authType" placeholder="请选认证方式"> <div>
<el-option key="1" label="密码" :value="1"> </el-option> <ResourceAuthCertTableEdit
<el-option key="2" label="授权凭证" :value="2"> </el-option> v-model="form.authCerts"
</el-select> :resource-code="form.code"
</el-form-item> :resource-type="TagResourceTypeEnum.Machine.value"
<el-form-item v-if="state.authType == 1" prop="password" label="密码"> :test-conn-btn-loading="testConnBtnLoading"
<el-input type="password" show-password v-model.trim="form.password" placeholder="请输入密码" autocomplete="new-password"> @test-conn="testConn"
</el-input> />
</el-form-item> </div>
<el-form-item v-if="state.authType == 2" prop="authCertId" label="授权凭证" required> <el-divider content-position="left">其他</el-divider>
<auth-cert-select ref="authCertSelectRef" v-model="form.authCertId" /> <el-form-item prop="enableRecorder" label="终端回放">
</el-form-item> <el-checkbox v-model="form.enableRecorder" :true-value="1" :false-value="-1"></el-checkbox>
</el-form-item>
<el-form-item prop="remark" label="备注"> <el-form-item prop="sshTunnelMachineId" label="SSH隧道">
<el-input type="textarea" v-model="form.remark"></el-input> <ssh-tunnel-select v-model="form.sshTunnelMachineId" />
</el-form-item> </el-form-item>
</el-tab-pane>
<el-tab-pane label="其他配置" name="other">
<el-form-item prop="enableRecorder" label="终端回放">
<el-checkbox v-model="form.enableRecorder" :true-value="1" :false-value="-1"></el-checkbox>
</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> </el-form>
<template #footer> <template #footer>
<div> <div>
<el-button @click="testConn" :loading="testConnBtnLoading" type="success">测试连接</el-button>
<el-button @click="cancel()">取 消</el-button> <el-button @click="cancel()">取 消</el-button>
<el-button type="primary" :loading="saveBtnLoading" @click="btnOk">确 定</el-button> <el-button type="primary" :loading="saveBtnLoading" @click="btnOk">确 定</el-button>
</div> </div>
</template> </template>
</el-dialog> </el-drawer>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { toRefs, reactive, watch, ref } from 'vue'; import { reactive, ref, toRefs, watchEffect } from 'vue';
import { machineApi } from './api'; import { machineApi } from './api';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import TagTreeSelect from '../component/TagTreeSelect.vue'; import TagTreeSelect from '../component/TagTreeSelect.vue';
import ResourceAuthCertTableEdit from '../component/ResourceAuthCertTableEdit.vue';
import SshTunnelSelect from '../component/SshTunnelSelect.vue'; import SshTunnelSelect from '../component/SshTunnelSelect.vue';
import AuthCertSelect from './authcert/AuthCertSelect.vue'; import { MachineProtocolEnum } from './enums';
import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue';
import { ResourceCodePattern } from '@/common/pattern';
import { TagResourceTypeEnum } from '@/common/commonEnum';
const props = defineProps({ const props = defineProps({
visible: { visible: {
@@ -99,16 +106,30 @@ const props = defineProps({
}); });
//定义事件 //定义事件
const emit = defineEmits(['update:visible', 'cancel', 'val-change']); const emit = defineEmits(['cancel', 'val-change']);
const dialogVisible = defineModel<boolean>('visible', { default: false });
const rules = { const rules = {
tagId: [ tagCodePaths: [
{ {
required: true, required: true,
message: '请选择标签', message: '请选择标签',
trigger: ['change'], trigger: ['change'],
}, },
], ],
code: [
{
required: true,
message: '请输入编码',
trigger: ['change', 'blur'],
},
{
pattern: ResourceCodePattern.pattern,
message: ResourceCodePattern.message,
trigger: ['blur'],
},
],
name: [ name: [
{ {
required: true, required: true,
@@ -116,137 +137,121 @@ const rules = {
trigger: ['change', 'blur'], trigger: ['change', 'blur'],
}, },
], ],
protocol: [
{
required: true,
message: '请选择机器类型',
trigger: ['change', 'blur'],
},
],
ip: [ ip: [
{ {
required: true, required: true,
message: '请输入主机ip和端口', message: '请输入主机ip和端口',
trigger: ['change', 'blur'], trigger: ['blur'],
},
],
authCertId: [
{
required: true,
message: '请选择授权凭证',
trigger: ['change', 'blur'],
},
],
username: [
{
required: true,
message: '请输入授权用户名',
trigger: ['change', 'blur'],
}, },
], ],
}; };
const machineForm: any = ref(null); const machineForm: any = ref(null);
const authCertSelectRef: any = ref(null);
const tagSelectRef: any = ref(null); const tagSelectRef: any = ref(null);
const defaultForm = {
id: null,
code: '',
tagPath: '',
ip: null,
port: 22,
protocol: MachineProtocolEnum.Ssh.value,
name: null,
authCerts: [],
tagCodePaths: [],
remark: '',
sshTunnelMachineId: null as any,
enableRecorder: -1,
};
const state = reactive({ const state = reactive({
dialogVisible: false,
tabActiveName: 'basic',
sshTunnelMachineList: [] as any, sshTunnelMachineList: [] as any,
authCerts: [] as any, form: defaultForm,
authType: 1, submitForm: {} as any,
form: {
id: null,
code: '',
tagPath: '',
ip: null,
port: 22,
name: null,
authCertId: null as any,
username: '',
password: '',
tagId: [],
remark: '',
sshTunnelMachineId: null as any,
enableRecorder: -1,
},
submitForm: {},
pwd: '', pwd: '',
}); });
const { dialogVisible, tabActiveName, form, submitForm } = toRefs(state); const { form, submitForm } = toRefs(state);
const { isFetching: testConnBtnLoading, execute: testConnExec } = machineApi.testConn.useApi(submitForm); const { isFetching: testConnBtnLoading, execute: testConnExec } = machineApi.testConn.useApi(submitForm);
const { isFetching: saveBtnLoading, execute: saveMachineExec } = machineApi.saveMachine.useApi(submitForm); const { isFetching: saveBtnLoading, execute: saveMachineExec } = machineApi.saveMachine.useApi(submitForm);
watch(props, async (newValue: any) => { watchEffect(() => {
state.dialogVisible = newValue.visible; if (!dialogVisible.value) {
if (!state.dialogVisible) {
return; return;
} }
state.tabActiveName = 'basic'; const machine: any = props.machine;
if (newValue.machine) { if (machine) {
state.form = { ...newValue.machine }; state.form = { ...machine };
state.form.tagId = newValue.machine.tags.map((t: any) => t.tagId); state.form.tagCodePaths = machine.tags.map((t: any) => t.codePath);
// 如果凭证类型为公共的,则表示使用授权凭证认证 state.form.authCerts = machine.authCerts || [];
const authCertId = (state.form as any).authCertId;
if (authCertId > 0) {
state.authType = 2;
} else {
state.authType = 1;
}
} else { } else {
state.form = { port: 22, tagId: [] } as any; state.form = { ...defaultForm };
state.authType = 1; state.form.authCerts = [];
} }
}); });
const changeAuthMethod = (val: any) => { const testConn = async (authCert: any) => {
if (state.form.id) { try {
if (val == 2) { await machineForm.value.validate();
state.form.authCertId = null; } catch (e: any) {
} else { ElMessage.error('请正确填写信息');
state.form.password = ''; return false;
}
} }
};
const testConn = async () => { state.submitForm = getReqForm();
machineForm.value.validate(async (valid: boolean) => { state.submitForm.authCerts = [authCert];
if (!valid) { await testConnExec();
ElMessage.error('请正确填写信息'); ElMessage.success('连接成功');
return false;
}
state.submitForm = getReqForm();
await testConnExec();
ElMessage.success('连接成功');
});
}; };
const btnOk = async () => { const btnOk = async () => {
machineForm.value.validate(async (valid: boolean) => { try {
if (!valid) { await machineForm.value.validate();
ElMessage.error('请正确填写信息'); } catch (e: any) {
return false; ElMessage.error('请正确填写信息');
} return false;
}
state.submitForm = getReqForm(); if (state.form.authCerts.length == 0) {
await saveMachineExec(); ElMessage.error('请完善授权凭证账号信息');
ElMessage.success('保存成功'); return false;
emit('val-change', submitForm); }
cancel();
}); state.submitForm = getReqForm();
await saveMachineExec();
ElMessage.success('保存成功');
emit('val-change', submitForm);
cancel();
}; };
const getReqForm = () => { const getReqForm = () => {
const reqForm: any = { ...state.form }; const reqForm: any = { ...state.form };
// 如果为密码认证则置空授权凭证id
if (state.authType == 1) {
reqForm.authCertId = -1;
}
if (!state.form.sshTunnelMachineId || state.form.sshTunnelMachineId <= 0) { if (!state.form.sshTunnelMachineId || state.form.sshTunnelMachineId <= 0) {
reqForm.sshTunnelMachineId = -1; reqForm.sshTunnelMachineId = -1;
} }
return reqForm; return reqForm;
}; };
const handleChangeProtocol = (val: any) => {
if (val == MachineProtocolEnum.Ssh.value) {
state.form.port = 22;
} else if (val == MachineProtocolEnum.Rdp.value) {
state.form.port = 3389;
} else {
state.form.port = 5901;
}
};
const cancel = () => { const cancel = () => {
emit('update:visible', false); dialogVisible.value = false;
emit('cancel'); emit('cancel');
}; };
</script> </script>

View File

@@ -4,11 +4,13 @@
ref="pageTableRef" ref="pageTableRef"
:page-api="machineApi.list" :page-api="machineApi.list"
:before-query-fn="checkRouteTagPath" :before-query-fn="checkRouteTagPath"
:data-handler-fn="handleData"
:search-items="searchItems" :search-items="searchItems"
v-model:query-form="params" v-model:query-form="params"
:show-selection="true" :show-selection="true"
v-model:selection-data="state.selectionData" v-model:selection-data="state.selectionData"
:columns="columns" :columns="columns"
:lazy="true"
> >
<template #tableHeader> <template #tableHeader>
<el-button v-auth="perms.addMachine" type="primary" icon="plus" @click="openFormDialog(false)" plain>添加 </el-button> <el-button v-auth="perms.addMachine" type="primary" icon="plus" @click="openFormDialog(false)" plain>添加 </el-button>
@@ -83,12 +85,19 @@
<ResourceTags :tags="data.tags" /> <ResourceTags :tags="data.tags" />
</template> </template>
<template #authCert="{ data }">
<ResourceAuthCert v-model:select-auth-cert="data.selectAuthCert" :auth-certs="data.authCerts" />
</template>
<template #action="{ data }"> <template #action="{ data }">
<span v-auth="'machine:terminal'"> <span v-auth="'machine:terminal'">
<el-tooltip :show-after="500" content="按住ctrl则为新标签打开" placement="top"> <el-tooltip v-if="data.protocol == MachineProtocolEnum.Ssh.value" :show-after="500" content="按住ctrl则为新标签打开" placement="top">
<el-button :disabled="data.status == -1" type="primary" @click="showTerminal(data, $event)" link>终端</el-button> <el-button :disabled="data.status == -1" type="primary" @click="showTerminal(data, $event)" link>SSH</el-button>
</el-tooltip> </el-tooltip>
<el-button v-if="data.protocol == MachineProtocolEnum.Rdp.value" type="primary" @click="showRDP(data)" link>RDP</el-button>
<el-button v-if="data.protocol == MachineProtocolEnum.Vnc.value" type="primary" @click="showRDP(data)" link>VNC</el-button>
<el-divider direction="vertical" border-style="dashed" /> <el-divider direction="vertical" border-style="dashed" />
</span> </span>
@@ -97,7 +106,9 @@
<el-divider direction="vertical" border-style="dashed" /> <el-divider direction="vertical" border-style="dashed" />
</span> </span>
<el-button :disabled="data.status == -1" type="warning" @click="serviceManager(data)" link>脚本</el-button> <el-button v-if="data.protocol == MachineProtocolEnum.Ssh.value" :disabled="data.status == -1" type="warning" @click="serviceManager(data)" link
>脚本</el-button
>
<el-divider direction="vertical" border-style="dashed" /> <el-divider direction="vertical" border-style="dashed" />
<el-dropdown @command="handleCommand"> <el-dropdown @command="handleCommand">
@@ -111,9 +122,19 @@
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item :command="{ type: 'detail', data }"> 详情 </el-dropdown-item> <el-dropdown-item :command="{ type: 'detail', data }"> 详情 </el-dropdown-item>
<el-dropdown-item :command="{ type: 'rdp-blank', data }" v-if="data.protocol == MachineProtocolEnum.Rdp.value">
RDP(新窗口)
</el-dropdown-item>
<el-dropdown-item :command="{ type: 'edit', data }" v-if="actionBtns[perms.updateMachine]"> 编辑 </el-dropdown-item> <el-dropdown-item :command="{ type: 'edit', data }" v-if="actionBtns[perms.updateMachine]"> 编辑 </el-dropdown-item>
<el-dropdown-item :command="{ type: 'process', data }" :disabled="data.status == -1"> 进程 </el-dropdown-item> <el-dropdown-item
v-if="data.protocol == MachineProtocolEnum.Ssh.value"
:command="{ type: 'process', data }"
:disabled="data.status == -1"
>
进程
</el-dropdown-item>
<el-dropdown-item :command="{ type: 'terminalRec', data }" v-if="actionBtns[perms.updateMachine] && data.enableRecorder == 1"> <el-dropdown-item :command="{ type: 'terminalRec', data }" v-if="actionBtns[perms.updateMachine] && data.enableRecorder == 1">
终端回放 终端回放
@@ -134,20 +155,15 @@
<el-descriptions-item :span="2" label="IP">{{ infoDialog.data.ip }}</el-descriptions-item> <el-descriptions-item :span="2" label="IP">{{ infoDialog.data.ip }}</el-descriptions-item>
<el-descriptions-item :span="1" label="端口">{{ infoDialog.data.port }}</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.authCertId > 1 ? '授权凭证' : '密码' }}
</el-descriptions-item>
<el-descriptions-item :span="3" label="备注">{{ infoDialog.data.remark }}</el-descriptions-item> <el-descriptions-item :span="3" label="备注">{{ infoDialog.data.remark }}</el-descriptions-item>
<el-descriptions-item :span="1.5" label="SSH隧道">{{ infoDialog.data.sshTunnelMachineId > 0 ? '是' : '否' }} </el-descriptions-item> <el-descriptions-item :span="1.5" label="SSH隧道">{{ infoDialog.data.sshTunnelMachineId > 0 ? '是' : '否' }} </el-descriptions-item>
<el-descriptions-item :span="1.5" label="终端回放">{{ infoDialog.data.enableRecorder == 1 ? '是' : '否' }} </el-descriptions-item> <el-descriptions-item :span="1.5" label="终端回放">{{ infoDialog.data.enableRecorder == 1 ? '是' : '否' }} </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="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-item :span="1" label="修改者">{{ infoDialog.data.modifier }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-dialog> </el-dialog>
@@ -156,7 +172,7 @@
<template #headerTitle="{ terminalInfo }"> <template #headerTitle="{ terminalInfo }">
{{ `${(terminalInfo.terminalId + '').slice(-2)}` }} {{ `${(terminalInfo.terminalId + '').slice(-2)}` }}
<el-divider direction="vertical" /> <el-divider direction="vertical" />
{{ `${terminalInfo.meta.username}@${terminalInfo.meta.ip}:${terminalInfo.meta.port}` }} {{ `${terminalInfo.meta.selectAuthCert.username}@${terminalInfo.meta.ip}:${terminalInfo.meta.port}` }}
<el-divider direction="vertical" /> <el-divider direction="vertical" />
{{ terminalInfo.meta.name }} {{ terminalInfo.meta.name }}
</template> </template>
@@ -171,30 +187,67 @@
<process-list v-model:visible="processDialog.visible" v-model:machineId="processDialog.machineId" /> <process-list v-model:visible="processDialog.visible" v-model:machineId="processDialog.machineId" />
<script-manage :title="serviceDialog.title" v-model:visible="serviceDialog.visible" v-model:machineId="serviceDialog.machineId" /> <script-manage
:title="serviceDialog.title"
v-model:visible="serviceDialog.visible"
v-model:machineId="serviceDialog.machineId"
:auth-cert-name="serviceDialog.authCertName"
/>
<file-conf-list :title="fileDialog.title" v-model:visible="fileDialog.visible" v-model:machineId="fileDialog.machineId" /> <file-conf-list
:title="fileDialog.title"
v-model:visible="fileDialog.visible"
v-model:machineId="fileDialog.machineId"
:auth-cert-name="fileDialog.authCertName"
/>
<machine-stats v-model:visible="machineStatsDialog.visible" :machineId="machineStatsDialog.machineId" :title="machineStatsDialog.title"></machine-stats> <machine-stats v-model:visible="machineStatsDialog.visible" :machineId="machineStatsDialog.machineId" :title="machineStatsDialog.title"></machine-stats>
<machine-rec v-model:visible="machineRecDialog.visible" :machineId="machineRecDialog.machineId" :title="machineRecDialog.title"></machine-rec> <machine-rec v-model:visible="machineRecDialog.visible" :machineId="machineRecDialog.machineId" :title="machineRecDialog.title"></machine-rec>
<machine-rdp-dialog-comp
:title="machineRdpDialog.title"
v-model:visible="machineRdpDialog.visible"
v-model:machine-id="machineRdpDialog.machineId"
v-model:auth-cert="machineRdpDialog.authCert"
>
<template #headerTitle="{ terminalInfo }">
{{ `${(terminalInfo.terminalId + '').slice(-2)}` }}
<el-divider direction="vertical" />
{{ `${terminalInfo.meta.username}@${terminalInfo.meta.ip}:${terminalInfo.meta.port}` }}
<el-divider direction="vertical" />
{{ terminalInfo.meta.name }}
</template>
</machine-rdp-dialog-comp>
<el-dialog destroy-on-close :title="filesystemDialog.title" v-model="filesystemDialog.visible" :close-on-click-modal="false" width="70%">
<machine-file
:machine-id="filesystemDialog.machineId"
:protocol="filesystemDialog.protocol"
:file-id="filesystemDialog.fileId"
:path="filesystemDialog.path"
/>
</el-dialog>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, toRefs, reactive, onMounted, defineAsyncComponent, Ref } from 'vue'; import { defineAsyncComponent, onMounted, reactive, ref, Ref, toRefs } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox } from 'element-plus';
import { machineApi, getMachineTerminalSocketUrl } from './api'; import { getMachineTerminalSocketUrl, machineApi } from './api';
import { dateFormat } from '@/common/utils/date';
import ResourceTags from '../component/ResourceTags.vue'; import ResourceTags from '../component/ResourceTags.vue';
import PageTable from '@/components/pagetable/PageTable.vue'; import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable'; import { TableColumn } from '@/components/pagetable';
import { hasPerms } from '@/components/auth/auth'; import { hasPerms } from '@/components/auth/auth';
import { formatByteSize } from '@/common/utils/format'; import { formatByteSize, formatDate } from '@/common/utils/format';
import { TagResourceTypeEnum } from '@/common/commonEnum'; import { TagResourceTypeEnum } from '@/common/commonEnum';
import { SearchItem } from '@/components/SearchForm'; import { SearchItem } from '@/components/SearchForm';
import { getTagPathSearchItem } from '../component/tag'; import { getTagPathSearchItem } from '../component/tag';
import MachineFile from '@/views/ops/machine/file/MachineFile.vue';
import ResourceAuthCert from '../component/ResourceAuthCert.vue';
import { MachineProtocolEnum } from './enums';
import MachineRdpDialogComp from '@/components/terminal-rdp/MachineRdpDialog.vue';
// 组件 // 组件
const TerminalDialog = defineAsyncComponent(() => import('@/components/terminal/TerminalDialog.vue')); const TerminalDialog = defineAsyncComponent(() => import('@/components/terminal/TerminalDialog.vue'));
@@ -205,6 +258,13 @@ const MachineStats = defineAsyncComponent(() => import('./MachineStats.vue'));
const MachineRec = defineAsyncComponent(() => import('./MachineRec.vue')); const MachineRec = defineAsyncComponent(() => import('./MachineRec.vue'));
const ProcessList = defineAsyncComponent(() => import('./ProcessList.vue')); const ProcessList = defineAsyncComponent(() => import('./ProcessList.vue'));
const props = defineProps({
lazy: {
type: [Boolean],
default: false,
},
});
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const terminalDialogRef: any = ref(null); const terminalDialogRef: any = ref(null);
@@ -217,17 +277,23 @@ const perms = {
terminal: 'machine:terminal', terminal: 'machine:terminal',
}; };
const searchItems = [getTagPathSearchItem(TagResourceTypeEnum.Machine.value), SearchItem.input('ip', 'IP'), SearchItem.input('name', '名称')]; const searchItems = [
getTagPathSearchItem(TagResourceTypeEnum.MachineAuthCert.value),
SearchItem.input('code', '编号'),
SearchItem.input('ip', 'IP'),
SearchItem.input('name', '名称'),
];
const columns = [ const columns = [
TableColumn.new('tags[0].tagPath', '关联标签').isSlot('tagPath').setAddWidth(20), TableColumn.new('tags[0].tagPath', '关联标签').isSlot('tagPath').setAddWidth(20),
TableColumn.new('name', '名称'), TableColumn.new('name', '名称'),
TableColumn.new('ipPort', 'ip:port').isSlot().setAddWidth(50), TableColumn.new('ipPort', 'ip:port').isSlot().setAddWidth(50),
TableColumn.new('authCerts[0].username', '授权凭证').isSlot('authCert').setAddWidth(10),
TableColumn.new('status', '状态').isSlot().setMinWidth(85),
TableColumn.new('stat', '运行状态').isSlot().setAddWidth(55), TableColumn.new('stat', '运行状态').isSlot().setAddWidth(55),
TableColumn.new('fs', '磁盘(挂载点=>可用/总)').isSlot().setAddWidth(25), TableColumn.new('fs', '磁盘(挂载点=>可用/总)').isSlot().setAddWidth(25),
TableColumn.new('username', '用户名'),
TableColumn.new('status', '状态').isSlot().setMinWidth(85),
TableColumn.new('remark', '备注'), TableColumn.new('remark', '备注'),
TableColumn.new('code', '编号'),
TableColumn.new('action', '操作').isSlot().setMinWidth(238).fixedRight().alignCenter(), TableColumn.new('action', '操作').isSlot().setMinWidth(238).fixedRight().alignCenter(),
]; ];
@@ -251,6 +317,7 @@ const state = reactive({
serviceDialog: { serviceDialog: {
visible: false, visible: false,
machineId: 0, machineId: 0,
authCertName: '',
title: '', title: '',
}, },
processDialog: { processDialog: {
@@ -260,8 +327,18 @@ const state = reactive({
fileDialog: { fileDialog: {
visible: false, visible: false,
machineId: 0, machineId: 0,
authCertName: '',
title: '', title: '',
}, },
filesystemDialog: {
visible: false,
machineId: 0,
protocol: 1,
title: '',
fileId: 0,
authCertName: '',
path: '',
},
machineStatsDialog: { machineStatsDialog: {
visible: false, visible: false,
stats: null, stats: null,
@@ -278,11 +355,33 @@ const state = reactive({
machineId: 0, machineId: 0,
title: '', title: '',
}, },
machineRdpDialog: {
visible: false,
machineId: 0,
title: '',
authCert: '',
},
}); });
const { params, infoDialog, selectionData, serviceDialog, processDialog, fileDialog, machineStatsDialog, machineEditDialog, machineRecDialog } = toRefs(state); const {
params,
infoDialog,
selectionData,
serviceDialog,
processDialog,
fileDialog,
machineStatsDialog,
machineEditDialog,
machineRecDialog,
machineRdpDialog,
filesystemDialog,
} = toRefs(state);
onMounted(async () => {}); onMounted(async () => {
if (!props.lazy) {
search();
}
});
const checkRouteTagPath = (query: any) => { const checkRouteTagPath = (query: any) => {
if (route.query.tagPath) { if (route.query.tagPath) {
@@ -291,6 +390,15 @@ const checkRouteTagPath = (query: any) => {
return query; return query;
}; };
const handleData = (res: any) => {
const dataList = res.list;
// 赋值授权凭证
for (let x of dataList) {
x.selectAuthCert = x.authCerts[0];
}
return res;
};
const handleCommand = (commond: any) => { const handleCommand = (commond: any) => {
const data = commond.data; const data = commond.data;
const type = commond.type; const type = commond.type;
@@ -311,16 +419,25 @@ const handleCommand = (commond: any) => {
showRec(data); showRec(data);
return; return;
} }
case 'rdp': {
showRDP(data);
return;
}
case 'rdp-blank': {
showRDP(data, true);
return;
}
} }
}; };
const showTerminal = (row: any, event: PointerEvent) => { const showTerminal = (row: any, event: PointerEvent) => {
const ac = row.selectAuthCert.name;
// 按住ctrl点击则新建标签页打开, metaKey对应mac command键 // 按住ctrl点击则新建标签页打开, metaKey对应mac command键
if (event.ctrlKey || event.metaKey) { if (event.ctrlKey || event.metaKey) {
const { href } = router.resolve({ const { href } = router.resolve({
path: `/machine/terminal`, path: `/machine/terminal`,
query: { query: {
id: row.id, ac,
name: row.name, name: row.name,
}, },
}); });
@@ -331,9 +448,9 @@ const showTerminal = (row: any, event: PointerEvent) => {
const terminalId = Date.now(); const terminalId = Date.now();
terminalDialogRef.value.open({ terminalDialogRef.value.open({
terminalId, terminalId,
socketUrl: getMachineTerminalSocketUrl(row.id), socketUrl: getMachineTerminalSocketUrl(ac),
minTitle: `${row.name} [${(terminalId + '').slice(-2)}]`, // 截取terminalId最后两位区分多个terminal minTitle: `${row.name} [${(terminalId + '').slice(-2)}]`, // 截取terminalId最后两位区分多个terminal
minDesc: `${row.username}@${row.ip}:${row.port} (${row.name})`, minDesc: `${row.selectAuthCert.username}@${row.ip}:${row.port} (${row.name})`,
meta: row, meta: row,
}); });
}; };
@@ -372,9 +489,11 @@ const deleteMachine = async () => {
}; };
const serviceManager = (row: any) => { const serviceManager = (row: any) => {
const authCert = row.selectAuthCert;
state.serviceDialog.machineId = row.id; state.serviceDialog.machineId = row.id;
state.serviceDialog.authCertName = authCert.name;
state.serviceDialog.visible = true; state.serviceDialog.visible = true;
state.serviceDialog.title = `${row.name} => ${row.ip}`; state.serviceDialog.title = `${row.name} => ${authCert.username}@${row.ip}`;
}; };
/** /**
@@ -396,7 +515,10 @@ const showMachineStats = async (machine: any) => {
state.machineStatsDialog.visible = true; state.machineStatsDialog.visible = true;
}; };
const search = async () => { const search = async (tagPath: string = '') => {
if (tagPath) {
state.params.tagPath = tagPath;
}
pageTableRef.value.search(); pageTableRef.value.search();
}; };
@@ -404,10 +526,23 @@ const submitSuccess = () => {
search(); search();
}; };
const showFileManage = (selectionData: any) => { const showFileManage = (data: any) => {
state.fileDialog.visible = true; if (data.protocol === MachineProtocolEnum.Ssh.value) {
state.fileDialog.machineId = selectionData.id; // ssh
state.fileDialog.title = `${selectionData.name} => ${selectionData.ip}`; state.fileDialog.visible = true;
state.fileDialog.machineId = data.id;
state.fileDialog.authCertName = data.selectAuthCert.name;
state.fileDialog.title = `${data.name} => ${data.selectAuthCert.username}@${data.ip}`;
} else if (data.protocol === MachineProtocolEnum.Rdp.value) {
// rdp
state.filesystemDialog.protocol = 2;
state.filesystemDialog.machineId = data.id;
state.filesystemDialog.fileId = data.id;
state.filesystemDialog.authCertName = data.selectAuthCert.name;
state.filesystemDialog.path = '/';
state.filesystemDialog.title = `${data.name} => ${data.selectAuthCert.username}@远程桌面文件`;
state.filesystemDialog.visible = true;
}
}; };
const getStatsFontClass = (availavle: number, total: number) => { const getStatsFontClass = (availavle: number, total: number) => {
@@ -437,6 +572,26 @@ const showRec = (row: any) => {
state.machineRecDialog.machineId = row.id; state.machineRecDialog.machineId = row.id;
state.machineRecDialog.visible = true; state.machineRecDialog.visible = true;
}; };
const showRDP = (row: any, blank = false) => {
if (blank) {
const { href } = router.resolve({
path: `/machine/terminal-rdp`,
query: {
ac: row.selectAuthCert.name,
name: row.name,
},
});
window.open(href, '_blank');
return;
}
state.machineRdpDialog.title = `${row.name}[${row.ip}]-远程桌面`;
state.machineRdpDialog.machineId = row.id;
state.machineRdpDialog.authCert = row.selectAuthCert.name;
state.machineRdpDialog.visible = true;
};
defineExpose({ search });
</script> </script>
<style> <style>

View File

@@ -6,17 +6,27 @@
<tag-tree <tag-tree
class="machine-terminal-tree" class="machine-terminal-tree"
ref="tagTreeRef" ref="tagTreeRef"
:resource-type="TagResourceTypeEnum.Machine.value" :resource-type="TagResourceTypeEnum.MachineAuthCert.value"
:tag-path-node-type="NodeTypeTagPath" :tag-path-node-type="NodeTypeTagPath"
:default-expanded-keys="state.defaultExpendKey"
> >
<template #prefix="{ data }"> <template #prefix="{ data }">
<SvgIcon v-if="data.icon && data.params.status == 1" :name="data.icon.name" :color="data.icon.color" /> <SvgIcon
<SvgIcon v-if="data.icon && data.params.status == -1" :name="data.icon.name" color="var(--el-color-danger)" /> v-if="data.icon && data.params.status == 1 && data.params.protocol == MachineProtocolEnum.Ssh.value"
:name="data.icon.name"
:color="data.icon.color"
/>
<SvgIcon
v-if="data.icon && data.params.status == -1 && data.params.protocol == MachineProtocolEnum.Ssh.value"
:name="data.icon.name"
color="var(--el-color-danger)"
/>
<SvgIcon v-if="data.icon && data.params.protocol != MachineProtocolEnum.Ssh.value" :name="data.icon.name" :color="data.icon.color" />
</template> </template>
<template #suffix="{ data }"> <template #suffix="{ data }">
<span style="color: #c4c9c4; font-size: 9px" v-if="data.type.value == MachineNodeType.Machine">{{ <span v-if="data.type.value == MachineNodeType.AuthCert">{{
` ${data.params.username}@${data.params.ip}:${data.params.port}` ` ${data.params.selectAuthCert.username}@${data.params.ip}:${data.params.port}`
}}</span> }}</span>
</template> </template>
</tag-tree> </tag-tree>
@@ -35,7 +45,7 @@
> >
<el-tab-pane class="h100" closable v-for="dt in state.tabs.values()" :label="dt.label" :name="dt.key" :key="dt.key"> <el-tab-pane class="h100" closable v-for="dt in state.tabs.values()" :label="dt.label" :name="dt.key" :key="dt.key">
<template #label> <template #label>
<el-popconfirm @confirm="handleReconnect(dt.key)" title="确认重新连接?"> <el-popconfirm @confirm="handleReconnect(dt, true)" title="确认重新连接?">
<template #reference> <template #reference>
<el-icon class="mr5" :color="dt.status == 1 ? '#67c23a' : '#f56c6c'" :title="dt.status == 1 ? '' : '点击重连'" <el-icon class="mr5" :color="dt.status == 1 ? '#67c23a' : '#f56c6c'" :title="dt.status == 1 ? '' : '点击重连'"
><Connection /> ><Connection />
@@ -59,13 +69,21 @@
</el-popover> </el-popover>
</template> </template>
<div class="terminal-wrapper" style="height: calc(100vh - 155px)"> <div :ref="(el: any) => setTerminalWrapperRef(el, dt.key)" class="terminal-wrapper" style="height: calc(100vh - 155px)">
<TerminalBody <TerminalBody
v-if="dt.params.protocol == MachineProtocolEnum.Ssh.value"
:mount-init="false" :mount-init="false"
@status-change="terminalStatusChange(dt.key, $event)" @status-change="terminalStatusChange(dt.key, $event)"
:ref="(el) => setTerminalRef(el, dt.key)" :ref="(el: any) => setTerminalRef(el, dt.key)"
:socket-url="dt.socketUrl" :socket-url="dt.socketUrl"
/> />
<machine-rdp
v-if="dt.params.protocol != MachineProtocolEnum.Ssh.value"
:machine-id="dt.params.id"
:auth-cert="dt.authCert"
:ref="(el: any) => setTerminalRef(el, dt.key)"
@status-change="terminalStatusChange(dt.key, $event)"
/>
</div> </div>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
@@ -75,34 +93,56 @@
<el-descriptions-item :span="1.5" label="机器id">{{ infoDialog.data.id }}</el-descriptions-item> <el-descriptions-item :span="1.5" label="机器id">{{ infoDialog.data.id }}</el-descriptions-item>
<el-descriptions-item :span="1.5" label="名称">{{ infoDialog.data.name }}</el-descriptions-item> <el-descriptions-item :span="1.5" label="名称">{{ infoDialog.data.name }}</el-descriptions-item>
<el-descriptions-item :span="3" label="标签路径">{{ infoDialog.data.tagPath }}</el-descriptions-item> <el-descriptions-item :span="3" label="关联标签"><ResourceTags :tags="infoDialog.data.tags" /></el-descriptions-item>
<el-descriptions-item :span="2" label="IP">{{ infoDialog.data.ip }}</el-descriptions-item> <el-descriptions-item :span="2" label="IP">{{ infoDialog.data.ip }}</el-descriptions-item>
<el-descriptions-item :span="1" label="端口">{{ infoDialog.data.port }}</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.authCertId > 1 ? '授权凭证' : '密码' }}
</el-descriptions-item>
<el-descriptions-item :span="3" label="备注">{{ infoDialog.data.remark }}</el-descriptions-item> <el-descriptions-item :span="3" label="备注">{{ infoDialog.data.remark }}</el-descriptions-item>
<el-descriptions-item :span="1.5" label="SSH隧道">{{ infoDialog.data.sshTunnelMachineId > 0 ? '是' : '否' }} </el-descriptions-item> <el-descriptions-item :span="1.5" label="SSH隧道">{{ infoDialog.data.sshTunnelMachineId > 0 ? '是' : '否' }} </el-descriptions-item>
<el-descriptions-item :span="1.5" label="终端回放">{{ infoDialog.data.enableRecorder == 1 ? '是' : '否' }} </el-descriptions-item> <el-descriptions-item :span="1.5" label="终端回放">{{ infoDialog.data.enableRecorder == 1 ? '是' : '否' }} </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="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-item :span="1" label="修改者">{{ infoDialog.data.modifier }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-dialog> </el-dialog>
<process-list v-model:visible="processDialog.visible" v-model:machineId="processDialog.machineId" /> <process-list v-model:visible="processDialog.visible" v-model:machineId="processDialog.machineId" />
<script-manage :title="serviceDialog.title" v-model:visible="serviceDialog.visible" v-model:machineId="serviceDialog.machineId" /> <script-manage
:title="serviceDialog.title"
v-model:visible="serviceDialog.visible"
v-model:machineId="serviceDialog.machineId"
:auth-cert-name="serviceDialog.authCertName"
/>
<file-conf-list :title="fileDialog.title" v-model:visible="fileDialog.visible" v-model:machineId="fileDialog.machineId" /> <file-conf-list
:title="fileDialog.title"
:auth-cert-name="fileDialog.authCertName"
v-model:visible="fileDialog.visible"
v-model:machineId="fileDialog.machineId"
:protocol="fileDialog.protocol"
/>
<el-dialog
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>
<machine-stats v-model:visible="machineStatsDialog.visible" :machineId="machineStatsDialog.machineId" :title="machineStatsDialog.title" /> <machine-stats v-model:visible="machineStatsDialog.visible" :machineId="machineStatsDialog.machineId" :title="machineStatsDialog.title" />
@@ -114,24 +154,31 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, toRefs, reactive, defineAsyncComponent, nextTick } from 'vue'; import { defineAsyncComponent, nextTick, onMounted, reactive, ref, toRefs, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { machineApi, getMachineTerminalSocketUrl } from './api'; import { getMachineTerminalSocketUrl, machineApi } from './api';
import { dateFormat } from '@/common/utils/date'; import { formatDate } from '@/common/utils/format';
import { hasPerms } from '@/components/auth/auth'; import { hasPerms } from '@/components/auth/auth';
import { TagResourceTypeEnum } from '@/common/commonEnum'; import { TagResourceTypeEnum } from '@/common/commonEnum';
import { NodeType, TagTreeNode } from '../component/tag'; import { NodeType, TagTreeNode, getTagTypeCodeByPath } from '../component/tag';
import TagTree from '../component/TagTree.vue'; import TagTree from '../component/TagTree.vue';
import { Splitpanes, Pane } from 'splitpanes'; import { Pane, Splitpanes } from 'splitpanes';
import { ContextmenuItem } from '@/components/contextmenu/index'; import { ContextmenuItem } from '@/components/contextmenu/index';
import TerminalBody from '@/components/terminal/TerminalBody.vue';
import { TerminalStatus } from '@/components/terminal/common';
import MachineRdp from '@/components/terminal-rdp/MachineRdp.vue';
import MachineFile from '@/views/ops/machine/file/MachineFile.vue';
import ResourceTags from '../component/ResourceTags.vue';
import { MachineProtocolEnum } from './enums';
import { useAutoOpenResource } from '@/store/autoOpenResource';
import { storeToRefs } from 'pinia';
// 组件 // 组件
const ScriptManage = defineAsyncComponent(() => import('./ScriptManage.vue')); const ScriptManage = defineAsyncComponent(() => import('./ScriptManage.vue'));
const FileConfList = defineAsyncComponent(() => import('./file/FileConfList.vue')); const FileConfList = defineAsyncComponent(() => import('./file/FileConfList.vue'));
const MachineStats = defineAsyncComponent(() => import('./MachineStats.vue')); const MachineStats = defineAsyncComponent(() => import('./MachineStats.vue'));
const MachineRec = defineAsyncComponent(() => import('./MachineRec.vue')); const MachineRec = defineAsyncComponent(() => import('./MachineRec.vue'));
const ProcessList = defineAsyncComponent(() => import('./ProcessList.vue')); const ProcessList = defineAsyncComponent(() => import('./ProcessList.vue'));
import TerminalBody from '@/components/terminal/TerminalBody.vue';
import { TerminalStatus } from '@/components/terminal/common';
const router = useRouter(); const router = useRouter();
@@ -148,9 +195,11 @@ const actionBtns = hasPerms([perms.updateMachine, perms.closeCli]);
class MachineNodeType { class MachineNodeType {
static Machine = 1; static Machine = 1;
static AuthCert = 2;
} }
const state = reactive({ const state = reactive({
defaultExpendKey: [] as any,
params: { params: {
pageNum: 1, pageNum: 1,
pageSize: 0, pageSize: 0,
@@ -165,6 +214,7 @@ const state = reactive({
serviceDialog: { serviceDialog: {
visible: false, visible: false,
machineId: 0, machineId: 0,
authCertName: '',
title: '', title: '',
}, },
processDialog: { processDialog: {
@@ -174,7 +224,18 @@ const state = reactive({
fileDialog: { fileDialog: {
visible: false, visible: false,
machineId: 0, machineId: 0,
protocol: 1,
title: '', title: '',
authCertName: '',
},
filesystemDialog: {
visible: false,
machineId: 0,
authCertName: '',
protocol: 1,
title: '',
fileId: 0,
path: '',
}, },
machineStatsDialog: { machineStatsDialog: {
visible: false, visible: false,
@@ -195,7 +256,12 @@ const { infoDialog, serviceDialog, processDialog, fileDialog, machineStatsDialog
const tagTreeRef: any = ref(null); const tagTreeRef: any = ref(null);
const NodeTypeTagPath = new NodeType(TagTreeNode.TagPath).withLoadNodesFunc(async (node: any) => { const autoOpenResourceStore = useAutoOpenResource();
const { autoOpenResource } = storeToRefs(autoOpenResourceStore);
let openIds = {};
const NodeTypeTagPath = new NodeType(TagTreeNode.TagPath).withLoadNodesFunc(async (node: TagTreeNode) => {
// 加载标签树下的机器列表 // 加载标签树下的机器列表
state.params.tagPath = node.key; state.params.tagPath = node.key;
state.params.pageNum = 1; state.params.pageNum = 1;
@@ -204,87 +270,171 @@ const NodeTypeTagPath = new NodeType(TagTreeNode.TagPath).withLoadNodesFunc(asyn
// 把list 根据name字段排序 // 把list 根据name字段排序
res.list = res.list.sort((a: any, b: any) => a.name.localeCompare(b.name)); res.list = res.list.sort((a: any, b: any) => a.name.localeCompare(b.name));
return res.list.map((x: any) => return res.list.map((x: any) =>
new TagTreeNode(x.id, x.name, NodeTypeMachine(x)) new TagTreeNode(x.code, x.name, NodeTypeMachine)
.withParams(x) .withParams(x)
.withDisabled(x.status == -1) .withDisabled(x.status == -1 && x.protocol == MachineProtocolEnum.Ssh.value)
.withIcon({ .withIcon({
name: 'Monitor', name: 'Monitor',
color: '#409eff', color: '#409eff',
}) })
.withIsLeaf(true)
); );
}); });
let openIds = {}; const NodeTypeMachine = new NodeType(MachineNodeType.Machine)
.withLoadNodesFunc((node: TagTreeNode) => {
const machine = node.params;
// 获取授权凭证列表
const authCerts = machine.authCerts;
return authCerts.map((x: any) =>
new TagTreeNode(x.name, x.username, NodeTypeAuthCert)
.withParams({ ...machine, selectAuthCert: x })
.withDisabled(machine.status == -1 && machine.protocol == MachineProtocolEnum.Ssh.value)
.withIcon({
name: 'Ticket',
color: '#409eff',
})
.withIsLeaf(true)
);
})
.withContextMenuItems([
new ContextmenuItem('detail', '详情').withIcon('More').withOnClick((node: any) => showInfo(node.params)),
const NodeTypeMachine = (machine: any) => { new ContextmenuItem('status', '状态')
let contextMenuItems = []; .withIcon('Compass')
contextMenuItems.push(new ContextmenuItem('term', '打开终端').withIcon('Monitor').withOnClick(() => openTerminal(machine))); .withHideFunc((node: any) => node.params.protocol != MachineProtocolEnum.Ssh.value)
contextMenuItems.push(new ContextmenuItem('term-ex', '打开终端(新窗口)').withIcon('Monitor').withOnClick(() => openTerminal(machine, true))); .withOnClick((node: any) => showMachineStats(node.params)),
contextMenuItems.push(new ContextmenuItem('files', '文件管理').withIcon('FolderOpened').withOnClick(() => showFileManage(machine)));
contextMenuItems.push(new ContextmenuItem('scripts', '脚本管理').withIcon('Files').withOnClick(() => serviceManager(machine))); new ContextmenuItem('process', '进程')
contextMenuItems.push(new ContextmenuItem('detail', '详情').withIcon('More').withOnClick(() => showInfo(machine))); .withIcon('DataLine')
contextMenuItems.push(new ContextmenuItem('status', '状态').withIcon('Compass').withOnClick(() => showMachineStats(machine))); .withHideFunc((node: any) => node.params.protocol != MachineProtocolEnum.Ssh.value)
contextMenuItems.push(new ContextmenuItem('process', '进程').withIcon('DataLine').withOnClick(() => showProcess(machine))); .withOnClick((node: any) => showProcess(node.params)),
if (actionBtns[perms.updateMachine] && machine.enableRecorder == 1) {
contextMenuItems.push(new ContextmenuItem('edit', '终端回放').withIcon('Compass').withOnClick(() => showRec(machine))); new ContextmenuItem('edit', '终端回放')
.withIcon('Compass')
.withOnClick((node: any) => showRec(node.params))
.withHideFunc((node: any) => actionBtns[perms.updateMachine] && node.params.enableRecorder == 1),
]);
const NodeTypeAuthCert = new NodeType(MachineNodeType.AuthCert)
.withNodeDblclickFunc((node: TagTreeNode) => {
openTerminal(node.params);
})
.withContextMenuItems([
new ContextmenuItem('term', '打开终端').withIcon('Monitor').withOnClick((node: any) => openTerminal(node.params)),
new ContextmenuItem('term-ex', '打开终端(新窗口)').withIcon('Monitor').withOnClick((node: any) => openTerminal(node.params, true)),
new ContextmenuItem('files', '文件管理').withIcon('FolderOpened').withOnClick((node: any) => showFileManage(node.params)),
new ContextmenuItem('scripts', '脚本管理')
.withIcon('Files')
.withHideFunc((node: any) => node.params.protocol != MachineProtocolEnum.Ssh.value)
.withOnClick((node: any) => serviceManager(node.params)),
]);
watch(
() => autoOpenResource.value.machineCodePath,
(codePath: any) => {
autoOpenTerminal(codePath);
} }
);
return new NodeType(MachineNodeType.Machine).withContextMenuItems(contextMenuItems).withNodeDblclickFunc(() => { watch(
// for (let k of state.tabs.keys()) { () => state.activeTermName,
// // 存在该机器相关的终端tab则直接激活该tab (newValue, oldValue) => {
// if (k.startsWith(`${machine.id}_${machine.username}_`)) { oldValue && terminalRefs[oldValue]?.blur && terminalRefs[oldValue]?.blur();
// state.activeTermName = k; terminalRefs[newValue]?.focus && terminalRefs[newValue]?.focus();
// onTabChange(); }
// return; );
// }
// }
openTerminal(machine); onMounted(() => {
}); autoOpenTerminal(autoOpenResource.value.machineCodePath);
}; });
const openTerminal = (machine: any, ex?: boolean) => { const autoOpenTerminal = (codePath: string) => {
// 新窗口打开 if (!codePath) {
if (ex) {
const { href } = router.resolve({
path: `/machine/terminal`,
query: {
id: machine.id,
name: machine.name,
},
});
window.open(href, '_blank');
return; return;
} }
let { name, id, username } = machine; const typeAndCodes = getTagTypeCodeByPath(codePath);
const tagPath = typeAndCodes[TagResourceTypeEnum.Tag.value].join('/') + '/';
const machineCode = typeAndCodes[TagResourceTypeEnum.Machine.value][0];
state.defaultExpendKey = [tagPath, machineCode];
const authCertName = typeAndCodes[TagResourceTypeEnum.MachineAuthCert.value][0];
setTimeout(() => {
// 置空
autoOpenResourceStore.setMachineCodePath('');
tagTreeRef.value.setCurrentKey(authCertName);
const acNode = tagTreeRef.value.getNode(authCertName);
openTerminal(acNode.data.params);
}, 1000);
};
const openTerminal = (machine: any, ex?: boolean) => {
// 授权凭证名
const ac = machine.selectAuthCert.name;
// 新窗口打开
if (ex) {
if (machine.protocol == MachineProtocolEnum.Ssh.value) {
const { href } = router.resolve({
path: `/machine/terminal`,
query: {
ac,
name: machine.name,
},
});
window.open(href, '_blank');
return;
}
if (machine.protocol == MachineProtocolEnum.Rdp.value) {
const { href } = router.resolve({
path: `/machine/terminal-rdp`,
query: {
machineId: machine.id,
ac: ac,
name: machine.name,
},
});
window.open(href, '_blank');
return;
}
}
let { name } = machine;
const labelName = `${machine.selectAuthCert.username}@${name}`;
// 同一个机器的终端打开多次key后添加下划线和数字区分 // 同一个机器的终端打开多次key后添加下划线和数字区分
openIds[id] = openIds[id] ? ++openIds[id] : 1; openIds[ac] = openIds[ac] ? ++openIds[ac] : 1;
let sameIndex = openIds[id]; let sameIndex = openIds[ac];
let key = `${id}_${username}_${sameIndex}`; let key = `${ac}_${sameIndex}`;
// 只保留name的10个字,超出部分只保留前后4个字符,中间用省略号代替 // 只保留name的15个字,超出部分只保留前后10个字符,中间用省略号代替
let label = name.length > 10 ? name.slice(0, 4) + '...' + name.slice(-4) : name; const label = labelName.length > 15 ? labelName.slice(0, 10) + '...' + labelName.slice(-10) : labelName;
state.tabs.set(key, { let tab = {
key, key,
label: `${label}${sameIndex === 1 ? '' : ':' + sameIndex}`, // label组成为:总打开term次数+name+同一个机器打开的次数 label: `${label}${sameIndex === 1 ? '' : ':' + sameIndex}`, // label组成为:总打开term次数+name+同一个机器打开的次数
params: machine, params: machine,
socketUrl: getMachineTerminalSocketUrl(id), authCert: ac,
}); socketUrl: getMachineTerminalSocketUrl(ac),
};
state.tabs.set(key, tab);
state.activeTermName = key; state.activeTermName = key;
nextTick(() => { nextTick(() => {
handleReconnect(key); handleReconnect(tab);
}); });
}; };
const serviceManager = (row: any) => { const serviceManager = (row: any) => {
const authCert = row.selectAuthCert;
state.serviceDialog.machineId = row.id; state.serviceDialog.machineId = row.id;
state.serviceDialog.visible = true; state.serviceDialog.visible = true;
state.serviceDialog.title = `${row.name} => ${row.ip}`; state.serviceDialog.authCertName = authCert.name;
state.serviceDialog.title = `${row.name} => ${authCert.username}@${row.ip}`;
}; };
/** /**
@@ -302,9 +452,24 @@ const search = async () => {
}; };
const showFileManage = (selectionData: any) => { const showFileManage = (selectionData: any) => {
state.fileDialog.visible = true; const authCert = selectionData.selectAuthCert;
state.fileDialog.machineId = selectionData.id; if (selectionData.protocol == 1) {
state.fileDialog.title = `${selectionData.name} => ${selectionData.ip}`; state.fileDialog.visible = true;
state.fileDialog.protocol = selectionData.protocol;
state.fileDialog.machineId = selectionData.id;
state.fileDialog.authCertName = authCert.name;
state.fileDialog.title = `${selectionData.name} => ${authCert.username}@${selectionData.ip}`;
}
if (selectionData.protocol == 2) {
state.filesystemDialog.protocol = 2;
state.filesystemDialog.machineId = selectionData.id;
state.filesystemDialog.authCertName = authCert.name;
state.filesystemDialog.fileId = selectionData.id;
state.filesystemDialog.path = '/';
state.filesystemDialog.title = `远程桌面文件管理`;
state.filesystemDialog.visible = true;
}
}; };
const showInfo = (info: any) => { const showInfo = (info: any) => {
@@ -337,7 +502,6 @@ const onRemoveTab = (targetName: string) => {
} else { } else {
activeTermName = ''; activeTermName = '';
} }
let info = state.tabs.get(targetName); let info = state.tabs.get(targetName);
if (info) { if (info) {
terminalRefs[info.key]?.close(); terminalRefs[info.key]?.close();
@@ -360,26 +524,38 @@ const setTerminalRef = (el: any, key: any) => {
} }
}; };
const terminalWrapperRefs: any = {};
const setTerminalWrapperRef = (el: any, key: any) => {
if (key) {
terminalWrapperRefs[key] = el;
}
};
const onResizeTagTree = () => { const onResizeTagTree = () => {
fitTerminal(); fitTerminal();
}; };
const onTabChange = () => { const onTabChange = () => {
fitTerminal(); fitTerminal();
const nowTab = state.tabs.get(state.activeTermName);
tagTreeRef.value.setCurrentKey(nowTab?.authCert);
}; };
const fitTerminal = () => { const fitTerminal = () => {
setTimeout(() => { setTimeout(() => {
let info = state.tabs.get(state.activeTermName); let info = state.tabs.get(state.activeTermName);
if (info) { if (info) {
terminalRefs[info.key]?.fitTerminal(); terminalRefs[info.key]?.fitTerminal && terminalRefs[info.key]?.fitTerminal();
terminalRefs[info.key]?.focus(); terminalRefs[info.key]?.focus && terminalRefs[info.key]?.focus();
} }
}, 100); }, 100);
}; };
const handleReconnect = (key: string) => { const handleReconnect = (tab: any, force = false) => {
terminalRefs[key].init(); let width = terminalWrapperRefs[tab.key].offsetWidth;
let height = terminalWrapperRefs[tab.key].offsetHeight;
terminalRefs[tab.key]?.init(width, height, force);
}; };
</script> </script>

View File

@@ -6,12 +6,13 @@
:before-close="handleClose" :before-close="handleClose"
:close-on-click-modal="false" :close-on-click-modal="false"
:destroy-on-close="true" :destroy-on-close="true"
width="800" width="1000"
@open="getTermOps()" @open="getTermOps()"
> >
<page-table ref="pageTableRef" :page-api="machineApi.termOpRecs" :lazy="true" height="100%" v-model:query-form="query" :columns="columns"> <page-table ref="pageTableRef" :page-api="machineApi.termOpRecs" :lazy="true" height="100%" v-model:query-form="query" :columns="columns">
<template #action="{ data }"> <template #action="{ data }">
<el-button @click="playRec(data)" loading-icon="loading" :loading="data.playRecLoding" type="primary" link>回放</el-button> <el-button @click="playRec(data)" loading-icon="loading" :loading="data.playRecLoding" type="primary" link>回放</el-button>
<el-button @click="showExecCmds(data)" type="primary" link>命令</el-button>
</template> </template>
</page-table> </page-table>
</el-dialog> </el-dialog>
@@ -26,6 +27,17 @@
> >
<div ref="playerRef" id="rc-player"></div> <div ref="playerRef" id="rc-player"></div>
</el-dialog> </el-dialog>
<el-dialog title="执行命令记录" v-model="execCmdsDialogVisible" :destroy-on-close="true" width="500">
<el-table :data="state.execCmds" max-height="480" stripe size="small">
<el-table-column prop="cmd" label="命令" show-overflow-tooltip min-width="150px"> </el-table-column>
<el-table-column prop="time" label="执行时间" min-width="80" show-overflow-tooltip>
<template #default="scope">
{{ formatDate(new Date(scope.row.time * 1000).toString()) }}
</template>
</el-table-column>
</el-table>
</el-dialog>
</div> </div>
</template> </template>
@@ -36,6 +48,7 @@ import * as AsciinemaPlayer from 'asciinema-player';
import 'asciinema-player/dist/bundle/asciinema-player.css'; import 'asciinema-player/dist/bundle/asciinema-player.css';
import PageTable from '@/components/pagetable/PageTable.vue'; import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable'; import { TableColumn } from '@/components/pagetable';
import { formatDate } from '@/common/utils/format';
const props = defineProps({ const props = defineProps({
visible: { type: Boolean }, visible: { type: Boolean },
@@ -50,7 +63,7 @@ const columns = [
TableColumn.new('createTime', '开始时间').isTime().setMinWidth(150), TableColumn.new('createTime', '开始时间').isTime().setMinWidth(150),
TableColumn.new('endTime', '结束时间').isTime().setMinWidth(150), TableColumn.new('endTime', '结束时间').isTime().setMinWidth(150),
TableColumn.new('recordFilePath', '文件路径').setMinWidth(200), TableColumn.new('recordFilePath', '文件路径').setMinWidth(200),
TableColumn.new('action', '操作').isSlot().setMinWidth(60).fixedRight().alignCenter(), TableColumn.new('action', '操作').isSlot().setMinWidth(120).fixedRight().alignCenter(),
]; ];
const playerRef = ref(null); const playerRef = ref(null);
@@ -63,11 +76,12 @@ const state = reactive({
pageSize: 10, pageSize: 10,
machineId: 0, machineId: 0,
}, },
playerDialogVisible: false, playerDialogVisible: false,
execCmdsDialogVisible: false,
execCmds: [],
}); });
const { dialogVisible, query, playerDialogVisible } = toRefs(state); const { dialogVisible, query, playerDialogVisible, execCmdsDialogVisible } = toRefs(state);
watch(props, async (newValue: any) => { watch(props, async (newValue: any) => {
const visible = newValue.visible; const visible = newValue.visible;
@@ -82,6 +96,11 @@ const getTermOps = async () => {
pageTableRef.value.search(); pageTableRef.value.search();
}; };
const showExecCmds = (data: any) => {
state.execCmds = JSON.parse(data.execCmds);
state.execCmdsDialogVisible = true;
};
let player: any = null; let player: any = null;
const playRec = async (rec: any) => { const playRec = async (rec: any) => {
@@ -103,8 +122,8 @@ const playRec = async (rec: any) => {
idleTimeLimit: 2, idleTimeLimit: 2,
// fit: false, // fit: false,
// terminalFontSize: 'small', // terminalFontSize: 'small',
cols: 144, // cols: 144,
rows: 32, // rows: 32,
}); });
}); });
} finally { } finally {
@@ -127,6 +146,12 @@ const handleClose = () => {
</script> </script>
<style lang="scss"> <style lang="scss">
#terminalRecDialog { #terminalRecDialog {
overflow: hidden;
#rc-player {
overflow: hidden;
}
.el-overlay .el-overlay-dialog .el-dialog .el-dialog__body { .el-overlay .el-overlay-dialog .el-dialog .el-dialog__body {
padding: 0px !important; padding: 0px !important;
} }

View File

@@ -0,0 +1,34 @@
<template>
<div class="terminal-wrapper" ref="terminalWrapperRef">
<machine-rdp ref="rdpRef" :auth-cert="state.authCert" :machine-id="state.machineId" />
</div>
</template>
<script lang="ts" setup>
import { useRoute } from 'vue-router';
import MachineRdp from '@/components/terminal-rdp/MachineRdp.vue';
import { computed, onMounted, ref } from 'vue';
import { TerminalExpose } from '@/components/terminal-rdp';
const route = useRoute();
const rdpRef = ref({} as TerminalExpose);
const terminalWrapperRef = ref({} as any);
const state = computed(() => {
return {
authCert: route.query.ac as string,
machineId: Number(route.query.machineId),
};
});
onMounted(() => {
let width = terminalWrapperRef.value.clientWidth;
let height = terminalWrapperRef.value.clientHeight;
rdpRef.value?.init(width, height, false);
});
</script>
<style lang="scss">
.terminal-wrapper {
height: calc(100vh);
}
</style>

View File

@@ -71,7 +71,7 @@
draggable draggable
append-to-body append-to-body
> >
<TerminalBody ref="terminal" :cmd="terminalDialog.cmd" :socket-url="getMachineTerminalSocketUrl(terminalDialog.machineId)" height="560px" /> <TerminalBody ref="terminal" :cmd="terminalDialog.cmd" :socket-url="getMachineTerminalSocketUrl(props.authCertName)" height="560px" />
</el-dialog> </el-dialog>
<script-edit <script-edit
@@ -100,6 +100,7 @@ import { SearchItem } from '@/components/SearchForm';
const props = defineProps({ const props = defineProps({
visible: { type: Boolean }, visible: { type: Boolean },
machineId: { type: Number }, machineId: { type: Number },
authCertName: { type: String },
title: { type: String }, title: { type: String },
}); });
@@ -143,7 +144,6 @@ const state = reactive({
terminalDialog: { terminalDialog: {
visible: false, visible: false,
cmd: '', cmd: '',
machineId: 0,
}, },
}); });
@@ -195,6 +195,7 @@ const run = async (script: any) => {
if (script.type == ScriptResultEnum.Result.value || noResult) { if (script.type == ScriptResultEnum.Result.value || noResult) {
const res = await machineApi.runScript.request({ const res = await machineApi.runScript.request({
machineId: props.machineId, machineId: props.machineId,
ac: props.authCertName,
scriptId: script.id, scriptId: script.id,
params: JSON.stringify(state.scriptParamsDialog.params), params: JSON.stringify(state.scriptParamsDialog.params),
}); });
@@ -215,7 +216,6 @@ const run = async (script: any) => {
} }
state.terminalDialog.cmd = script; state.terminalDialog.cmd = script;
state.terminalDialog.visible = true; state.terminalDialog.visible = true;
state.terminalDialog.machineId = props.machineId as any;
return; return;
} }
}; };
@@ -236,7 +236,6 @@ function templateResolve(template: string, param: any) {
const closeTermnial = () => { const closeTermnial = () => {
state.terminalDialog.visible = false; state.terminalDialog.visible = false;
state.terminalDialog.machineId = 0;
}; };
const editScript = (data: any) => { const editScript = (data: any) => {

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="terminal-wrapper"> <div class="terminal-wrapper">
<TerminalBody :socket-url="getMachineTerminalSocketUrl(route.query.id)" /> <TerminalBody :socket-url="getMachineTerminalSocketUrl(route.query.ac)" />
</div> </div>
</template> </template>

View File

@@ -20,7 +20,7 @@ export const machineApi = {
// 删除机器 // 删除机器
del: Api.newDelete('/machines/{id}'), del: Api.newDelete('/machines/{id}'),
scripts: Api.newGet('/machines/{machineId}/scripts'), scripts: Api.newGet('/machines/{machineId}/scripts'),
runScript: Api.newGet('/machines/{machineId}/scripts/{scriptId}/run'), runScript: Api.newGet('/machines/scripts/{scriptId}/{ac}/run'),
saveScript: Api.newPost('/machines/{machineId}/scripts'), saveScript: Api.newPost('/machines/{machineId}/scripts'),
deleteScript: Api.newDelete('/machines/{machineId}/scripts/{scriptId}'), deleteScript: Api.newDelete('/machines/{machineId}/scripts/{scriptId}'),
// 获取配置文件列表 // 获取配置文件列表
@@ -42,20 +42,12 @@ export const machineApi = {
addConf: Api.newPost('/machines/{machineId}/files'), addConf: Api.newPost('/machines/{machineId}/files'),
// 删除配置的文件or目录 // 删除配置的文件or目录
delConf: Api.newDelete('/machines/{machineId}/files/{id}'), delConf: Api.newDelete('/machines/{machineId}/files/{id}'),
terminal: Api.newGet('/api/machines/{id}/terminal'),
// 机器终端操作记录列表 // 机器终端操作记录列表
termOpRecs: Api.newGet('/machines/{machineId}/term-recs'), termOpRecs: Api.newGet('/machines/{machineId}/term-recs'),
// 机器终端操作记录详情 // 机器终端操作记录详情
termOpRec: Api.newGet('/machines/{id}/term-recs/{recId}'), termOpRec: Api.newGet('/machines/{id}/term-recs/{recId}'),
}; };
export const authCertApi = {
baseList: Api.newGet('/sys/authcerts/base'),
list: Api.newGet('/sys/authcerts'),
save: Api.newPost('/sys/authcerts'),
delete: Api.newDelete('/sys/authcerts/{id}'),
};
export const cronJobApi = { export const cronJobApi = {
list: Api.newGet('/machine-cronjobs'), list: Api.newGet('/machine-cronjobs'),
relateMachineIds: Api.newGet('/machine-cronjobs/machine-ids'), relateMachineIds: Api.newGet('/machine-cronjobs/machine-ids'),
@@ -66,6 +58,16 @@ export const cronJobApi = {
execList: Api.newGet('/machine-cronjobs/execs'), execList: Api.newGet('/machine-cronjobs/execs'),
}; };
export function getMachineTerminalSocketUrl(machineId: any) { export const cmdConfApi = {
return `${config.baseWsUrl}/machines/${machineId}/terminal?${joinClientParams()}`; list: Api.newGet('/machine/security/cmd-confs'),
save: Api.newPost('/machine/security/cmd-confs'),
delete: Api.newDelete('/machine/security/cmd-confs/{id}'),
};
export function getMachineTerminalSocketUrl(authCertName: any) {
return `${config.baseWsUrl}/machines/terminal/${authCertName}?${joinClientParams()}`;
}
export function getMachineRdpSocketUrl(authCertName: any) {
return `${config.baseWsUrl}/machines/rdp/${authCertName}`;
} }

View File

@@ -1,118 +0,0 @@
<template>
<div>
<el-dialog :title="title" v-model="dvisible" :show-close="false" :before-close="cancel" width="500px" :destroy-on-close="true">
<el-form ref="acForm" :rules="rules" :model="form" label-width="auto">
<el-form-item prop="name" label="名称" required>
<el-input v-model="form.name"></el-input>
</el-form-item>
<el-form-item prop="authMethod" label="认证方式" required>
<el-select style="width: 100%" v-model="form.authMethod" placeholder="请选择认证方式">
<el-option key="1" label="密码" :value="1"> </el-option>
<el-option key="2" label="密钥" :value="2"> </el-option>
</el-select>
</el-form-item>
<el-form-item v-if="form.authMethod == 1" prop="password" label="密码">
<el-input type="password" show-password clearable v-model.trim="form.password" placeholder="请输入密码" autocomplete="new-password">
</el-input>
</el-form-item>
<el-form-item v-if="form.authMethod == 2" prop="password" label="秘钥">
<el-input type="textarea" :rows="5" v-model="form.password" placeholder="请将私钥文件内容拷贝至此"> </el-input>
</el-form-item>
<el-form-item v-if="form.authMethod == 2" prop="passphrase" label="秘钥密码">
<el-input type="password" v-model="form.passphrase"> </el-input>
</el-form-item>
<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="cancel()"> </el-button>
<el-button type="primary" :loading="btnLoading" @click="btnOk"> </el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { ref, toRefs, reactive, watch } from 'vue';
import { authCertApi } from '../api';
const props = defineProps({
visible: {
type: Boolean,
},
data: {
type: [Boolean, Object],
},
title: {
type: String,
},
});
//定义事件
const emit = defineEmits(['update:visible', 'cancel', 'val-change']);
const acForm: any = ref(null);
const rules = {
name: [
{
required: true,
message: '授权凭证名称不能为空',
trigger: ['change', 'blur'],
},
],
};
const state = reactive({
dvisible: false,
params: [] as any,
form: {
id: null,
name: '',
authMethod: 1,
password: '',
passphrase: '',
remark: '',
},
btnLoading: false,
});
const { dvisible, form, btnLoading } = toRefs(state);
watch(props, (newValue: any) => {
state.dvisible = newValue.visible;
if (newValue.data) {
state.form = { ...newValue.data };
} else {
state.form = { authMethod: 1 } as any;
state.params = [];
}
});
const cancel = () => {
// 更新父组件visible prop对应的值为false
emit('update:visible', false);
// 若父组件有取消事件,则调用
emit('cancel');
};
const btnOk = async () => {
acForm.value.validate(async (valid: boolean) => {
if (valid) {
state.btnLoading = true;
try {
await authCertApi.save.request(state.form);
emit('val-change', state.form);
cancel();
} finally {
state.btnLoading = false;
}
}
});
};
</script>
<style lang="scss"></style>

View File

@@ -1,106 +0,0 @@
<template>
<div>
<page-table
ref="pageTableRef"
:page-api="authCertApi.list"
:search-items="state.searchItems"
v-model:query-form="query"
:show-selection="true"
v-model:selection-data="selectionData"
:columns="state.columns"
>
<template #tableHeader>
<el-button type="primary" icon="plus" @click="edit(false)">添加</el-button>
<el-button :disabled="selectionData.length < 1" @click="deleteAc(selectionData)" type="danger" icon="delete">删除 </el-button>
</template>
<template #action="{ data }">
<el-button @click="edit(data)" type="primary" link>编辑 </el-button>
</template>
</page-table>
<auth-cert-edit :title="editor.title" v-model:visible="editor.visible" :data="editor.authcert" @val-change="editChange" />
</div>
</template>
<script lang="ts" setup>
import { toRefs, reactive, onMounted, ref, Ref } from 'vue';
import AuthCertEdit from './AuthCertEdit.vue';
import { authCertApi } from '../api';
import { ElMessage, ElMessageBox } from 'element-plus';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { AuthMethodEnum } from '../enums';
import { SearchItem } from '@/components/SearchForm';
const pageTableRef: Ref<any> = ref(null);
const state = reactive({
query: {
pageNum: 1,
pageSize: 0,
name: null,
},
searchItems: [SearchItem.input('name', '凭证名称')],
columns: [
TableColumn.new('name', '名称'),
TableColumn.new('authMethod', '认证方式').typeTag(AuthMethodEnum),
TableColumn.new('remark', '备注'),
TableColumn.new('creator', '创建人'),
TableColumn.new('createTime', '创建时间').isTime(),
TableColumn.new('creator', '修改者'),
TableColumn.new('createTime', '修改时间').isTime(),
TableColumn.new('action', '操作').isSlot().fixedRight().setMinWidth(65).alignCenter(),
],
selectionData: [],
paramsDialog: {
visible: false,
config: null as any,
params: {},
paramsFormItem: [] as any,
},
editor: {
title: '授权凭证保存',
visible: false,
authcert: {},
},
});
const { query, selectionData, editor } = toRefs(state);
onMounted(() => {});
const search = async () => {
pageTableRef.value.search();
};
const editChange = () => {
ElMessage.success('保存成功');
search();
};
const edit = (data: any) => {
if (data) {
state.editor.authcert = data;
} else {
state.editor.authcert = false;
}
state.editor.visible = true;
};
const deleteAc = async (data: any) => {
try {
await ElMessageBox.confirm(`确定删除该【${data.map((x: any) => x.name).join(', ')}授权凭证?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await authCertApi.delete.request({ id: data.map((x: any) => x.id).join(',') });
ElMessage.success('删除成功');
search();
} catch (err) {
//
}
};
</script>
<style lang="scss"></style>

View File

@@ -1,57 +0,0 @@
<template>
<div style="width: 100%">
<el-select @change="changeValue" v-model="id" filterable placeholder="请选择授权凭证,可前往[机器管理->授权凭证]添加" style="width: 100%">
<el-option v-for="ac in acs" :key="ac.id" :value="ac.id" :label="ac.name">
<el-tag v-if="ac.authMethod == 1" type="success" size="small">密码</el-tag>
<el-tag v-if="ac.authMethod == 2" size="small">密钥</el-tag>
<el-divider direction="vertical" border-style="dashed" />
{{ ac.name }}
<el-divider direction="vertical" border-style="dashed" />
{{ ac.remark }}
</el-option>
</el-select>
</div>
</template>
<script lang="ts" setup>
import { reactive, toRefs, onMounted } from 'vue';
import { authCertApi } from '../api';
//定义事件
const emit = defineEmits(['update:modelValue', 'change']);
const props = defineProps({
modelValue: {
type: [Number],
required: true,
},
});
const state = reactive({
acs: [] as any,
id: null as any,
});
const { acs, id } = toRefs(state);
onMounted(async () => {
await getAcs();
if (props.modelValue) {
state.id = props.modelValue;
}
});
const changeValue = (val: any) => {
emit('update:modelValue', val);
emit('change', val);
};
const getAcs = async () => {
const acs = await authCertApi.baseList.request({ pageSize: 100, type: 2 });
state.acs = acs.list;
};
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,69 @@
<template>
<div>
<el-popover placement="right" width="auto" title="机器详情" trigger="click">
<template #reference>
<el-link @click="getMachineDetail" type="primary">{{ props.code }}</el-link>
</template>
<el-descriptions v-loading="state.loading" :column="3" border>
<el-descriptions-item :span="1" label="机器id">{{ state.machineDetail.id }}</el-descriptions-item>
<el-descriptions-item :span="1" label="编号">{{ state.machineDetail.code }}</el-descriptions-item>
<el-descriptions-item :span="1" label="名称">{{ state.machineDetail.name }}</el-descriptions-item>
<el-descriptions-item :span="3" label="关联标签"><ResourceTags :tags="state.machineDetail.tags" /></el-descriptions-item>
<el-descriptions-item :span="2" label="IP">{{ state.machineDetail.ip }}</el-descriptions-item>
<el-descriptions-item :span="1" label="端口">{{ state.machineDetail.port }}</el-descriptions-item>
<el-descriptions-item :span="3" label="备注">{{ state.machineDetail.remark }}</el-descriptions-item>
<el-descriptions-item :span="1.5" label="SSH隧道">{{ state.machineDetail.sshTunnelMachineId > 0 ? '是' : '否' }} </el-descriptions-item>
<el-descriptions-item :span="1.5" label="终端回放">{{ state.machineDetail.enableRecorder == 1 ? '是' : '否' }} </el-descriptions-item>
<el-descriptions-item :span="2" label="创建时间">{{ formatDate(state.machineDetail.createTime) }} </el-descriptions-item>
<el-descriptions-item :span="1" label="创建者">{{ state.machineDetail.creator }}</el-descriptions-item>
<el-descriptions-item :span="2" label="更新时间">{{ formatDate(state.machineDetail.updateTime) }} </el-descriptions-item>
<el-descriptions-item :span="1" label="修改者">{{ state.machineDetail.modifier }}</el-descriptions-item>
</el-descriptions>
</el-popover>
</div>
</template>
<script lang="ts" setup>
import { reactive } from 'vue';
import { machineApi } from '../api';
import { formatDate } from '@/common/utils/format';
import ResourceTags from '../../component/ResourceTags.vue';
const props = defineProps({
code: {
type: [String],
requierd: true,
},
});
const state = reactive({
loading: false,
machineDetail: {} as any,
});
const getMachineDetail = async () => {
try {
state.machineDetail = {};
state.loading = true;
const res = await machineApi.list.request({
code: props.code,
});
if (res.total == 0) {
return;
}
state.machineDetail = res.list?.[0];
} finally {
state.loading = false;
}
};
</script>
<style></style>

View File

@@ -1,14 +1,18 @@
<template> <template>
<div class="mock-data-dialog"> <div class="mock-data-dialog">
<el-dialog <el-drawer
:title="title" :title="title"
v-model="dialogVisible" v-model="dialogVisible"
:close-on-click-modal="false" :close-on-click-modal="false"
:before-close="cancel" :before-close="cancel"
:show-close="true" :show-close="true"
:destroy-on-close="true" :destroy-on-close="true"
width="900px" size="40%"
> >
<template #header>
<DrawerHeader :header="title" :back="cancel" />
</template>
<el-form :model="form" ref="formRef" :rules="rules" label-width="auto"> <el-form :model="form" ref="formRef" :rules="rules" label-width="auto">
<el-form-item prop="name" label="名称"> <el-form-item prop="name" label="名称">
<el-input v-model="form.name" placeholder="请输入名称"></el-input> <el-input v-model="form.name" placeholder="请输入名称"></el-input>
@@ -34,19 +38,13 @@
<el-input v-model="form.remark" placeholder="请输入备注"></el-input> <el-input v-model="form.remark" placeholder="请输入备注"></el-input>
</el-form-item> </el-form-item>
<el-form-item prop="machineIds" label="关联机器">
<el-select multiple v-model="form.machineIds" filterable placeholder="请选关联机器" style="width: 100%">
<el-option v-for="ac in state.machines" :key="ac.id" :value="ac.id" :label="ac.ip">
{{ ac.ip }}
<el-divider direction="vertical" border-style="dashed" />
{{ ac.tagPath }}{{ ac.name }}
</el-option>
</el-select>
</el-form-item>
<el-form-item prop="script" label="执行脚本" required> <el-form-item prop="script" label="执行脚本" required>
<monaco-editor style="width: 100%" v-model="form.script" language="shell" height="300px" <monaco-editor style="width: 100%" v-model="form.script" language="shell" height="200px"
/></el-form-item> /></el-form-item>
<el-form-item ref="tagSelectRef" prop="codePaths" label="关联机器">
<tag-tree-check height="200px" :tag-type="TagResourceTypeEnum.Machine.value" v-model="form.codePaths" />
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -55,7 +53,7 @@
<el-button v-auth="'machine:script:save'" type="primary" :loading="btnLoading" @click="btnOk" :disabled="submitDisabled"> </el-button> <el-button v-auth="'machine:script:save'" type="primary" :loading="btnLoading" @click="btnOk" :disabled="submitDisabled"> </el-button>
</div> </div>
</template> </template>
</el-dialog> </el-drawer>
</div> </div>
</template> </template>
@@ -67,6 +65,9 @@ import { CronJobStatusEnum, CronJobSaveExecResTypeEnum } from '../enums';
import { notEmpty } from '@/common/assert'; import { notEmpty } from '@/common/assert';
import MonacoEditor from '@/components/monaco/MonacoEditor.vue'; import MonacoEditor from '@/components/monaco/MonacoEditor.vue';
import CrontabInput from '@/components/crontab/CrontabInput.vue'; import CrontabInput from '@/components/crontab/CrontabInput.vue';
import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue';
import TagTreeCheck from '../../component/TagTreeCheck.vue';
import { TagResourceTypeEnum } from '@/common/commonEnum';
const props = defineProps({ const props = defineProps({
visible: { visible: {
@@ -130,11 +131,11 @@ const state = reactive({
id: null, id: null,
name: '', name: '',
cron: '', cron: '',
machineIds: [],
remark: '', remark: '',
script: '', script: '',
status: 1, status: 1,
saveExecResType: -1, saveExecResType: -1,
codePaths: [],
}, },
machines: [] as any, machines: [] as any,
btnLoading: false, btnLoading: false,
@@ -154,7 +155,7 @@ watch(props, async (newValue: any) => {
} }
if (newValue.data) { if (newValue.data) {
state.form = { ...newValue.data }; state.form = { ...newValue.data };
state.form.machineIds = await cronJobApi.relateMachineIds.request({ cronJobId: state.form.id }); state.form.codePaths = newValue.data.tags?.map((tag: any) => tag.codePath);
} else { } else {
state.form = { script: '', status: 1 } as any; state.form = { script: '', status: 1 } as any;
state.chooseMachines = []; state.chooseMachines = [];

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