22 Commits

Author SHA1 Message Date
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
272 changed files with 28475 additions and 6260 deletions

View File

@@ -10,19 +10,19 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@vueuse/core": "^10.8.0",
"@vueuse/core": "^10.9.0",
"asciinema-player": "^3.7.0",
"axios": "^1.6.2",
"clipboard": "^2.0.11",
"countup.js": "^2.8.0",
"cropperjs": "^1.6.1",
"echarts": "^5.5.0",
"element-plus": "^2.6.0",
"js-base64": "^3.7.5",
"element-plus": "^2.6.2",
"js-base64": "^3.7.7",
"jsencrypt": "^3.3.2",
"lodash": "^4.17.21",
"mitt": "^3.0.1",
"monaco-editor": "^0.46.0",
"monaco-editor": "^0.47.0",
"monaco-sql-languages": "^0.11.0",
"monaco-themes": "^0.4.4",
"nprogress": "^0.2.0",
@@ -32,6 +32,7 @@
"sortablejs": "^1.15.2",
"splitpanes": "^3.1.5",
"sql-formatter": "^15.0.2",
"trzsz": "^1.1.5",
"uuid": "^9.0.1",
"vue": "^3.4.21",
"vue-router": "^4.3.0",
@@ -56,7 +57,7 @@
"prettier": "^3.2.5",
"sass": "^1.69.0",
"typescript": "^5.3.2",
"vite": "^5.1.4",
"vite": "^5.2.8",
"vue-eslint-parser": "^9.4.2"
},
"browserslist": [

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -2,8 +2,13 @@ import EnumValue from './Enum';
// 标签关联的资源类型
export const TagResourceTypeEnum = {
Machine: EnumValue.of(1, '机器'),
Db: EnumValue.of(2, '数据库'),
Redis: EnumValue.of(3, 'redis'),
Mongo: EnumValue.of(4, 'mongo'),
AuthCert: EnumValue.of(-2, '公共凭证').setExtra({ icon: 'Ticket' }),
Tag: EnumValue.of(-1, '标签').setExtra({ icon: 'CollectionTag' }),
Machine: EnumValue.of(1, '机器').setExtra({ icon: 'Monitor' }).tagTypeSuccess(),
Db: EnumValue.of(2, '数据库').setExtra({ icon: 'Coin' }).tagTypeWarning(),
Redis: EnumValue.of(3, 'redis').setExtra({ icon: 'iconfont icon-redis' }).tagTypeInfo(),
Mongo: EnumValue.of(4, 'mongo').setExtra({ icon: 'iconfont icon-mongo' }).tagTypeDanger(),
MachineAuthCert: EnumValue.of(11, '机器-授权凭证').setExtra({ icon: 'Ticket' }),
};

View File

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

View File

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

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) {
const keys = path.split('.');
let result = obj;

View File

@@ -40,7 +40,7 @@ onMounted(() => {
});
const convert = (value: any) => {
const enumValue = EnumValue.getEnumByValue(Object.values(props.enums as any) as any, value) as any;
const enumValue = EnumValue.getEnumByValue(props.enums, value) as any;
if (!enumValue) {
state.enumLabel = '-';
state.type = 'danger';

View File

@@ -185,7 +185,7 @@ import SvgIcon from '@/components/svgIcon/index.vue';
import { usePageTable } from '@/hooks/usePageTable';
import { ElTable } from 'element-plus';
const emit = defineEmits(['update:queryForm', 'update:selectionData', 'pageChange']);
const emit = defineEmits(['update:selectionData', 'pageChange']);
export interface PageTableProps {
size?: string;

View File

@@ -0,0 +1,478 @@
<template>
<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 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';
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);
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,130 @@
<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" @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 },
title: { type: String },
});
const emit = defineEmits(['update:visible', 'cancel', 'update:machineId']);
const state = reactive({
dialogVisible: false,
title: '',
status: TerminalStatus.NoConnected,
});
const { dialogVisible } = toRefs(state);
watch(props, async (newValue: any) => {
const visible = newValue.visible;
state.dialogVisible = visible;
if (visible) {
state.title = newValue.title;
}
});
const connect = (force = false) => {
rdpRef.value?.disconnect();
let width = 1024;
let height = 710;
rdpRef.value?.connect(width, height, force);
};
const handleStatusChange = (status: TerminalStatus) => {
state.status = status;
};
/**
* 关闭取消按钮触发的事件
*/
const handleClose = () => {
emit('update:visible', false);
emit('update:machineId', null);
emit('cancel');
rdpRef.value?.disconnect();
};
</script>
<style lang="scss">
.rdpDialog {
.el-dialog {
padding: 0;
.el-dialog__header {
padding: 10px;
}
}
.el-overlay .el-overlay-dialog .el-dialog .el-dialog__body {
max-height: 100% !important;
padding: 0 !important;
}
.terminal-title-wrapper {
display: flex;
justify-content: space-between;
font-size: 16px;
.title-right-fixed {
display: flex;
align-items: center;
font-size: 20px;
text-align: end;
}
}
}
</style>

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ const useCustomFetch = createFetch({
combination: 'chain',
options: {
immediate: false,
timeout: 60000,
timeout: 600000,
// beforeFetch in pre-configured instance will only run when the newly spawned instance do not pass beforeFetch
async beforeFetch({ options }) {
const token = getToken();

View File

@@ -89,13 +89,21 @@ type RouterConvCallbackFunc = (router: any) => void;
* @param meta.link ==> 外链地址
* */
export function backEndRouterConverter(routes: any, callbackFunc: RouterConvCallbackFunc = null as any, parentPath: string = '/') {
if (!routes) return [];
return routes.map((item: any) => {
if (!routes) {
return [];
}
const routeItems = [];
for (let item of routes) {
if (!item.meta) {
return item;
}
// 将json字符串的meta转为对象
item.meta = JSON.parse(item.meta);
if (item.meta.isHide) {
continue;
}
// 将meta.comoponet 解析为route.component
if (item.meta.component) {
item.component = dynamicImport(dynamicViewsModules, item.meta.component);
@@ -126,8 +134,10 @@ export function backEndRouterConverter(routes: any, callbackFunc: RouterConvCall
// 存在回调,则执行回调
callbackFunc && callbackFunc(item);
item.children && backEndRouterConverter(item.children, callbackFunc, item.path);
return item;
});
routeItems.push(item);
}
return routeItems;
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
<template>
<div v-if="props.authCerts">
<el-select default-first-option value-key="name" style="width: 100%" 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,262 @@
<template>
<div class="auth-cert-edit">
<el-dialog :title="props.title" v-model="dialogVisible" :show-close="false" width="500px" :destroy-on-close="true" :close-on-click-modal="false">
<el-form ref="acForm" :model="state.form" label-width="auto" :rules="rules">
<el-form-item prop="type" label="凭证类型" required>
<el-select @change="changeType" v-model="form.type" placeholder="请选择凭证类型">
<el-option
v-for="item in AuthCertTypeEnum"
:key="item.value"
:label="item.label"
:value="item.value"
v-show="!props.disableType?.includes(item.value)"
>
</el-option>
</el-select>
</el-form-item>
<el-form-item prop="ciphertextType" label="密文类型" required>
<el-select v-model="form.ciphertextType" placeholder="请选择密文类型" @change="changeCiphertextType">
<el-option
v-for="item in AuthCertCiphertextTypeEnum"
:key="item.value"
:label="item.label"
:value="item.value"
v-show="!props.disableCiphertextType?.includes(item.value)"
:disabled="item.value == AuthCertCiphertextTypeEnum.Public.value && form.type == AuthCertTypeEnum.Public.value"
>
</el-option>
</el-select>
</el-form-item>
<template v-if="showResourceEdit">
<el-form-item prop="type" label="资源类型" required>
<el-select :disabled="form.id" v-model="form.resourceType" placeholder="请选择资源类型">
<el-option
:key="TagResourceTypeEnum.Machine.value"
:label="TagResourceTypeEnum.Machine.label"
:value="TagResourceTypeEnum.Machine.value"
/>
<el-option :key="TagResourceTypeEnum.Db.value" :label="TagResourceTypeEnum.Db.label" :value="TagResourceTypeEnum.Db.value" />
</el-select>
</el-form-item>
<el-form-item prop="resourceCode" label="资源编号" required>
<el-input :disabled="form.id" v-model="form.resourceCode" placeholder="请输入资源编号"></el-input>
</el-form-item>
</template>
<el-form-item prop="name" label="名称" required>
<el-input :disabled="form.id" v-model="form.name" placeholder="请输入凭证名 (全局唯一)"></el-input>
</el-form-item>
<template v-if="form.ciphertextType != AuthCertCiphertextTypeEnum.Public.value">
<el-form-item prop="username" label="用户名">
<el-input v-model="form.username"></el-input>
</el-form-item>
<el-form-item v-if="form.ciphertextType == AuthCertCiphertextTypeEnum.Password.value" prop="ciphertext" label="密码">
<el-input type="password" show-password clearable v-model.trim="form.ciphertext" placeholder="请输入密码" autocomplete="new-password">
<template #suffix>
<SvgIcon v-if="form.id" v-auth="'authcert:showciphertext'" @click="getCiphertext" name="search" />
</template>
</el-input>
</el-form-item>
<el-form-item v-if="form.ciphertextType == AuthCertCiphertextTypeEnum.PrivateKey.value" prop="ciphertext" label="秘钥">
<div class="w100" style="position: relative">
<SvgIcon
v-if="form.id"
v-auth="'authcert:showciphertext'"
@click="getCiphertext"
name="search"
style="position: absolute; top: 5px; right: 5px; cursor: pointer; z-index: 1"
/>
<el-input type="textarea" :rows="5" v-model="form.ciphertext" placeholder="请将私钥文件内容拷贝至此"> </el-input>
</div>
</el-form-item>
<el-form-item v-if="form.ciphertextType == AuthCertCiphertextTypeEnum.PrivateKey.value" prop="passphrase" label="秘钥密码">
<el-input type="password" show-password v-model="form.extra.passphrase"> </el-input>
</el-form-item>
</template>
<template v-else>
<el-form-item label="公共凭证">
<el-select default-first-option filterable v-model="form.ciphertext" @change="changePublicAuthCert">
<el-option v-for="item in state.publicAuthCerts" :key="item.name" :label="item.name" :value="item.name">
{{ item.name }}
<el-divider direction="vertical" border-style="dashed" />
{{ item.username }}
<el-divider direction="vertical" border-style="dashed" />
<EnumTag :value="item.ciphertextType" :enums="AuthCertCiphertextTypeEnum" />
<el-divider direction="vertical" border-style="dashed" />
{{ item.remark }}
</el-option>
</el-select>
</el-form-item>
</template>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2"></el-input>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancelEdit"> </el-button>
<el-button type="primary" :loading="btnLoading" @click="btnOk"> </el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref, toRefs, onMounted, watch, computed } 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'],
},
],
};
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;
});
onMounted(() => {
setForm(props.authCert);
});
watch(
() => props.authCert,
(val: any) => {
setForm(val);
}
);
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) {
state.form.type = AuthCertTypeEnum.Private.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;
emit('cancel');
setTimeout(() => {
acForm.value?.resetFields();
state.form = { ...DefaultForm };
}, 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,150 @@
<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;
setTimeout(() => {
state.form = {};
}, 300);
};
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

@@ -46,7 +46,7 @@ onMounted(async () => {
const getSshTunnelMachines = async () => {
if (state.sshTunnelMachineList.length == 0) {
const res = await machineApi.list.request({ pageNum: 1, pageSize: 100 });
const res = await machineApi.list.request({ pageNum: 1, pageSize: 100, ssh: 1 });
state.sshTunnelMachineList = res.list;
}
};

View File

@@ -1,7 +1,7 @@
<template>
<div class="tag-tree card pd5">
<el-scrollbar>
<el-input v-model="filterText" placeholder="输入关键字->搜索已展开节点信息" clearable size="small" class="mb5 w100" />
<div class="card pd5">
<el-input v-model="filterText" placeholder="输入关键字->搜索已展开节点信息" clearable size="small" class="mb5 w100" />
<el-scrollbar class="tag-tree">
<el-tree
ref="treeRef"
:highlight-current="true"
@@ -10,7 +10,7 @@
:props="treeProps"
lazy
node-key="key"
:expand-on-click-node="true"
:expand-on-click-node="false"
:filter-node-method="filterNode"
@node-click="treeNodeClick"
@node-expand="treeNodeClick"
@@ -140,8 +140,8 @@ const loadNode = async (node: any, resolve: any) => {
};
const treeNodeClick = (data: any) => {
emit('nodeClick', data);
if (!data.disabled && !data.type.nodeDblclickFunc && data.type.nodeClickFunc) {
emit('nodeClick', data);
data.type.nodeClickFunc(data);
}
// 关闭可能存在的右击菜单
@@ -206,7 +206,7 @@ defineExpose({
<style lang="scss" scoped>
.tag-tree {
height: calc(100vh - 108px);
height: calc(100vh - 148px);
.el-tree {
display: inline-block;

View File

@@ -58,7 +58,7 @@ onMounted(async () => {
state.selectTags = props.selectTags;
}
state.tags = await tagApi.getTagTrees.request(null);
state.tags = await tagApi.getTagTrees.request({ type: -1 });
});
const changeTag = () => {

View File

@@ -30,13 +30,14 @@
remote
:remote-method="getInstances"
@change="changeInstance"
v-model="form.instanceId"
v-model="state.selectInstalce"
value-key="id"
placeholder="请输入实例名称搜索并选择实例"
filterable
clearable
class="w100"
>
<el-option v-for="item in state.instances" :key="item.id" :label="`${item.name}`" :value="item.id">
<el-option v-for="item in state.instances" :key="item.id" :label="`${item.name}`" :value="item">
{{ item.name }}
<el-divider direction="vertical" border-style="dashed" />
@@ -47,6 +48,26 @@
</el-select>
</el-form-item>
<el-form-item prop="authCertName" label="授权凭证" required>
<el-select @focus="getAuthCerts" @change="changeAuthCert" v-model="form.authCertName" placeholder="请选择授权凭证" filterable>
<el-option v-for="item in state.authCerts" :key="item.id" :label="`${item.name}`" :value="item.name">
{{ item.name }}
<el-divider direction="vertical" border-style="dashed" />
{{ item.username }}
<el-divider direction="vertical" border-style="dashed" />
<EnumTag :value="item.ciphertextType" :enums="AuthCertCiphertextTypeEnum" />
<el-divider direction="vertical" border-style="dashed" />
{{ item.remark }}
</el-option>
</el-select>
</el-form-item>
<el-form-item prop="code" label="编号" required>
<el-input :disabled="form.id" v-model.trim="form.code" placeholder="请输入编号 (数字字母下划线), 不可修改" auto-complete="off"></el-input>
</el-form-item>
<el-form-item prop="name" label="别名" required>
<el-input v-model.trim="form.name" placeholder="请输入数据库别名" auto-complete="off"></el-input>
</el-form-item>
@@ -96,6 +117,11 @@ import TagTreeSelect from '../component/TagTreeSelect.vue';
import type { CheckboxValueType } from 'element-plus';
import ProcdefSelectFormItem from '@/views/flow/components/ProcdefSelectFormItem.vue';
import { DbType } from '@/views/ops/db/dialect';
import { ResourceCodePattern } from '@/common/pattern';
import { resourceAuthCertApi } from '../tag/api';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import EnumTag from '@/components/enumtag/EnumTag.vue';
import { AuthCertCiphertextTypeEnum } from '../tag/enums';
const props = defineProps({
visible: {
@@ -128,7 +154,18 @@ const rules = {
trigger: ['change', 'blur'],
},
],
code: [
{
required: true,
message: '请输入编码',
trigger: ['change', 'blur'],
},
{
pattern: ResourceCodePattern.pattern,
message: ResourceCodePattern.message,
trigger: ['blur'],
},
],
name: [
{
required: true,
@@ -157,6 +194,8 @@ const state = reactive({
dbNamesSelected: [] as any,
dbNamesFiltered: [] as any,
filterString: '',
selectInstalce: {} as any,
authCerts: [] as any,
form: {
id: null,
tagId: [],
@@ -165,6 +204,7 @@ const state = reactive({
database: '',
remark: '',
instanceId: null as any,
authCertName: '',
flowProcdefKey: '',
},
instances: [] as any,
@@ -190,14 +230,27 @@ watch(props, async (newValue: any) => {
}
});
const changeInstance = () => {
const changeInstance = async () => {
state.dbNamesSelected = [];
getAllDatabase();
state.form.instanceId = state.selectInstalce.id;
};
const getAllDatabase = async () => {
const getAuthCerts = async () => {
const res = await resourceAuthCertApi.listByQuery.request({
resourceCode: state.selectInstalce.code,
resourceType: TagResourceTypeEnum.Db.value,
pageSize: 100,
});
state.authCerts = res.list || [];
};
const changeAuthCert = (val: string) => {
getAllDatabase(val);
};
const getAllDatabase = async (authCertName: string) => {
if (state.form.instanceId > 0) {
let dbs = await dbApi.getAllDatabase.request({ instanceId: state.form.instanceId });
let dbs = await dbApi.getAllDatabase.request({ instanceId: state.form.instanceId, authCertName });
state.allDatabases = dbs;
// 如果是oracle且没查出数据库列表则取实例sid
@@ -223,8 +276,9 @@ const open = async () => {
if (state.form.instanceId) {
// 根据id获取因为需要回显实例名称
await getInstances('', state.form.instanceId);
state.selectInstalce = state.instances[0];
await getAllDatabase(state.form.authCertName);
}
await getAllDatabase();
};
const btnOk = async () => {

View File

@@ -9,6 +9,7 @@
:show-selection="true"
v-model:selection-data="state.selectionData"
:columns="columns"
lazy
>
<template #instanceSelect>
<el-select remote :remote-method="getInstances" v-model="query.instanceId" placeholder="输入并选择实例" filterable clearable>
@@ -61,7 +62,7 @@
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item :command="{ type: 'detail', data }"> 详情 </el-dropdown-item>
<el-dropdown-item :command="{ type: 'dumpDb', data }" v-if="supportAction('dumpDb', data.type)"> 导出 </el-dropdown-item>
<el-dropdown-item :command="{ type: 'dumpDb', data }"> 导出 </el-dropdown-item>
<el-dropdown-item :command="{ type: 'backupDb', data }" v-if="actionBtns[perms.backupDb] && supportAction('backupDb', data.type)">
备份任务
</el-dropdown-item>
@@ -88,16 +89,16 @@
<el-col :span="9">
<el-form-item label="导出内容: ">
<el-checkbox-group v-model="exportDialog.contents" :min="1">
<el-checkbox label="结构" />
<el-checkbox label="数据" />
<el-checkbox label="结构" value="结构" />
<el-checkbox label="数据" value="数据" />
</el-checkbox-group>
</el-form-item>
</el-col>
<el-col :span="9">
<el-form-item label="扩展名: ">
<el-radio-group v-model="exportDialog.extName">
<el-radio label="sql" />
<el-radio label="gzip" />
<el-radio label="sql" value="sql" />
<el-radio label="gzip" value="gzip" />
</el-radio-group>
</el-form-item>
</el-col>
@@ -193,7 +194,7 @@
</el-descriptions>
</el-dialog>
<db-edit @val-change="search" :title="dbEditDialog.title" v-model:visible="dbEditDialog.visible" v-model:db="dbEditDialog.data"></db-edit>
<db-edit @val-change="search()" :title="dbEditDialog.title" v-model:visible="dbEditDialog.visible" v-model:db="dbEditDialog.data"></db-edit>
</div>
</template>
@@ -219,9 +220,17 @@ import DbBackupList from './DbBackupList.vue';
import DbBackupHistoryList from './DbBackupHistoryList.vue';
import DbRestoreList from './DbRestoreList.vue';
import ResourceTags from '../component/ResourceTags.vue';
import { sleep } from '@/common/utils/loading';
const DbEdit = defineAsyncComponent(() => import('./DbEdit.vue'));
const props = defineProps({
lazy: {
type: [Boolean],
default: false,
},
});
const perms = {
base: 'db',
saveDb: 'db:save',
@@ -230,10 +239,15 @@ const perms = {
restoreDb: 'db:restore',
};
const searchItems = [getTagPathSearchItem(TagResourceTypeEnum.Db.value), SearchItem.slot('instanceId', '实例', 'instanceSelect')];
const searchItems = [
getTagPathSearchItem(TagResourceTypeEnum.Db.value),
SearchItem.slot('instanceId', '实例', 'instanceSelect'),
SearchItem.input('code', '编号'),
];
const columns = ref([
TableColumn.new('tags[0].tagPath', '关联标签').isSlot('tagPath').setAddWidth(20),
TableColumn.new('code', '编号'),
TableColumn.new('name', '名称'),
TableColumn.new('type', '类型').isSlot().setAddWidth(-15).alignCenter(),
TableColumn.new('instanceName', '实例名'),
@@ -336,6 +350,9 @@ onMounted(async () => {
if (Object.keys(actionBtns).length > 0) {
columns.value.push(actionColumn);
}
if (!props.lazy) {
search();
}
});
const checkRouteTagPath = (query: any) => {
@@ -345,7 +362,10 @@ const checkRouteTagPath = (query: any) => {
return query;
};
const search = async () => {
const search = async (tagPath: string = '') => {
if (tagPath) {
state.query.tagPath = tagPath;
}
pageTableRef.value.search();
};
@@ -485,9 +505,8 @@ const onDumpDbs = async (row: any) => {
/**
* 数据库信息导出
*/
const dumpDbs = () => {
const dumpDbs = async () => {
isTrue(state.exportDialog.value.length > 0, '请添加要导出的数据库');
const a = document.createElement('a');
let type = 0;
for (let c of state.exportDialog.contents) {
if (c == '结构') {
@@ -496,13 +515,15 @@ const dumpDbs = () => {
type += 2;
}
}
a.setAttribute(
'href',
`${config.baseApiUrl}/dbs/${state.exportDialog.dbId}/dump?db=${state.exportDialog.value.join(',')}&type=${type}&extName=${
state.exportDialog.extName
}&${joinClientParams()}`
);
a.click();
for (let db of state.exportDialog.value) {
const a = document.createElement('a');
a.setAttribute(
'href',
`${config.baseApiUrl}/dbs/${state.exportDialog.dbId}/dump?db=${db}&type=${type}&extName=${state.exportDialog.extName}&${joinClientParams()}`
);
a.click();
await sleep(500);
}
state.exportDialog.visible = false;
};
@@ -515,6 +536,8 @@ const supportAction = (action: string, dbType: string): boolean => {
}
return actions.includes(action);
};
defineExpose({ search });
</script>
<style lang="scss">
.db-list {

View File

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

@@ -1,72 +1,111 @@
<template>
<div>
<el-dialog :title="title" v-model="dialogVisible" :before-close="cancel" :close-on-click-modal="false" :destroy-on-close="true" width="38%">
<el-drawer :title="title" v-model="dialogVisible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<template #header>
<DrawerHeader :header="title" :back="cancel" />
</template>
<el-form :model="form" ref="dbForm" :rules="rules" label-width="auto">
<el-tabs v-model="tabActiveName">
<el-tab-pane label="基础信息" name="basic">
<el-form-item prop="name" label="别名" required>
<el-input v-model.trim="form.name" placeholder="请输入数据库别名" auto-complete="off"></el-input>
</el-form-item>
<el-form-item prop="type" label="类型" required>
<el-select @change="changeDbType" style="width: 100%" v-model="form.type" placeholder="请选择数据库类型">
<el-option
v-for="(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-divider content-position="left">基本</el-divider>
<el-form-item prop="code" label="编号" required>
<el-input :disabled="form.id" v-model.trim="form.code" placeholder="请输入编号 (数字字母下划线), 不可修改" auto-complete="off"></el-input>
</el-form-item>
<el-form-item prop="name" label="名称" required>
<el-input v-model.trim="form.name" placeholder="请输入数据库别名" auto-complete="off"></el-input>
</el-form-item>
<template #prefix>
<SvgIcon :name="getDbDialect(form.type).getInfo().icon" :size="20" />
<el-form-item prop="type" label="类型" required>
<el-select @change="changeDbType" style="width: 100%" v-model="form.type" placeholder="请选择数据库类型">
<el-option
v-for="(dbTypeAndDialect, key) in getDbDialectMap()"
:key="key"
:value="dbTypeAndDialect[0]"
:label="dbTypeAndDialect[1].getInfo().name"
>
<SvgIcon :name="dbTypeAndDialect[1].getInfo().icon" :size="20" />
{{ dbTypeAndDialect[1].getInfo().name }}
</el-option>
<template #prefix>
<SvgIcon :name="getDbDialect(form.type).getInfo().icon" :size="20" />
</template>
</el-select>
</el-form-item>
<el-form-item v-if="form.type !== DbType.sqlite" prop="host" label="host" required>
<el-col :span="18">
<el-input :disabled="form.id !== undefined" v-model.trim="form.host" placeholder="请输入主机ip" auto-complete="off"></el-input>
</el-col>
<el-col style="text-align: center" :span="1">:</el-col>
<el-col :span="5">
<el-input type="number" v-model.number="form.port" placeholder="端口"></el-input>
</el-col>
</el-form-item>
<el-form-item v-if="form.type === DbType.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>
<template v-if="form.type !== DbType.sqlite">
<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>
</template>
<!--
<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-select>
</el-form-item>
<el-form-item v-if="form.type !== DbType.sqlite" prop="host" label="host" required>
<el-col :span="18">
<el-input :disabled="form.id !== undefined" v-model.trim="form.host" placeholder="请输入主机ip" auto-complete="off"></el-input>
</el-col>
<el-col style="text-align: center" :span="1">:</el-col>
<el-col :span="5">
<el-input type="number" v-model.number="form.port" placeholder="端口"></el-input>
</el-col>
</el-form-item>
</el-popover>
</template>
</el-input>
</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" prop="sid" label="SID">
<el-input v-model.trim="form.sid" placeholder="请输入服务id"></el-input>
</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-input v-model="form.remark" auto-complete="off" type="textarea"></el-input>
</el-form-item>
</el-tab-pane>
<el-tab-pane label="其他配置" name="other">
<el-form-item prop="params" label="连接参数">
<el-input v-model.trim="form.params" placeholder="其他连接参数,形如: key1=value1&key2=value2">
<!-- <template #suffix>
<el-divider content-position="left">其他</el-divider>
<el-form-item prop="params" label="连接参数">
<el-input v-model.trim="form.params" placeholder="其他连接参数,形如: key1=value1&key2=value2">
<!-- <template #suffix>
<el-link
target="_blank"
href="https://github.com/go-sql-driver/mysql#parameters"
@@ -76,24 +115,21 @@
>参数参考</el-link
>
</template> -->
</el-input>
</el-form-item>
</el-input>
</el-form-item>
<el-form-item prop="sshTunnelMachineId" label="SSH隧道">
<ssh-tunnel-select v-model="form.sshTunnelMachineId" />
</el-form-item>
</el-tab-pane>
</el-tabs>
<el-form-item prop="sshTunnelMachineId" label="SSH隧道">
<ssh-tunnel-select v-model="form.sshTunnelMachineId" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="testConn" :loading="testConnBtnLoading" type="success">测试连接</el-button>
<el-button @click="cancel()"> </el-button>
<el-button type="primary" :loading="saveBtnLoading" @click="btnOk"> </el-button>
</div>
</template>
</el-dialog>
</el-drawer>
</div>
</template>
@@ -101,11 +137,14 @@
import { reactive, ref, toRefs, watch } from 'vue';
import { dbApi } from './api';
import { ElMessage } from 'element-plus';
import { notBlank } from '@/common/assert';
import { RsaEncrypt } from '@/common/rsa';
import SshTunnelSelect from '../component/SshTunnelSelect.vue';
import { DbType, getDbDialect, getDbDialectMap } from './dialect';
import SvgIcon from '@/components/svgIcon/index.vue';
import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue';
import { ResourceCodePattern } from '@/common/pattern';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import ResourceAuthCertTableEdit from '../component/ResourceAuthCertTableEdit.vue';
import { AuthCertCiphertextTypeEnum } from '../tag/enums';
const props = defineProps({
visible: {
@@ -123,6 +162,18 @@ const props = defineProps({
const emit = defineEmits(['update:visible', 'cancel', 'val-change']);
const rules = {
code: [
{
required: true,
message: '请输入编码',
trigger: ['change', 'blur'],
},
{
pattern: ResourceCodePattern.pattern,
message: ResourceCodePattern.message,
trigger: ['blur'],
},
],
name: [
{
required: true,
@@ -144,13 +195,6 @@ const rules = {
trigger: ['blur'],
},
],
username: [
{
required: true,
message: '请输入用户名',
trigger: ['change', 'blur'],
},
],
sid: [
{
required: true,
@@ -164,28 +208,24 @@ const dbForm: any = ref(null);
const state = reactive({
dialogVisible: false,
tabActiveName: 'basic',
extra: {} as any, // 连接需要的额外参数json
form: {
id: null,
type: '',
code: '',
name: null,
host: '',
port: null,
username: null,
sid: null, // oracle类项目需要服务id
password: null,
authCerts: [],
extra: '', // 连接需要的额外参数json字符串
params: null,
remark: '',
sshTunnelMachineId: null as any,
},
submitForm: {},
// 原密码
pwd: '',
// 原用户名
oldUserName: null,
submitForm: {} as any,
});
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: testConnBtnLoading, execute: testConnExec } = dbApi.testConn.useApi(submitForm);
@@ -195,13 +235,16 @@ watch(props, (newValue: any) => {
if (!state.dialogVisible) {
return;
}
state.tabActiveName = 'basic';
if (newValue.data) {
state.form = { ...newValue.data };
state.oldUserName = state.form.username;
try {
state.extra = JSON.parse(state.form.extra);
} catch (e) {
state.extra = {};
}
} else {
state.form = { port: null, type: DbType.mysql } as any;
state.oldUserName = null;
state.form.authCerts = [];
}
});
@@ -209,22 +252,21 @@ 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 });
state.extra = {};
};
const getReqForm = async () => {
const reqForm = { ...state.form };
reqForm.password = await RsaEncrypt(reqForm.password);
if (!state.form.sshTunnelMachineId) {
reqForm.sshTunnelMachineId = -1;
}
if (Object.keys(state.extra).length > 0) {
reqForm.extra = JSON.stringify(state.extra);
}
return reqForm;
};
const testConn = async () => {
const testConn = async (authCert: any) => {
dbForm.value.validate(async (valid: boolean) => {
if (!valid) {
ElMessage.error('请正确填写信息');
@@ -232,20 +274,13 @@ const testConn = async () => {
}
state.submitForm = await getReqForm();
state.submitForm.authCerts = [authCert];
await testConnExec();
ElMessage.success('连接成功');
});
};
const btnOk = async () => {
if (state.form.type !== DbType.sqlite) {
if (!state.form.id) {
notBlank(state.form.password, '新增操作,密码不可为空');
} else if (state.form.username != state.oldUserName) {
notBlank(state.form.password, '已修改用户名,请输入密码');
}
}
dbForm.value.validate(async (valid: boolean) => {
if (!valid) {
ElMessage.error('请正确填写信息');
@@ -263,6 +298,7 @@ const btnOk = async () => {
const cancel = () => {
emit('update:visible', false);
emit('cancel');
state.extra = {};
};
</script>
<style lang="scss"></style>

View File

@@ -3,6 +3,7 @@
<page-table
ref="pageTableRef"
:page-api="dbApi.instances"
:data-handler-fn="handleData"
:searchItems="searchItems"
v-model:query-form="query"
:show-selection="true"
@@ -16,6 +17,10 @@
>
</template>
<template #authCert="{ data }">
<ResourceAuthCert v-model:select-auth-cert="data.selectAuthCert" :auth-certs="data.authCerts" />
</template>
<template #type="{ data }">
<el-tooltip :content="getDbDialect(data.type).getInfo().name" placement="top">
<SvgIcon :name="getDbDialect(data.type).getInfo().icon" :size="20" />
@@ -35,7 +40,6 @@
<el-descriptions-item :span="2" label="主机">{{ infoDialog.data.host }}</el-descriptions-item>
<el-descriptions-item :span="1" label="端口">{{ infoDialog.data.port }}</el-descriptions-item>
<el-descriptions-item :span="2" label="用户名">{{ infoDialog.data.username }}</el-descriptions-item>
<el-descriptions-item :span="1" label="类型">{{ infoDialog.data.type }}</el-descriptions-item>
<el-descriptions-item :span="3" label="连接参数">{{ infoDialog.data.params }}</el-descriptions-item>
@@ -71,6 +75,7 @@ import { hasPerms } from '@/components/auth/auth';
import SvgIcon from '@/components/svgIcon/index.vue';
import { getDbDialect } from './dialect';
import { SearchItem } from '@/components/SearchForm';
import ResourceAuthCert from '../component/ResourceAuthCert.vue';
const InstanceEdit = defineAsyncComponent(() => import('./InstanceEdit.vue'));
@@ -79,13 +84,14 @@ const perms = {
delInstance: 'db:instance:del',
};
const searchItems = [SearchItem.input('name', '名称')];
const searchItems = [SearchItem.input('code', '编号'), SearchItem.input('name', '名称')];
const columns = ref([
TableColumn.new('code', '编号'),
TableColumn.new('name', '名称'),
TableColumn.new('type', '类型').isSlot().setAddWidth(-15).alignCenter(),
TableColumn.new('host', 'host:port').setFormatFunc((data: any) => `${data.host}:${data.port}`),
TableColumn.new('username', '用户名'),
TableColumn.new('authCerts[0].username', '授权凭证').isSlot('authCert').setAddWidth(10),
TableColumn.new('params', '连接参数'),
TableColumn.new('remark', '备注'),
]);
@@ -134,6 +140,15 @@ const 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) => {
state.infoDialog.data = info;
state.infoDialog.visible = true;

View File

@@ -337,7 +337,7 @@ const NodeTypeTableMenu = new NodeType(SqlExecNodeType.TableMenu)
])
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
let { id, db, type, flowProcdefKey } = params;
let { id, db, type, flowProcdefKey, schema } = params;
// 获取当前库的所有表信息
let tables = await DbInst.getInst(id).loadTables(db, state.reloadStatus);
state.reloadStatus = false;
@@ -352,6 +352,7 @@ const NodeTypeTableMenu = new NodeType(SqlExecNodeType.TableMenu)
id,
db,
type,
schema,
flowProcdefKey: flowProcdefKey,
key: key,
parentKey: parentNode.key,
@@ -366,7 +367,10 @@ const NodeTypeTableMenu = new NodeType(SqlExecNodeType.TableMenu)
parentNode.params.dbTableSize = dbTableSize == 0 ? '' : formatByteSize(dbTableSize);
return tablesNode;
})
.withNodeClickFunc(nodeClickChangeDb);
.withNodeDblclickFunc((node: TagTreeNode) => {
const params = node.params;
addTablesOpTab({ id: params.id, db: params.db, type: params.type, nodeKey: node.key });
});
// 数据库sql模板菜单节点
const NodeTypeSqlMenu = new NodeType(SqlExecNodeType.SqlMenu)
@@ -681,7 +685,7 @@ const onEditTable = async (data: any) => {
};
const onDeleteTable = async (data: any) => {
let { db, id, tableName, parentKey, flowProcdefKey } = data.params;
let { db, id, tableName, parentKey, flowProcdefKey, schema } = data.params;
await ElMessageBox.confirm(`此操作是永久性且无法撤销,确定删除【${tableName}】? `, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
@@ -689,7 +693,10 @@ const onDeleteTable = async (data: any) => {
});
// 执行sql
dbApi.sqlExec.request({ id, db, sql: `drop table ${getDbDialect(state.nowDbInst.type).quoteIdentifier(tableName)}` }).then(() => {
let dialect = getDbDialect(state.nowDbInst.type);
let schemaStr = schema ? `${dialect.quoteIdentifier(schema)}.` : '';
dbApi.sqlExec.request({ id, db, sql: `drop table ${schemaStr + dialect.quoteIdentifier(tableName)}` }).then(() => {
if (flowProcdefKey) {
ElMessage.success('工单提交成功');
return;

View File

@@ -115,7 +115,7 @@
<el-option
v-for="item in state.targetColumnList"
:key="item.columnName"
:label="item.columnName + ` ${item.columnType}` + (item.columnComment && ' - ' + item.columnComment)"
:label="item.columnName + ` ${item.showDataType}` + (item.columnComment && ' - ' + item.columnComment)"
:value="item.columnName"
/>
</el-select>
@@ -301,7 +301,9 @@ watch(dialogVisible, async (newValue: boolean) => {
state.tabActiveName = 'basic';
const propsData = props.data as any;
if (!propsData?.id) {
state.form = basicFormData;
let d = {} as FormData;
Object.assign(d, basicFormData);
state.form = d;
return;
}

View File

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

View File

@@ -43,7 +43,6 @@ export const dbApi = {
getInstanceServerInfo: Api.newGet('/instances/{instanceId}/server-info'),
testConn: Api.newPost('/instances/test-conn'),
saveInstance: Api.newPost('/instances'),
getInstancePwd: Api.newGet('/instances/{id}/pwd'),
deleteInstance: Api.newDelete('/instances/{id}'),
// 获取数据库备份列表
@@ -83,6 +82,14 @@ export const dbApi = {
runDatasyncTask: Api.newPost('/datasync/tasks/{taskId}/run'),
stopDatasyncTask: Api.newPost('/datasync/tasks/{taskId}/stop'),
datasyncLogs: Api.newGet('/datasync/tasks/{taskId}/logs'),
// 数据库迁移相关
dbTransferTasks: Api.newGet('/dbTransfer'),
saveDbTransferTask: Api.newPost('/dbTransfer/save'),
deleteDbTransferTask: Api.newDelete('/dbTransfer/{taskId}/del'),
runDbTransferTask: Api.newPost('/dbTransfer/{taskId}/run'),
stopDbTransferTask: Api.newPost('/dbTransfer/{taskId}/stop'),
dbTransferTaskLogs: Api.newGet('/dbTransfer/{taskId}/logs'),
};
export const dbSqlExecApi = {

View File

@@ -2,7 +2,13 @@
<div>
<el-dialog title="待执行SQL" v-model="dialogVisible" :show-close="false" width="600px">
<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.flowProcdefKey ? '执行备注(必填)' : '执行备注(选填)'"
class="mt5"
/>
<div v-if="props.flowProcdefKey">
<el-divider content-position="left">审批节点</el-divider>
@@ -52,7 +58,8 @@ onMounted(() => {
* 执行sql
*/
const runSql = async () => {
if (!state.remark) {
// 存在流程审批,则备注为必填
if (!state.remark && props.flowProcdefKey) {
ElMessage.error('请输入执行的备注信息');
return;
}

View File

@@ -461,7 +461,7 @@ const formatDataValues = (datas: any) => {
for (let data of datas) {
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]);
}
}
};
@@ -479,9 +479,9 @@ const setTableColumns = (columns: any) => {
state.columns = columns.map((x: any) => {
const columnName = x.columnName;
// 数据类型
x.dataType = dbDialect.getDataType(x.columnType);
x.dataType = dbDialect.getDataType(x.dataType);
x.dataTypeSubscript = ColumnTypeSubscript[x.dataType];
x.remark = `${x.columnType} ${x.columnComment ? ' | ' + x.columnComment : ''}`;
x.remark = `${x.showDataType} ${x.columnComment ? ' | ' + x.columnComment : ''}`;
return {
...x,

View File

@@ -9,15 +9,15 @@
:required="column.nullable != 'YES' && !column.isPrimaryKey && !column.isIdentity"
>
<template #label>
<span class="pointer" :title="`${column.columnType} | ${column.columnComment}`">
<span class="pointer" :title="`${column.showDataType} | ${column.columnComment}`">
{{ column.columnName }}
</span>
</template>
<ColumnFormItem
v-model="modelValue[`${column.columnName}`]"
:data-type="dbInst.getDialect().getDataType(column.columnType)"
:placeholder="`${column.columnType} ${column.columnComment}`"
:data-type="dbInst.getDialect().getDataType(column.dataType)"
:placeholder="`${column.showDataType} ${column.columnComment}`"
:column-name="column.columnName"
:disabled="column.isIdentity"
/>

View File

@@ -98,7 +98,7 @@
<el-divider direction="vertical" />
<span style="color: var(--el-color-info-light-3)">
{{ item.columnType }}
{{ item.showDataType }}
<template v-if="item.columnComment">
<el-divider direction="vertical" />
@@ -205,8 +205,8 @@
</el-col>
</el-row>
<el-dialog v-model="conditionDialog.visible" :title="conditionDialog.title" width="420px">
<el-row>
<el-dialog v-model="conditionDialog.visible" :title="conditionDialog.title" width="460px">
<el-row gutter="5">
<el-col :span="5">
<el-select v-model="conditionDialog.condition">
<el-option label="=" value="="> </el-option>
@@ -463,7 +463,7 @@ const handlerColumnSelect = (column: any) => {
// 默认拼接上 columnName =
let value = column.columnName + ' = ';
// 不是数字类型默认拼接上''
if (!DbInst.isNumber(column.columnType)) {
if (!DbInst.isNumber(column.dataType)) {
value = `${value} ''`;
}
@@ -507,7 +507,7 @@ const filterCondColumns = computed(() => {
const onConditionRowClick = (event: any) => {
const row = event[0];
state.conditionDialog.title = `请输入 [${row.columnName}] 的值`;
state.conditionDialog.placeholder = `${row.columnType} ${row.columnComment}`;
state.conditionDialog.placeholder = `${row.showDataType} ${row.columnComment}`;
state.conditionDialog.columnRow = row;
state.conditionDialog.visible = true;
setTimeout(() => {
@@ -524,7 +524,7 @@ const onConfirmCondition = () => {
}
const row = conditionDialog.columnRow as any;
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();
condInputRef.value.focus();
};

View File

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

View File

@@ -7,9 +7,9 @@
</template>
<el-form-item label="导出内容: ">
<el-radio-group v-model="dumpInfo.type">
<el-radio :label="1" size="small">结构</el-radio>
<el-radio :label="2" size="small">数据</el-radio>
<el-radio :label="3" size="small">结构数据</el-radio>
<el-radio :value="1" size="small">结构</el-radio>
<el-radio :value="2" size="small">数据</el-radio>
<el-radio :value="3" size="small">结构数据</el-radio>
</el-radio-group>
</el-form-item>
@@ -82,7 +82,7 @@
<el-dialog width="40%" :title="`${chooseTableName} 字段信息`" v-model="columnDialog.visible">
<el-table border stripe :data="columnDialog.columns" size="small">
<el-table-column prop="columnName" label="名称" show-overflow-tooltip> </el-table-column>
<el-table-column width="120" prop="columnType" label="类型" show-overflow-tooltip> </el-table-column>
<el-table-column width="120" prop="showDataType" label="类型" show-overflow-tooltip> </el-table-column>
<el-table-column width="80" prop="nullable" label="是否可为空" show-overflow-tooltip> </el-table-column>
<el-table-column prop="columnComment" label="备注" show-overflow-tooltip> </el-table-column>
</el-table>
@@ -98,8 +98,8 @@
</el-table>
</el-dialog>
<el-dialog width="55%" :title="`${chooseTableName} Create-DDL`" v-model="ddlDialog.visible">
<el-input disabled type="textarea" :autosize="{ minRows: 15, maxRows: 30 }" v-model="ddlDialog.ddl" size="small"> </el-input>
<el-dialog width="55%" :title="`'${chooseTableName}' DDL`" v-model="ddlDialog.visible">
<monaco-editor height="400px" language="sql" v-model="ddlDialog.ddl" :options="{ readOnly: true }" />
</el-dialog>
<db-table-op
@@ -126,7 +126,10 @@ import SqlExecBox from '../sqleditor/SqlExecBox';
import config from '@/common/config';
import { joinClientParams } from '@/common/request';
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'));
@@ -275,11 +278,13 @@ const dump = (db: string) => {
const showColumns = async (row: any) => {
state.chooseTableName = row.tableName;
state.columnDialog.columns = await dbApi.columnMetadata.request({
const columns = await dbApi.columnMetadata.request({
id: props.dbId,
db: props.db,
tableName: row.tableName,
});
DbInst.initColumns(columns);
state.columnDialog.columns = columns;
state.columnDialog.visible = true;
};
@@ -302,7 +307,8 @@ const showCreateDdl = async (row: any) => {
db: props.db,
tableName: row.tableName,
});
state.ddlDialog.ddl = res;
state.ddlDialog.ddl = sqlFormatter(res, { language: getDbDialect(props.dbType).getInfo().formatSqlDialect });
state.ddlDialog.visible = true;
};

View File

@@ -175,6 +175,9 @@ export class DbInst {
db: dbName,
tableName: table,
});
DbInst.initColumns(columns);
db.columnsMap.set(table, columns);
return columns;
}
@@ -317,7 +320,7 @@ export class DbInst {
let sql = `UPDATE ${schema}${this.wrapName(table)} SET `;
// 主键列信息
const primaryKey = await this.loadTableColumn(dbName, table);
let primaryKeyType = primaryKey.columnType;
let primaryKeyType = primaryKey.dataType;
let primaryKeyName = primaryKey.columnName;
let primaryKeyValue = rowData[primaryKeyName];
const dialect = this.getDialect();
@@ -325,7 +328,7 @@ export class DbInst {
const v = columnValue[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);
@@ -341,7 +344,7 @@ export class DbInst {
async genDeleteByPrimaryKeysSql(db: string, table: string, datas: any[]) {
const primaryKey = await this.loadTableColumn(db, table);
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})`;
}
@@ -426,7 +429,7 @@ export class DbInst {
* @returns
*/
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 +469,35 @@ export class DbInst {
const flexWidth: number = contentWidth > columnWidth ? contentWidth : columnWidth;
return flexWidth > 500 ? 500 : flexWidth;
};
// 初始化所有列信息完善需要显示的列类型包含长度等如varchar(20)
static initColumns(columns: any[]) {
if (!columns) {
return;
}
for (let col of columns) {
if (col.charMaxLength > 0) {
col.showDataType = `${col.dataType}(${col.charMaxLength})`;
col.showLength = col.charMaxLength;
col.showScale = null;
continue;
}
if (col.numPrecision > 0) {
if (col.numScale > 0) {
col.showDataType = `${col.dataType}(${col.numPrecision},${col.numScale})`;
col.showScale = col.numScale;
} else {
col.showDataType = `${col.dataType}(${col.numPrecision})`;
col.showScale = null;
}
col.showLength = col.numPrecision;
continue;
}
col.showDataType = col.dataType;
}
}
}
/**

View File

@@ -7,6 +7,7 @@ import {
DuplicateStrategy,
EditorCompletion,
EditorCompletionItem,
QuoteEscape,
IndexDefinition,
RowDefinition,
sqlColumnType,
@@ -35,7 +36,6 @@ const DM_TYPE_LIST: sqlColumnType[] = [
// 位串数据类型 BIT 用于存储整数数据 1、0 或 NULL只有 0 才转换为假,其他非空、非 0 值都会自动转换为真
{ udtName: 'BIT', dataType: 'BIT', desc: '用于存储整数数据 1、0 或 NULL', space: '1', range: '1' },
// 一般日期时间数据类型 DATE TIME TIMESTAMP 默认精度 6
// 多媒体数据类型 TEXT/LONG/LONGVARCHAR 类型:变长字符串类型 IMAGE/LONGVARBINARY 类型 BLOB CLOB BFILE 100G-1
{ udtName: 'DATE', dataType: 'DATE', desc: '年、月、日', space: '', range: '' },
{ udtName: 'TIME', dataType: 'TIME', desc: '时、分、秒', space: '', range: '' },
{
@@ -45,6 +45,7 @@ const DM_TYPE_LIST: sqlColumnType[] = [
space: '',
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: 'LONG', dataType: 'LONG', 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) {
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) {
tableCommentSql = ` comment on table "${data.tableName}" is '${data.tableComment}'; `;
tableCommentSql = ` comment on table "${data.tableName}" is '${QuoteEscape(data.tableComment)}'; `;
}
return createSql + tableCommentSql + columCommentSql;
@@ -569,7 +570,7 @@ class DMDialect implements DbDialect {
changeData.add.forEach((a) => {
modifySql += `ALTER TABLE ${dbTable} add COLUMN ${this.genColumnBasicSql(a)};`;
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) {
priArr.add(`"${a.name}"`);
@@ -579,7 +580,7 @@ class DMDialect implements DbDialect {
if (changeData.upd.length > 0) {
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) {
commentSql += cmtSql;
}
@@ -675,7 +676,7 @@ class DMDialect implements DbDialect {
}
if (tableData.oldTableComment !== tableData.tableComment) {
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;
}

View File

@@ -262,6 +262,18 @@ export const getDbDialect = (dbType?: string): DbDialect => {
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 () {
console.log('init register db dialect');
registerDbDialect(DbType.mysql, mysqlDialect);

View File

@@ -7,6 +7,7 @@ import {
DuplicateStrategy,
EditorCompletion,
EditorCompletionItem,
QuoteEscape,
IndexDefinition,
RowDefinition,
} from './index';
@@ -225,7 +226,7 @@ class MssqlDialect implements DbDialect {
item.name && fields.push(this.genColumnBasicSql(item));
item.remark &&
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) {
pks.push(`${this.quoteIdentifier(item.name)}`);
@@ -244,7 +245,7 @@ class MssqlDialect implements DbDialect {
// 表注释
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(';');
@@ -268,7 +269,7 @@ class MssqlDialect implements DbDialect {
sql.push(` CREATE ${a.unique ? 'UNIQUE' : ''} NONCLUSTERED INDEX ${this.quoteIdentifier(a.indexName)} on ${baseTable} (${columnNames.join(',')})`);
if (a.indexComment) {
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) {
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) {
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) {
changeData.upd.forEach((a) => {
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 {
updArr.push(` ALTER TABLE ${baseTable} ALTER COLUMN ${this.genColumnBasicSql(a)} `);
}
@@ -325,13 +326,13 @@ class MssqlDialect implements DbDialect {
'TABLE', N'${tableName}',
'COLUMN', N'${a.name}')) > 0)
EXEC sp_updateextendedproperty
'MS_Description', N'${a.remark}',
'MS_Description', N'${QuoteEscape(a.remark)}',
'SCHEMA', N'${schema}',
'TABLE', N'${tableName}',
'COLUMN', N'${a.name}'
ELSE
EXEC sp_addextendedproperty
'MS_Description', N'${a.remark}',
'MS_Description', N'${QuoteEscape(a.remark)}',
'SCHEMA', N'${schema}',
'TABLE', N'${tableName}',
'COLUMN',N'${a.name}'`);
@@ -367,7 +368,7 @@ ELSE
);
if (a.indexComment) {
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) {
// 转义注释中的单引号和换行符
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',
'SCHEMA', N'${schema}',
'TABLE', N'${tableData.tableName}', NULL, NULL)) > 0)

View File

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

View File

@@ -7,6 +7,7 @@ import {
DuplicateStrategy,
EditorCompletion,
EditorCompletionItem,
QuoteEscape,
IndexDefinition,
RowDefinition,
sqlColumnType,
@@ -296,7 +297,12 @@ class OracleDialect implements DbDialect {
let length = this.getTypeLengthSql(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 baseSql = ` ${this.quoteIdentifier(name)} ${cl.type}${length} ${incr}`;
@@ -319,7 +325,7 @@ class OracleDialect implements DbDialect {
item.name && fields.push(this.genColumnBasicSql(item, true));
// 列注释
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) {
@@ -329,13 +335,13 @@ class OracleDialect implements DbDialect {
// 主键语句
let prisql = '';
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) {
tableCommentSql = ` COMMENT ON TABLE ${dbTable} is '${data.tableComment}'; `;
tableCommentSql = ` COMMENT ON TABLE ${dbTable} is '${QuoteEscape(data.tableComment)}'; `;
}
return createSql + tableCommentSql + columCommentSql;
@@ -374,7 +380,7 @@ class OracleDialect implements DbDialect {
if (changeData.upd.length > 0) {
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) {
commentArr.push(commentSql);
}
@@ -396,7 +402,7 @@ class OracleDialect implements DbDialect {
changeData.add.forEach((a) => {
modifyArr.push(` ADD (${this.genColumnBasicSql(a, false)})`);
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) {
priArr.add(`"${a.name}"`);
@@ -481,7 +487,7 @@ class OracleDialect implements DbDialect {
let sql = '';
if (tableData.tableComment != tableData.oldTableComment) {
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) {
let dbTable = `${this.quoteIdentifier(schema)}.${this.quoteIdentifier(tableData.oldTableName)}`;

View File

@@ -7,6 +7,7 @@ import {
DuplicateStrategy,
EditorCompletion,
EditorCompletionItem,
QuoteEscape,
IndexDefinition,
RowDefinition,
sqlColumnType,
@@ -283,7 +284,7 @@ class PostgresqlDialect implements DbDialect {
}
// 列注释
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) {
tableCommentSql = ` comment on table ${data.tableName} is '${data.tableComment}'; `;
tableCommentSql = ` comment on table ${data.tableName} is '${QuoteEscape(data.tableComment)}'; `;
}
return createSql + tableCommentSql + columCommentSql;
@@ -312,7 +313,7 @@ class PostgresqlDialect implements DbDialect {
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(',')})`);
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(';');
@@ -334,14 +335,14 @@ class PostgresqlDialect implements DbDialect {
changeData.add.forEach((a) => {
modifySql += `alter table ${dbTable} add ${this.genColumnBasicSql(a)};`;
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) {
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) {
commentSql += cmtSql;
}
@@ -412,7 +413,7 @@ class PostgresqlDialect implements DbDialect {
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(',')})`);
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 = '';
if (tableData.tableComment != tableData.oldTableComment) {
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) {
let dbTable = `${this.quoteIdentifier(schema)}.${this.quoteIdentifier(tableData.oldTableName)}`;

View File

@@ -30,3 +30,10 @@ export const DbDataSyncRunningStateEnum = {
Wait: EnumValue.of(2, '待运行').setTagType('primary'),
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,93 @@
<template>
<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-tabs v-model="tabActiveName">
<el-tab-pane label="基础信息" name="basic">
<el-form-item ref="tagSelectRef" prop="tagId" label="标签">
<tag-tree-select
multiple
@change-tag="
(tagIds) => {
form.tagId = tagIds;
tagSelectRef.validate();
}
"
:tag-path="form.tagPath"
:select-tags="form.tagId"
style="width: 100%"
/>
</el-form-item>
<el-form-item prop="name" label="名称" required>
<el-input v-model.trim="form.name" placeholder="请输入机器别名" auto-complete="off"></el-input>
</el-form-item>
<el-form-item prop="ip" label="ip" required>
<el-col :span="18">
<el-input :disabled="form.id" 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-divider content-position="left">基本</el-divider>
<el-form-item ref="tagSelectRef" prop="tagId" label="标签">
<tag-tree-select
multiple
@change-tag="
(tagIds) => {
form.tagId = tagIds;
tagSelectRef.validate();
}
"
:tag-path="form.tagPath"
:select-tags="form.tagId"
style="width: 100%"
/>
</el-form-item>
<el-form-item prop="code" label="编号" required>
<el-input :disabled="form.id" v-model.trim="form.code" placeholder="请输入编号 (数字字母下划线), 不可修改" auto-complete="off"></el-input>
</el-form-item>
<el-form-item prop="name" label="名称" required>
<el-input v-model.trim="form.name" placeholder="请输入机器别名" auto-complete="off"></el-input>
</el-form-item>
<el-form-item prop="protocol" label="协议" required>
<el-radio-group v-model="form.protocol" @change="handleChangeProtocol">
<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-input v-model.trim="form.username" placeholder="请输授权用户名" autocomplete="new-password"> </el-input>
</el-form-item>
<el-form-item prop="remark" label="备注">
<el-input type="textarea" v-model="form.remark"></el-input>
</el-form-item>
<el-form-item label="认证方式" required>
<el-select @change="changeAuthMethod" style="width: 100%" v-model="state.authType" 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="state.authType == 1" prop="password" label="密码">
<el-input type="password" show-password v-model.trim="form.password" placeholder="请输入密码" autocomplete="new-password">
</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.Machine.value"
:test-conn-btn-loading="testConnBtnLoading"
@test-conn="testConn"
/>
</div>
<el-form-item v-if="state.authType == 2" prop="authCertId" label="授权凭证" required>
<auth-cert-select ref="authCertSelectRef" v-model="form.authCertId" />
</el-form-item>
<el-divider content-position="left">其他</el-divider>
<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="remark" label="备注">
<el-input type="textarea" v-model="form.remark"></el-input>
</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-item prop="sshTunnelMachineId" label="SSH隧道">
<ssh-tunnel-select v-model="form.sshTunnelMachineId" />
</el-form-item>
</el-form>
<template #footer>
<div>
<el-button @click="testConn" :loading="testConnBtnLoading" type="success">测试连接</el-button>
<el-button @click="cancel()">取 消</el-button>
<el-button type="primary" :loading="saveBtnLoading" @click="btnOk">确 定</el-button>
</div>
</template>
</el-dialog>
</el-drawer>
</div>
</template>
<script lang="ts" setup>
import { toRefs, reactive, watch, ref } from 'vue';
import { reactive, ref, toRefs, watch } from 'vue';
import { machineApi } from './api';
import { ElMessage } from 'element-plus';
import TagTreeSelect from '../component/TagTreeSelect.vue';
import ResourceAuthCertTableEdit from '../component/ResourceAuthCertTableEdit.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({
visible: {
@@ -109,6 +112,18 @@ const rules = {
trigger: ['change'],
},
],
code: [
{
required: true,
message: '请输入编码',
trigger: ['change', 'blur'],
},
{
pattern: ResourceCodePattern.pattern,
message: ResourceCodePattern.message,
trigger: ['blur'],
},
],
name: [
{
required: true,
@@ -116,59 +131,49 @@ const rules = {
trigger: ['change', 'blur'],
},
],
protocol: [
{
required: true,
message: '请选择机器类型',
trigger: ['change', 'blur'],
},
],
ip: [
{
required: true,
message: '请输入主机ip和端口',
trigger: ['change', 'blur'],
},
],
authCertId: [
{
required: true,
message: '请选择授权凭证',
trigger: ['change', 'blur'],
},
],
username: [
{
required: true,
message: '请输入授权用户名',
trigger: ['change', 'blur'],
trigger: ['blur'],
},
],
};
const machineForm: any = ref(null);
const authCertSelectRef: 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: [],
tagId: [],
remark: '',
sshTunnelMachineId: null as any,
enableRecorder: -1,
};
const state = reactive({
dialogVisible: false,
tabActiveName: 'basic',
sshTunnelMachineList: [] as any,
authCerts: [] as any,
authType: 1,
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: {},
form: defaultForm,
submitForm: {} as any,
pwd: '',
});
const { dialogVisible, tabActiveName, form, submitForm } = toRefs(state);
const { dialogVisible, form, submitForm } = toRefs(state);
const { isFetching: testConnBtnLoading, execute: testConnExec } = machineApi.testConn.useApi(submitForm);
const { isFetching: saveBtnLoading, execute: saveMachineExec } = machineApi.saveMachine.useApi(submitForm);
@@ -176,36 +181,17 @@ const { isFetching: saveBtnLoading, execute: saveMachineExec } = machineApi.save
watch(props, async (newValue: any) => {
state.dialogVisible = newValue.visible;
if (!state.dialogVisible) {
state.form = defaultForm;
return;
}
state.tabActiveName = 'basic';
if (newValue.machine) {
state.form = { ...newValue.machine };
state.form.tagId = newValue.machine.tags.map((t: any) => t.tagId);
// 如果凭证类型为公共的,则表示使用授权凭证认证
const authCertId = (state.form as any).authCertId;
if (authCertId > 0) {
state.authType = 2;
} else {
state.authType = 1;
}
} else {
state.form = { port: 22, tagId: [] } as any;
state.authType = 1;
state.form.authCerts = newValue.machine.authCerts || [];
}
});
const changeAuthMethod = (val: any) => {
if (state.form.id) {
if (val == 2) {
state.form.authCertId = null;
} else {
state.form.password = '';
}
}
};
const testConn = async () => {
const testConn = async (authCert: any) => {
machineForm.value.validate(async (valid: boolean) => {
if (!valid) {
ElMessage.error('请正确填写信息');
@@ -213,6 +199,7 @@ const testConn = async () => {
}
state.submitForm = getReqForm();
state.submitForm.authCerts = [authCert];
await testConnExec();
ElMessage.success('连接成功');
});
@@ -225,6 +212,11 @@ const btnOk = async () => {
return false;
}
if (state.form.authCerts.length == 0) {
ElMessage.error('请完善授权凭证账号信息');
return false;
}
state.submitForm = getReqForm();
await saveMachineExec();
ElMessage.success('保存成功');
@@ -235,16 +227,22 @@ const btnOk = async () => {
const getReqForm = () => {
const reqForm: any = { ...state.form };
// 如果为密码认证则置空授权凭证id
if (state.authType == 1) {
reqForm.authCertId = -1;
}
if (!state.form.sshTunnelMachineId || state.form.sshTunnelMachineId <= 0) {
reqForm.sshTunnelMachineId = -1;
}
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 = () => {
emit('update:visible', false);
emit('cancel');

View File

@@ -4,11 +4,13 @@
ref="pageTableRef"
:page-api="machineApi.list"
:before-query-fn="checkRouteTagPath"
:data-handler-fn="handleData"
:search-items="searchItems"
v-model:query-form="params"
:show-selection="true"
v-model:selection-data="state.selectionData"
:columns="columns"
:lazy="true"
>
<template #tableHeader>
<el-button v-auth="perms.addMachine" type="primary" icon="plus" @click="openFormDialog(false)" plain>添加 </el-button>
@@ -83,12 +85,19 @@
<ResourceTags :tags="data.tags" />
</template>
<template #authCert="{ data }">
<ResourceAuthCert v-model:select-auth-cert="data.selectAuthCert" :auth-certs="data.authCerts" />
</template>
<template #action="{ data }">
<span v-auth="'machine:terminal'">
<el-tooltip :show-after="500" content="按住ctrl则为新标签打开" placement="top">
<el-button :disabled="data.status == -1" type="primary" @click="showTerminal(data, $event)" link>终端</el-button>
<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>SSH</el-button>
</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 == 3" type="primary" @click="showRDP(data)" link>VNC</el-button>
<el-divider direction="vertical" border-style="dashed" />
</span>
@@ -97,7 +106,9 @@
<el-divider direction="vertical" border-style="dashed" />
</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-dropdown @command="handleCommand">
@@ -111,9 +122,19 @@
<el-dropdown-menu>
<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: '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">
终端回放
@@ -134,11 +155,6 @@
<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="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="1.5" label="SSH隧道">{{ infoDialog.data.sshTunnelMachineId > 0 ? '是' : '否' }} </el-descriptions-item>
@@ -156,7 +172,7 @@
<template #headerTitle="{ terminalInfo }">
{{ `${(terminalInfo.terminalId + '').slice(-2)}` }}
<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" />
{{ terminalInfo.meta.name }}
</template>
@@ -171,21 +187,50 @@
<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-rec v-model:visible="machineRecDialog.visible" :machineId="machineRecDialog.machineId" :title="machineRecDialog.title"></machine-rec>
<MachineRdpDialog :title="machineRdpDialog.title" v-model:visible="machineRdpDialog.visible" v-model:machineId="machineRdpDialog.machineId">
<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>
</MachineRdpDialog>
<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>
</template>
<script lang="ts" setup>
import { ref, toRefs, reactive, onMounted, defineAsyncComponent, Ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { defineAsyncComponent, onMounted, reactive, ref, Ref, toRefs } from 'vue';
import { useRoute, useRouter } from 'vue-router';
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 PageTable from '@/components/pagetable/PageTable.vue';
@@ -195,9 +240,13 @@ import { formatByteSize } from '@/common/utils/format';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { SearchItem } from '@/components/SearchForm';
import { getTagPathSearchItem } from '../component/tag';
import MachineFile from '@/views/ops/machine/file/MachineFile.vue';
import ResourceAuthCert from '../component/ResourceAuthCert.vue';
import { MachineProtocolEnum } from './enums';
// 组件
const TerminalDialog = defineAsyncComponent(() => import('@/components/terminal/TerminalDialog.vue'));
const MachineRdpDialog = defineAsyncComponent(() => import('@/components/terminal-rdp/MachineRdpDialog.vue'));
const MachineEdit = defineAsyncComponent(() => import('./MachineEdit.vue'));
const ScriptManage = defineAsyncComponent(() => import('./ScriptManage.vue'));
const FileConfList = defineAsyncComponent(() => import('./file/FileConfList.vue'));
@@ -205,6 +254,13 @@ const MachineStats = defineAsyncComponent(() => import('./MachineStats.vue'));
const MachineRec = defineAsyncComponent(() => import('./MachineRec.vue'));
const ProcessList = defineAsyncComponent(() => import('./ProcessList.vue'));
const props = defineProps({
lazy: {
type: [Boolean],
default: false,
},
});
const router = useRouter();
const route = useRoute();
const terminalDialogRef: any = ref(null);
@@ -217,15 +273,21 @@ const perms = {
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 = [
TableColumn.new('tags[0].tagPath', '关联标签').isSlot('tagPath').setAddWidth(20),
TableColumn.new('code', '编号'),
TableColumn.new('name', '名称'),
TableColumn.new('ipPort', 'ip:port').isSlot().setAddWidth(50),
TableColumn.new('stat', '运行状态').isSlot().setAddWidth(55),
TableColumn.new('fs', '磁盘(挂载点=>可用/总)').isSlot().setAddWidth(25),
TableColumn.new('username', '用户名'),
TableColumn.new('authCerts[0].username', '授权凭证').isSlot('authCert').setAddWidth(10),
TableColumn.new('status', '状态').isSlot().setMinWidth(85),
TableColumn.new('remark', '备注'),
TableColumn.new('action', '操作').isSlot().setMinWidth(238).fixedRight().alignCenter(),
@@ -251,6 +313,7 @@ const state = reactive({
serviceDialog: {
visible: false,
machineId: 0,
authCertName: '',
title: '',
},
processDialog: {
@@ -260,8 +323,18 @@ const state = reactive({
fileDialog: {
visible: false,
machineId: 0,
authCertName: '',
title: '',
},
filesystemDialog: {
visible: false,
machineId: 0,
protocol: 1,
title: '',
fileId: 0,
authCertName: '',
path: '',
},
machineStatsDialog: {
visible: false,
stats: null,
@@ -278,11 +351,32 @@ const state = reactive({
machineId: 0,
title: '',
},
machineRdpDialog: {
visible: false,
machineId: 0,
title: '',
},
});
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) => {
if (route.query.tagPath) {
@@ -291,6 +385,15 @@ const checkRouteTagPath = (query: any) => {
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 data = commond.data;
const type = commond.type;
@@ -311,16 +414,25 @@ const handleCommand = (commond: any) => {
showRec(data);
return;
}
case 'rdp': {
showRDP(data);
return;
}
case 'rdp-blank': {
showRDP(data, true);
return;
}
}
};
const showTerminal = (row: any, event: PointerEvent) => {
const ac = row.selectAuthCert.name;
// 按住ctrl点击则新建标签页打开, metaKey对应mac command键
if (event.ctrlKey || event.metaKey) {
const { href } = router.resolve({
path: `/machine/terminal`,
query: {
id: row.id,
ac,
name: row.name,
},
});
@@ -331,9 +443,9 @@ const showTerminal = (row: any, event: PointerEvent) => {
const terminalId = Date.now();
terminalDialogRef.value.open({
terminalId,
socketUrl: getMachineTerminalSocketUrl(row.id),
socketUrl: getMachineTerminalSocketUrl(ac),
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,
});
};
@@ -372,9 +484,11 @@ const deleteMachine = async () => {
};
const serviceManager = (row: any) => {
const authCert = row.selectAuthCert;
state.serviceDialog.machineId = row.id;
state.serviceDialog.authCertName = authCert.name;
state.serviceDialog.visible = true;
state.serviceDialog.title = `${row.name} => ${row.ip}`;
state.serviceDialog.title = `${row.name} => ${authCert.username}@${row.ip}`;
};
/**
@@ -396,7 +510,10 @@ const showMachineStats = async (machine: any) => {
state.machineStatsDialog.visible = true;
};
const search = async () => {
const search = async (tagPath: string = '') => {
if (tagPath) {
state.params.tagPath = tagPath;
}
pageTableRef.value.search();
};
@@ -404,10 +521,23 @@ const submitSuccess = () => {
search();
};
const showFileManage = (selectionData: any) => {
state.fileDialog.visible = true;
state.fileDialog.machineId = selectionData.id;
state.fileDialog.title = `${selectionData.name} => ${selectionData.ip}`;
const showFileManage = (data: any) => {
if (data.protocol === MachineProtocolEnum.Ssh.value) {
// ssh
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) => {
@@ -437,6 +567,25 @@ const showRec = (row: any) => {
state.machineRecDialog.machineId = row.id;
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.visible = true;
};
defineExpose({ search });
</script>
<style>

View File

@@ -6,17 +6,26 @@
<tag-tree
class="machine-terminal-tree"
ref="tagTreeRef"
:resource-type="TagResourceTypeEnum.Machine.value"
:resource-type="TagResourceTypeEnum.MachineAuthCert.value"
:tag-path-node-type="NodeTypeTagPath"
>
<template #prefix="{ data }">
<SvgIcon v-if="data.icon && data.params.status == 1" :name="data.icon.name" :color="data.icon.color" />
<SvgIcon v-if="data.icon && data.params.status == -1" :name="data.icon.name" color="var(--el-color-danger)" />
<SvgIcon
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 #suffix="{ data }">
<span style="color: #c4c9c4; font-size: 9px" v-if="data.type.value == MachineNodeType.Machine">{{
` ${data.params.username}@${data.params.ip}:${data.params.port}`
<span style="color: #c4c9c4; font-size: 9px" v-if="data.type.value == MachineNodeType.AuthCert">{{
` ${data.params.selectAuthCert.username}@${data.params.ip}:${data.params.port}`
}}</span>
</template>
</tag-tree>
@@ -35,7 +44,7 @@
>
<el-tab-pane class="h100" closable v-for="dt in state.tabs.values()" :label="dt.label" :name="dt.key" :key="dt.key">
<template #label>
<el-popconfirm @confirm="handleReconnect(dt.key)" title="确认重新连接?">
<el-popconfirm @confirm="handleReconnect(dt, true)" title="确认重新连接?">
<template #reference>
<el-icon class="mr5" :color="dt.status == 1 ? '#67c23a' : '#f56c6c'" :title="dt.status == 1 ? '' : '点击重连'"
><Connection />
@@ -59,13 +68,21 @@
</el-popover>
</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
v-if="dt.params.protocol == MachineProtocolEnum.Ssh.value"
:mount-init="false"
@status-change="terminalStatusChange(dt.key, $event)"
:ref="(el) => setTerminalRef(el, dt.key)"
:ref="(el: any) => setTerminalRef(el, dt.key)"
: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>
</el-tab-pane>
</el-tabs>
@@ -75,16 +92,11 @@
<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="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="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="1.5" label="SSH隧道">{{ infoDialog.data.sshTunnelMachineId > 0 ? '是' : '否' }} </el-descriptions-item>
@@ -100,9 +112,36 @@
<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" />
@@ -114,24 +153,29 @@
</template>
<script lang="ts" setup>
import { ref, toRefs, reactive, defineAsyncComponent, nextTick } from 'vue';
import { defineAsyncComponent, nextTick, reactive, ref, toRefs, watch } from 'vue';
import { useRouter } from 'vue-router';
import { machineApi, getMachineTerminalSocketUrl } from './api';
import { getMachineTerminalSocketUrl, machineApi } from './api';
import { dateFormat } from '@/common/utils/date';
import { hasPerms } from '@/components/auth/auth';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { NodeType, TagTreeNode } from '../component/tag';
import TagTree from '../component/TagTree.vue';
import { Splitpanes, Pane } from 'splitpanes';
import { Pane, Splitpanes } from 'splitpanes';
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';
// 组件
const ScriptManage = defineAsyncComponent(() => import('./ScriptManage.vue'));
const FileConfList = defineAsyncComponent(() => import('./file/FileConfList.vue'));
const MachineStats = defineAsyncComponent(() => import('./MachineStats.vue'));
const MachineRec = defineAsyncComponent(() => import('./MachineRec.vue'));
const ProcessList = defineAsyncComponent(() => import('./ProcessList.vue'));
import TerminalBody from '@/components/terminal/TerminalBody.vue';
import { TerminalStatus } from '@/components/terminal/common';
const router = useRouter();
@@ -148,6 +192,7 @@ const actionBtns = hasPerms([perms.updateMachine, perms.closeCli]);
class MachineNodeType {
static Machine = 1;
static AuthCert = 2;
}
const state = reactive({
@@ -165,6 +210,7 @@ const state = reactive({
serviceDialog: {
visible: false,
machineId: 0,
authCertName: '',
title: '',
},
processDialog: {
@@ -174,7 +220,18 @@ const state = reactive({
fileDialog: {
visible: false,
machineId: 0,
protocol: 1,
title: '',
authCertName: '',
},
filesystemDialog: {
visible: false,
machineId: 0,
authCertName: '',
protocol: 1,
title: '',
fileId: 0,
path: '',
},
machineStatsDialog: {
visible: false,
@@ -195,7 +252,9 @@ const { infoDialog, serviceDialog, processDialog, fileDialog, machineStatsDialog
const tagTreeRef: any = ref(null);
const NodeTypeTagPath = new NodeType(TagTreeNode.TagPath).withLoadNodesFunc(async (node: any) => {
let openIds = {};
const NodeTypeTagPath = new NodeType(TagTreeNode.TagPath).withLoadNodesFunc(async (node: TagTreeNode) => {
// 加载标签树下的机器列表
state.params.tagPath = node.key;
state.params.pageNum = 1;
@@ -204,87 +263,130 @@ const NodeTypeTagPath = new NodeType(TagTreeNode.TagPath).withLoadNodesFunc(asyn
// 把list 根据name字段排序
res.list = res.list.sort((a: any, b: any) => a.name.localeCompare(b.name));
return res.list.map((x: any) =>
new TagTreeNode(x.id, x.name, NodeTypeMachine(x))
new TagTreeNode(x.id, x.name, NodeTypeMachine)
.withParams(x)
.withDisabled(x.status == -1)
.withDisabled(x.status == -1 && x.protocol == MachineProtocolEnum.Ssh.value)
.withIcon({
name: 'Monitor',
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.id, 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) => {
let contextMenuItems = [];
contextMenuItems.push(new ContextmenuItem('term', '打开终端').withIcon('Monitor').withOnClick(() => openTerminal(machine)));
contextMenuItems.push(new ContextmenuItem('term-ex', '打开终端(新窗口)').withIcon('Monitor').withOnClick(() => openTerminal(machine, true)));
contextMenuItems.push(new ContextmenuItem('files', '文件管理').withIcon('FolderOpened').withOnClick(() => showFileManage(machine)));
contextMenuItems.push(new ContextmenuItem('scripts', '脚本管理').withIcon('Files').withOnClick(() => serviceManager(machine)));
contextMenuItems.push(new ContextmenuItem('detail', '详情').withIcon('More').withOnClick(() => showInfo(machine)));
contextMenuItems.push(new ContextmenuItem('status', '状态').withIcon('Compass').withOnClick(() => showMachineStats(machine)));
contextMenuItems.push(new ContextmenuItem('process', '进程').withIcon('DataLine').withOnClick(() => showProcess(machine)));
if (actionBtns[perms.updateMachine] && machine.enableRecorder == 1) {
contextMenuItems.push(new ContextmenuItem('edit', '终端回放').withIcon('Compass').withOnClick(() => showRec(machine)));
}
new ContextmenuItem('status', '状态')
.withIcon('Compass')
.withHideFunc((node: any) => node.params.protocol != MachineProtocolEnum.Ssh.value)
.withOnClick((node: any) => showMachineStats(node.params)),
return new NodeType(MachineNodeType.Machine).withContextMenuItems(contextMenuItems).withNodeDblclickFunc(() => {
// for (let k of state.tabs.keys()) {
// // 存在该机器相关的终端tab则直接激活该tab
// if (k.startsWith(`${machine.id}_${machine.username}_`)) {
// state.activeTermName = k;
// onTabChange();
// return;
// }
// }
new ContextmenuItem('process', '进程')
.withIcon('DataLine')
.withHideFunc((node: any) => node.params.protocol != MachineProtocolEnum.Ssh.value)
.withOnClick((node: any) => showProcess(node.params)),
openTerminal(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)),
]);
const openTerminal = (machine: any, ex?: boolean) => {
// 授权凭证名
const ac = machine.selectAuthCert.name;
// 新窗口打开
if (ex) {
const { href } = router.resolve({
path: `/machine/terminal`,
query: {
id: machine.id,
name: machine.name,
},
});
window.open(href, '_blank');
return;
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, id, username } = machine;
let { name, username } = machine;
const labelName = `${machine.selectAuthCert.username}@${name}`;
// 同一个机器的终端打开多次key后添加下划线和数字区分
openIds[id] = openIds[id] ? ++openIds[id] : 1;
let sameIndex = openIds[id];
openIds[ac] = openIds[ac] ? ++openIds[ac] : 1;
let sameIndex = openIds[ac];
let key = `${id}_${username}_${sameIndex}`;
// 只保留name的10个字,超出部分只保留前后4个字符,中间用省略号代替
let label = name.length > 10 ? name.slice(0, 4) + '...' + name.slice(-4) : name;
let key = `${ac}_${username}_${sameIndex}`;
// 只保留name的15个字,超出部分只保留前后10个字符,中间用省略号代替
const label = labelName.length > 15 ? labelName.slice(0, 10) + '...' + labelName.slice(-10) : labelName;
state.tabs.set(key, {
let tab = {
key,
label: `${label}${sameIndex === 1 ? '' : ':' + sameIndex}`, // label组成为:总打开term次数+name+同一个机器打开的次数
params: machine,
socketUrl: getMachineTerminalSocketUrl(id),
});
authCert: ac,
socketUrl: getMachineTerminalSocketUrl(ac),
};
state.tabs.set(key, tab);
state.activeTermName = key;
nextTick(() => {
handleReconnect(key);
handleReconnect(tab);
});
};
const serviceManager = (row: any) => {
const authCert = row.selectAuthCert;
state.serviceDialog.machineId = row.id;
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 +404,24 @@ const search = async () => {
};
const showFileManage = (selectionData: any) => {
state.fileDialog.visible = true;
state.fileDialog.machineId = selectionData.id;
state.fileDialog.title = `${selectionData.name} => ${selectionData.ip}`;
const authCert = selectionData.selectAuthCert;
if (selectionData.protocol == 1) {
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) => {
@@ -337,7 +454,6 @@ const onRemoveTab = (targetName: string) => {
} else {
activeTermName = '';
}
let info = state.tabs.get(targetName);
if (info) {
terminalRefs[info.key]?.close();
@@ -349,6 +465,15 @@ const onRemoveTab = (targetName: string) => {
}
};
watch(
() => state.activeTermName,
(newValue, oldValue) => {
console.log('oldValue', oldValue);
oldValue && terminalRefs[oldValue]?.blur && terminalRefs[oldValue]?.blur();
terminalRefs[newValue]?.focus && terminalRefs[newValue]?.focus();
}
);
const terminalStatusChange = (key: string, status: TerminalStatus) => {
state.tabs.get(key).status = status;
};
@@ -360,6 +485,13 @@ const setTerminalRef = (el: any, key: any) => {
}
};
const terminalWrapperRefs: any = {};
const setTerminalWrapperRef = (el: any, key: any) => {
if (key) {
terminalWrapperRefs[key] = el;
}
};
const onResizeTagTree = () => {
fitTerminal();
};
@@ -372,14 +504,16 @@ const fitTerminal = () => {
setTimeout(() => {
let info = state.tabs.get(state.activeTermName);
if (info) {
terminalRefs[info.key]?.fitTerminal();
terminalRefs[info.key]?.focus();
terminalRefs[info.key]?.fitTerminal && terminalRefs[info.key]?.fitTerminal();
terminalRefs[info.key]?.focus && terminalRefs[info.key]?.focus();
}
}, 100);
};
const handleReconnect = (key: string) => {
terminalRefs[key].init();
const handleReconnect = (tab: any, force = false) => {
let width = terminalWrapperRefs[tab.key].offsetWidth;
let height = terminalWrapperRefs[tab.key].offsetHeight;
terminalRefs[tab.key]?.init(width, height, force);
};
</script>

View File

@@ -127,6 +127,12 @@ const handleClose = () => {
</script>
<style lang="scss">
#terminalRecDialog {
overflow: hidden;
#rc-player {
overflow: hidden;
}
.el-overlay .el-overlay-dialog .el-dialog .el-dialog__body {
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
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>
<script-edit
@@ -100,6 +100,7 @@ import { SearchItem } from '@/components/SearchForm';
const props = defineProps({
visible: { type: Boolean },
machineId: { type: Number },
authCertName: { type: String },
title: { type: String },
});
@@ -143,7 +144,6 @@ const state = reactive({
terminalDialog: {
visible: false,
cmd: '',
machineId: 0,
},
});
@@ -195,6 +195,7 @@ const run = async (script: any) => {
if (script.type == ScriptResultEnum.Result.value || noResult) {
const res = await machineApi.runScript.request({
machineId: props.machineId,
ac: props.authCertName,
scriptId: script.id,
params: JSON.stringify(state.scriptParamsDialog.params),
});
@@ -215,7 +216,6 @@ const run = async (script: any) => {
}
state.terminalDialog.cmd = script;
state.terminalDialog.visible = true;
state.terminalDialog.machineId = props.machineId as any;
return;
}
};
@@ -236,7 +236,6 @@ function templateResolve(template: string, param: any) {
const closeTermnial = () => {
state.terminalDialog.visible = false;
state.terminalDialog.machineId = 0;
};
const editScript = (data: any) => {

View File

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

View File

@@ -20,7 +20,7 @@ export const machineApi = {
// 删除机器
del: Api.newDelete('/machines/{id}'),
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'),
deleteScript: Api.newDelete('/machines/{machineId}/scripts/{scriptId}'),
// 获取配置文件列表
@@ -42,20 +42,12 @@ export const machineApi = {
addConf: Api.newPost('/machines/{machineId}/files'),
// 删除配置的文件or目录
delConf: Api.newDelete('/machines/{machineId}/files/{id}'),
terminal: Api.newGet('/api/machines/{id}/terminal'),
// 机器终端操作记录列表
termOpRecs: Api.newGet('/machines/{machineId}/term-recs'),
// 机器终端操作记录详情
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 = {
list: Api.newGet('/machine-cronjobs'),
relateMachineIds: Api.newGet('/machine-cronjobs/machine-ids'),
@@ -66,6 +58,10 @@ export const cronJobApi = {
execList: Api.newGet('/machine-cronjobs/execs'),
};
export function getMachineTerminalSocketUrl(machineId: any) {
return `${config.baseWsUrl}/machines/${machineId}/terminal?${joinClientParams()}`;
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

@@ -1,5 +1,10 @@
import { EnumValue } from '@/common/Enum';
export const MachineProtocolEnum = {
Ssh: EnumValue.of(1, 'SSH'),
Rdp: EnumValue.of(2, 'RDP'),
};
// 脚本执行结果类型
export const ScriptResultEnum = {
Result: EnumValue.of(1, '有结果'),

View File

@@ -44,13 +44,21 @@
</el-row>
<el-dialog destroy-on-close :title="fileDialog.title" v-model="fileDialog.visible" :close-on-click-modal="false" width="70%">
<machine-file :title="fileDialog.title" :machine-id="machineId" :file-id="fileDialog.fileId" :path="fileDialog.path" />
<machine-file
:title="fileDialog.title"
:machine-id="machineId"
:auth-cert-name="props.authCertName"
:file-id="fileDialog.fileId"
:path="fileDialog.path"
:protocol="protocol"
/>
</el-dialog>
<machine-file-content
:title="fileContent.title"
v-model:visible="fileContent.contentVisible"
:machine-id="machineId"
:auth-cert-name="props.authCertName"
:file-id="fileContent.fileId"
:path="fileContent.path"
/>
@@ -59,7 +67,7 @@
</template>
<script lang="ts" setup>
import { toRefs, reactive, watch } from 'vue';
import { reactive, toRefs, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { machineApi } from '../api';
import { FileTypeEnum } from '../enums';
@@ -68,7 +76,9 @@ import MachineFileContent from './MachineFileContent.vue';
const props = defineProps({
visible: { type: Boolean },
protocol: { type: Number, default: 1 },
machineId: { type: Number },
authCertName: { type: String },
title: { type: String },
});
@@ -96,6 +106,7 @@ const state = reactive({
fileTable: [] as any,
fileDialog: {
visible: false,
protocol: 1,
title: '',
fileId: 0,
path: '',

View File

@@ -154,7 +154,7 @@
<SvgIcon :size="15" name="folder" color="#007AFF" />
</span>
<span v-else>
<SvgIcon :size="15" name="document" />
<SvgIcon :size="15" :name="scope.row.icon" />
</span>
<span class="ml5" style="display: inline-block; width: 90%">
@@ -251,8 +251,8 @@
</el-form-item>
<el-form-item prop="type" label="类型">
<el-radio-group v-model="createFileDialog.type">
<el-radio label="d">文件夹</el-radio>
<el-radio label="-">文件</el-radio>
<el-radio value="d" label="d">文件夹</el-radio>
<el-radio value="-" label="-">文件</el-radio>
</el-radio-group>
</el-form-item>
</div>
@@ -265,26 +265,34 @@
</template>
</el-dialog>
<machine-file-content v-model:visible="fileContent.contentVisible" :machine-id="machineId" :file-id="fileId" :path="fileContent.path" />
<machine-file-content
v-model:visible="fileContent.contentVisible"
:machine-id="machineId"
:auth-cert-name="props.authCertName"
:file-id="fileId"
:path="fileContent.path"
:protocol="protocol"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, toRefs, reactive, onMounted, computed } from 'vue';
import { ElMessage, ElMessageBox, ElInput } from 'element-plus';
import { computed, onMounted, reactive, ref, toRefs } from 'vue';
import { ElInput, ElMessage, ElMessageBox } from 'element-plus';
import { machineApi } from '../api';
import { joinClientParams } from '@/common/request';
import config from '@/common/config';
import { isTrue } from '@/common/assert';
import { isTrue, notBlank } from '@/common/assert';
import MachineFileContent from './MachineFileContent.vue';
import { notBlank } from '@/common/assert';
import { getToken } from '@/common/utils/storage';
import { formatByteSize, convertToBytes } from '@/common/utils/format';
import { convertToBytes, formatByteSize } from '@/common/utils/format';
import { getMachineConfig } from '@/common/sysconfig';
const props = defineProps({
machineId: { type: Number },
authCertName: { type: String },
protocol: { type: Number, default: 1 },
fileId: { type: Number, default: 0 },
path: { type: String, default: '' },
isFolder: { type: Boolean, default: true },
@@ -413,8 +421,10 @@ const pasteFile = async () => {
await api.request({
machineId: props.machineId,
fileId: props.fileId,
path: cmFile.paths,
authCertName: props.authCertName,
paths: cmFile.paths,
toPath: state.nowPath,
protocol: props.protocol,
});
ElMessage.success('粘贴成功');
state.copyOrMvFile.paths = [];
@@ -454,15 +464,15 @@ const fileRename = async (row: any) => {
notBlank(row.name, '新名称不能为空');
try {
await machineApi.renameFile.request({
machineId: props.machineId,
fileId: props.fileId,
oldname: state.nowPath + pathSep + state.renameFile.oldname,
machineId: parseInt(props.machineId + ''),
authCertName: props.authCertName,
fileId: parseInt(props.fileId + ''),
path: state.nowPath + pathSep + state.renameFile.oldname,
newname: state.nowPath + pathSep + row.name,
protocol: props.protocol,
});
ElMessage.success('重命名成功');
// 修改路径上的文件名
row.path = state.nowPath + pathSep + row.name;
state.renameFile.oldname = '';
await refresh();
} catch (e) {
row.name = state.renameFile.oldname;
}
@@ -502,14 +512,92 @@ const lsFile = async (path: string) => {
const res = await machineApi.lsFile.request({
fileId: props.fileId,
machineId: props.machineId,
authCertName: props.authCertName,
protocol: props.protocol,
path,
});
for (const file of res) {
const type = file.type;
if (type == folderType) {
file.isFolder = true;
file.iocn = 'folder';
} else {
file.isFolder = false;
const fileExtension = file.name.split('.').pop().toLowerCase();
switch (fileExtension) {
case 'doc':
case 'docx':
file.icon = 'iconfont icon-word';
break;
case 'xls':
case 'xlsx':
file.icon = 'iconfont icon-excel';
break;
case 'ppt':
case 'pptx':
file.icon = 'iconfont icon-ppt';
break;
case 'pdf':
file.icon = 'iconfont icon-pdf';
break;
case 'xml':
file.icon = 'iconfont icon-xml';
break;
case 'html':
file.icon = 'iconfont icon-html';
break;
case 'yaml':
case 'yml':
file.icon = 'iconfont icon-yaml';
break;
case 'css':
file.icon = 'iconfont icon-file-css';
break;
case 'js':
case 'ts':
file.icon = 'iconfont icon-file-js';
break;
case 'mp4':
case 'rmvb':
file.icon = 'iconfont icon-file-video';
break;
case 'mp3':
file.icon = 'iconfont icon-file-audio';
break;
case 'bmp':
case 'jpg':
case 'jpeg':
case 'png':
case 'tif':
case 'gif':
case 'pcx':
case 'tga':
case 'exif':
case 'svg':
case 'psd':
case 'ai':
case 'webp':
file.icon = 'iconfont icon-file-image';
break;
case 'md':
file.icon = 'iconfont icon-md';
break;
case 'txt':
file.icon = 'iconfont icon-txt';
break;
case 'zip':
case 'rar':
case '7z':
case 'gz':
case 'tar':
case 'tgz':
file.icon = 'iconfont icon-file-zip';
break;
default:
file.icon = 'iconfont icon-file';
break;
}
}
}
return res;
@@ -530,6 +618,7 @@ const getDirSize = async (data: any) => {
machineId: props.machineId,
fileId: props.fileId,
path: data.path,
protocol: props.protocol,
});
data.dirSize = res;
} finally {
@@ -547,6 +636,7 @@ const showFileStat = async (data: any) => {
machineId: props.machineId,
fileId: props.fileId,
path: data.path,
protocol: props.protocol,
});
data.stat = res;
} finally {
@@ -565,7 +655,9 @@ const createFile = async () => {
const path = state.nowPath + pathSep + name;
await machineApi.createFile.request({
machineId: props.machineId,
authCertName: props.authCertName,
id: props.fileId,
protocol: props.protocol,
path,
type,
});
@@ -597,8 +689,10 @@ const deleteFile = async (files: any) => {
state.loading = true;
await machineApi.rmFile.request({
fileId: props.fileId,
path: files.map((x: any) => x.path),
paths: files.map((x: any) => x.path),
machineId: props.machineId,
authCertName: props.authCertName,
protocol: props.protocol,
});
ElMessage.success('删除成功');
refresh();
@@ -611,7 +705,11 @@ const deleteFile = async (files: any) => {
const downloadFile = (data: any) => {
const a = document.createElement('a');
a.setAttribute('href', `${config.baseApiUrl}/machines/${props.machineId}/files/${props.fileId}/download?path=${data.path}&${joinClientParams()}`);
a.setAttribute(
'href',
`${config.baseApiUrl}/machines/${props.machineId}/files/${props.fileId}/download?path=${data.path}&machineId=${props.machineId}&authCertName=${props.authCertName}&fileId=${props.fileId}&protocol=${props.protocol}&${joinClientParams()}`
);
a.setAttribute('target', '_blank');
a.click();
};
@@ -624,6 +722,10 @@ function uploadFolder(e: any) {
// 把文件夹数据放到formData里面下面的files和paths字段根据接口来定
var form = new FormData();
form.append('basePath', state.nowPath);
form.append('authCertName', props.authCertName as any);
form.append('machineId', props.machineId as any);
form.append('protocol', props.protocol as any);
form.append('fileId', props.fileId as any);
let totalFileSize = 0;
for (let file of e.target.files) {
@@ -676,7 +778,9 @@ const uploadFile = (content: any) => {
const path = state.nowPath;
params.append('file', content.file);
params.append('path', path);
params.append('authCertName', props.authCertName as any);
params.append('machineId', props.machineId as any);
params.append('protocol', props.protocol as any);
params.append('fileId', props.fileId as any);
params.append('token', token);
machineApi.uploadFile

View File

@@ -24,7 +24,7 @@
</template>
<script lang="ts" setup>
import { toRefs, reactive, watch } from 'vue';
import { reactive, toRefs, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { machineApi } from '../api';
@@ -32,8 +32,10 @@ import MonacoEditor from '@/components/monaco/MonacoEditor.vue';
const props = defineProps({
visible: { type: Boolean, default: false },
protocol: { type: Number, default: 1 },
title: { type: String, default: '' },
machineId: { type: Number },
authCertName: { type: String },
fileId: { type: Number, default: 0 },
path: { type: String, default: '' },
});
@@ -63,6 +65,8 @@ const getFileContent = async () => {
fileId: props.fileId,
path,
machineId: props.machineId,
authCertName: props.authCertName,
protocol: props.protocol,
});
state.fileType = getFileType(path);
state.content = res;
@@ -79,6 +83,8 @@ const updateContent = async () => {
id: props.fileId,
path: props.path,
machineId: props.machineId,
authCertName: props.authCertName,
protocol: props.protocol,
});
ElMessage.success('修改成功');
handleClose();

View File

@@ -17,7 +17,14 @@
style="width: 100%"
/>
</el-form-item>
<el-form-item prop="code" label="编号" required>
<el-input
:disabled="form.id"
v-model.trim="form.code"
placeholder="请输入编号 (数字字母下划线), 不可修改"
auto-complete="off"
></el-input>
</el-form-item>
<el-form-item prop="name" label="名称" required>
<el-input v-model.trim="form.name" placeholder="请输入名称" auto-complete="off"></el-input>
</el-form-item>
@@ -57,6 +64,7 @@ import { mongoApi } from './api';
import { ElMessage } from 'element-plus';
import TagTreeSelect from '../component/TagTreeSelect.vue';
import SshTunnelSelect from '../component/SshTunnelSelect.vue';
import { ResourceCodePattern } from '@/common/pattern';
const props = defineProps({
visible: {
@@ -81,6 +89,18 @@ const rules = {
trigger: ['change', 'blur'],
},
],
code: [
{
required: true,
message: '请输入编码',
trigger: ['change', 'blur'],
},
{
pattern: ResourceCodePattern.pattern,
message: ResourceCodePattern.message,
trigger: ['blur'],
},
],
name: [
{
required: true,

View File

@@ -9,6 +9,7 @@
:show-selection="true"
v-model:selection-data="selectionData"
:columns="columns"
lazy
>
<template #tableHeader>
<el-button type="primary" icon="plus" @click="editMongo(true)" plain>添加</el-button>
@@ -51,18 +52,27 @@ import { TableColumn } from '@/components/pagetable';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { useRoute } from 'vue-router';
import { getTagPathSearchItem } from '../component/tag';
import { SearchItem } from '@/components/SearchForm';
const MongoEdit = defineAsyncComponent(() => import('./MongoEdit.vue'));
const MongoDbs = defineAsyncComponent(() => import('./MongoDbs.vue'));
const MongoRunCommand = defineAsyncComponent(() => import('./MongoRunCommand.vue'));
const props = defineProps({
lazy: {
type: [Boolean],
default: false,
},
});
const route = useRoute();
const pageTableRef: Ref<any> = ref(null);
const searchItems = [getTagPathSearchItem(TagResourceTypeEnum.Mongo.value)];
const searchItems = [getTagPathSearchItem(TagResourceTypeEnum.Mongo.value), SearchItem.input('code', '编号')];
const columns = [
TableColumn.new('tags[0].tagPath', '关联标签').isSlot('tagPath').setAddWidth(20),
TableColumn.new('code', '编号'),
TableColumn.new('name', '名称'),
TableColumn.new('uri', '连接uri'),
TableColumn.new('createTime', '创建时间').isTime(),
@@ -92,7 +102,11 @@ const state = reactive({
const { selectionData, query, mongoEditDialog, dbsVisible, usersVisible } = toRefs(state);
onMounted(async () => {});
onMounted(() => {
if (!props.lazy) {
search();
}
});
const checkRouteTagPath = (query: any) => {
if (route.query.tagPath) {
@@ -126,7 +140,10 @@ const deleteMongo = async () => {
}
};
const search = async () => {
const search = async (tagPath: string = '') => {
if (tagPath) {
state.query.tagPath = tagPath;
}
pageTableRef.value.search();
};
@@ -140,6 +157,8 @@ const editMongo = async (data: any) => {
}
state.mongoEditDialog.visible = true;
};
defineExpose({ search });
</script>
<style></style>

View File

@@ -1,6 +1,10 @@
<template>
<div>
<el-dialog :title="title" v-model="dialogVisible" :before-close="cancel" :close-on-click-modal="false" :destroy-on-close="true" width="38%">
<el-drawer :title="title" v-model="dialogVisible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false">
<template #header>
<DrawerHeader :header="title" :back="cancel" />
</template>
<el-form :model="form" ref="redisForm" :rules="rules" label-width="auto">
<el-tabs v-model="tabActiveName">
<el-tab-pane label="基础信息" name="basic">
@@ -17,6 +21,14 @@
style="width: 100%"
/>
</el-form-item>
<el-form-item prop="code" label="编号" required>
<el-input
:disabled="form.id"
v-model.trim="form.code"
placeholder="请输入编号 (数字字母下划线), 不可修改"
auto-complete="off"
></el-input>
</el-form-item>
<el-form-item prop="name" label="名称" required>
<el-input v-model.trim="form.name" placeholder="请输入redis名称" auto-complete="off"></el-input>
</el-form-item>
@@ -90,7 +102,7 @@
<el-button type="primary" :loading="saveBtnLoading" @click="btnOk">确 定</el-button>
</div>
</template>
</el-dialog>
</el-drawer>
</div>
</template>
@@ -102,6 +114,8 @@ import { RsaEncrypt } from '@/common/rsa';
import TagTreeSelect from '../component/TagTreeSelect.vue';
import SshTunnelSelect from '../component/SshTunnelSelect.vue';
import ProcdefSelectFormItem from '@/views/flow/components/ProcdefSelectFormItem.vue';
import { ResourceCodePattern } from '@/common/pattern';
import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue';
const props = defineProps({
visible: {
@@ -125,6 +139,18 @@ const rules = {
trigger: ['blur', 'change'],
},
],
code: [
{
required: true,
message: '请输入编码',
trigger: ['change', 'blur'],
},
{
pattern: ResourceCodePattern.pattern,
message: ResourceCodePattern.message,
trigger: ['blur'],
},
],
name: [
{
required: true,

View File

@@ -9,6 +9,7 @@
:show-selection="true"
v-model:selection-data="selectionData"
:columns="columns"
lazy
>
<template #tableHeader>
<el-button type="primary" icon="plus" @click="editRedis(false)" plain>添加</el-button>
@@ -160,17 +161,27 @@ import { TableColumn } from '@/components/pagetable';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { useRoute } from 'vue-router';
import { getTagPathSearchItem } from '../component/tag';
import { SearchItem } from '@/components/SearchForm';
const props = defineProps({
lazy: {
type: [Boolean],
default: false,
},
});
const route = useRoute();
const pageTableRef: Ref<any> = ref(null);
const searchItems = [getTagPathSearchItem(TagResourceTypeEnum.Redis.value)];
const searchItems = [getTagPathSearchItem(TagResourceTypeEnum.Redis.value), SearchItem.input('code', '编号')];
const columns = ref([
TableColumn.new('tags[0].tagPath', '关联标签').isSlot('tagPath').setAddWidth(20),
TableColumn.new('code', '编号'),
TableColumn.new('name', '名称'),
TableColumn.new('host', 'host:port'),
TableColumn.new('mode', 'mode'),
TableColumn.new('flowProcdefKey', '关联流程'),
TableColumn.new('remark', '备注'),
TableColumn.new('action', '操作').isSlot().setMinWidth(200).fixedRight().alignCenter(),
]);
@@ -212,7 +223,11 @@ const state = reactive({
const { selectionData, query, detailDialog, clusterInfoDialog, infoDialog, redisEditDialog } = toRefs(state);
onMounted(async () => {});
onMounted(() => {
if (!props.lazy) {
search();
}
});
const checkRouteTagPath = (query: any) => {
if (route.query.tagPath) {
@@ -260,7 +275,10 @@ const onShowClusterInfo = async (redis: any) => {
state.clusterInfoDialog.visible = true;
};
const search = () => {
const search = async (tagPath: string = '') => {
if (tagPath) {
state.query.tagPath = tagPath;
}
pageTableRef.value.search();
};
@@ -274,6 +292,8 @@ const editRedis = async (data: any) => {
}
state.redisEditDialog.visible = true;
};
defineExpose({ search });
</script>
<style></style>

View File

@@ -0,0 +1,142 @@
<template>
<div>
<page-table
ref="pageTableRef"
:page-api="resourceAuthCertApi.listByQuery"
:search-items="state.searchItems"
v-model:query-form="query"
:columns="state.columns"
>
<template #tableHeader>
<el-button v-auth="'authcert:save'" type="primary" icon="plus" @click="edit(false)">添加</el-button>
</template>
<template #action="{ data }">
<el-button v-auth="'authcert:save'" @click="edit(data)" type="primary" link>编辑</el-button>
<el-button v-auth="'authcert:del'" @click="deleteAc(data)" type="danger" link>删除</el-button>
</template>
</page-table>
<ResourceAuthCertEdit
:title="editor.title"
v-model:visible="editor.visible"
:auth-cert="editor.authcert"
@confirm="confirmSave"
@cancel="editor.authcert = {}"
:disable-type="state.disableAuthCertType"
:disable-ciphertext-type="state.disableAuthCertCiphertextType"
:resource-edit="false"
/>
</div>
</template>
<script lang="ts" setup>
import { toRefs, reactive, onMounted, ref, Ref } from 'vue';
import { resourceAuthCertApi } from './api';
import { ElMessage, ElMessageBox } from 'element-plus';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { SearchItem } from '@/components/SearchForm';
import { AuthCertCiphertextTypeEnum, AuthCertTypeEnum } from './enums';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import ResourceAuthCertEdit from '../component/ResourceAuthCertEdit.vue';
const pageTableRef: Ref<any> = ref(null);
const state = reactive({
query: {
pageNum: 1,
pageSize: 0,
name: null,
},
searchItems: [
SearchItem.input('name', '凭证名称'),
SearchItem.select('type', '凭证类型').withEnum(AuthCertTypeEnum),
SearchItem.select('ciphertextType', '密文类型').withEnum(AuthCertCiphertextTypeEnum),
],
columns: [
TableColumn.new('name', '名称'),
TableColumn.new('type', '凭证类型').typeTag(AuthCertTypeEnum),
TableColumn.new('username', '用户名'),
TableColumn.new('ciphertextType', '密文类型').typeTag(AuthCertCiphertextTypeEnum),
TableColumn.new('resourceType', '资源类型').typeTag(TagResourceTypeEnum),
TableColumn.new('resourceCode', '资源编号'),
TableColumn.new('remark', '备注'),
TableColumn.new('creator', '创建人'),
TableColumn.new('createTime', '创建时间').isTime(),
TableColumn.new('modifier', '修改者'),
TableColumn.new('updateTime', '修改时间').isTime(),
TableColumn.new('action', '操作').isSlot().fixedRight().setMinWidth(120).alignCenter(),
],
paramsDialog: {
visible: false,
config: null as any,
params: {},
paramsFormItem: [] as any,
},
editor: {
title: '添加授权凭证',
visible: false,
authcert: {},
},
disableAuthCertType: [] as any,
disableAuthCertCiphertextType: [] as any,
});
const { query, editor } = toRefs(state);
onMounted(() => {});
const search = async () => {
pageTableRef.value.search();
};
const edit = (data: any) => {
state.disableAuthCertType = [];
state.disableAuthCertCiphertextType = [];
if (data) {
state.editor.title = `编辑授权凭证-[${data.name}]`;
state.editor.authcert = data;
// 如果数据为公共授权凭证,则不允许修改凭证类型
if (data.type == AuthCertTypeEnum.Public.value) {
state.disableAuthCertType = [AuthCertTypeEnum.Private.value, AuthCertTypeEnum.PrivateDefault.value, AuthCertTypeEnum.Privileged.value];
state.disableAuthCertCiphertextType = [AuthCertCiphertextTypeEnum.Public.value];
} else {
// 如果非公共凭证,也无法修改为公共凭证
state.disableAuthCertType = [AuthCertTypeEnum.Public.value];
}
} else {
state.editor.title = '添加授权凭证';
state.editor.authcert = {
type: AuthCertTypeEnum.Public.value,
ciphertextType: AuthCertCiphertextTypeEnum.Password.value,
extra: {},
};
}
state.editor.visible = true;
};
const confirmSave = async (authCert: any) => {
await resourceAuthCertApi.save.request(authCert);
ElMessage.success('保存成功');
state.editor.visible = false;
search();
};
const deleteAc = async (data: any) => {
try {
await ElMessageBox.confirm(`确定删除该【${data.name}授权凭证?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await resourceAuthCertApi.delete.request({ id: data.id });
ElMessage.success('删除成功');
search();
} catch (err) {
//
}
};
</script>
<style lang="scss"></style>

View File

@@ -1,52 +1,121 @@
<template>
<div class="tag-tree-list card">
<div class="card pd10">
<el-input v-model="filterTag" clearable placeholder="输入关键字过滤(右击进行操作)" style="width: 220px; margin-right: 10px" />
<el-button v-if="useUserInfo().userInfo.username == 'admin'" v-auth="'tag:save'" type="primary" icon="plus" @click="showSaveTagDialog(null)"
>添加</el-button
>
<div style="float: right">
<el-tooltip placement="top">
<template #content>
1. 用于将资产进行归类
<br />2. 可在团队管理中进行分配用于资源隔离 <br />3. 拥有父标签的团队成员可访问操作其自身或子标签关联的资源
</template>
<span
>标签作用<el-icon>
<question-filled />
</el-icon>
</span>
</el-tooltip>
</div>
</div>
<el-scrollbar class="tag-tree-data">
<el-tree
ref="tagTreeRef"
class="none-select"
node-key="id"
:props="props"
:data="data"
@node-expand="handleNodeExpand"
@node-collapse="handleNodeCollapse"
@node-contextmenu="nodeContextmenu"
@node-click="treeNodeClick"
:default-expanded-keys="defaultExpandedKeys"
:expand-on-click-node="true"
:filter-node-method="filterNode"
>
<template #default="{ data }">
<span class="custom-tree-node">
<span style="font-size: 13px">
{{ 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>
<Splitpanes class="default-theme">
<Pane size="30" min-size="25" max-size="35">
<div class="card pd5 mr5">
<el-input v-model="filterTag" clearable placeholder="关键字过滤(右击操作)" style="width: 200px; margin-right: 10px" />
<el-button
v-if="useUserInfo().userInfo.username == 'admin'"
v-auth="'tag:save'"
type="primary"
icon="plus"
@click="showSaveTagDialog(null)"
></el-button>
<div style="float: right">
<el-tooltip placement="top">
<template #content>
1. 用于将资产进行归类
<br />2. 可在团队管理中进行分配用于资源隔离 <br />3. 拥有父标签的团队成员可访问操作其自身或子标签关联的资源
</template>
<span>
<el-icon>
<question-filled />
</el-icon>
</span>
</el-tooltip>
</div>
</div>
<el-scrollbar class="tag-tree-data">
<el-tree
ref="tagTreeRef"
node-key="id"
highlight-current
:props="props"
:data="data"
@node-expand="handleNodeExpand"
@node-collapse="handleNodeCollapse"
@node-contextmenu="nodeContextmenu"
@node-click="treeNodeClick"
:default-expanded-keys="defaultExpandedKeys"
:expand-on-click-node="false"
:filter-node-method="filterNode"
>
<template #default="{ data }">
<span class="custom-tree-node">
<SvgIcon :name="EnumValue.getEnumByValue(TagResourceTypeEnum, data.type)?.extra.icon" />
<span class="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>
</Pane>
<Pane min-size="40">
<div class="ml10">
<el-tabs @tab-change="tabChange" v-model="state.activeTabName" v-if="currentTag">
<el-tab-pane label="标签详情" :name="TagDetail">
<el-descriptions :column="2" border>
<el-descriptions-item label="类型">
<EnumTag :enums="TagResourceTypeEnum" :value="currentTag.type" />
</el-descriptions-item>
<el-descriptions-item label="code">{{ currentTag.code }}</el-descriptions-item>
<el-descriptions-item label="路径" :span="2">
<span v-for="item in parseTagPath(currentTag.codePath)" :key="item.code">
<SvgIcon :name="EnumValue.getEnumByValue(TagResourceTypeEnum, item.type)?.extra.icon" class="mr2" />
<span> {{ item.code }}</span>
<SvgIcon v-if="!item.isEnd" class="mr5 ml5" name="arrow-right" />
</span>
</el-descriptions-item>
<el-descriptions-item label="名称">{{ currentTag.name }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ currentTag.remark }}</el-descriptions-item>
<el-descriptions-item label="创建者">{{ currentTag.creator }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ dateFormat(currentTag.createTime) }}</el-descriptions-item>
<el-descriptions-item label="修改者">{{ currentTag.modifier }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ dateFormat(currentTag.updateTime) }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<el-tab-pane
:disabled="currentTag.type != TagResourceTypeEnum.Tag.value"
:label="`机器 (${resourceCount.machine || 0})`"
:name="MachineTag"
>
<MachineList lazy ref="machineListRef" />
</el-tab-pane>
<el-tab-pane :disabled="currentTag.type != TagResourceTypeEnum.Tag.value" :label="`数据库 (${resourceCount.db || 0})`" :name="DbTag">
<DbList lazy ref="dbListRef" />
</el-tab-pane>
<el-tab-pane
:disabled="currentTag.type != TagResourceTypeEnum.Tag.value"
:label="`Redis (${resourceCount.redis || 0})`"
:name="RedisTag"
>
<RedisList lazy ref="redisListRef" />
</el-tab-pane>
<el-tab-pane
:disabled="currentTag.type != TagResourceTypeEnum.Tag.value"
:label="`Mongo (${resourceCount.mongo || 0})`"
:name="MongoTag"
>
<MongoList lazy ref="mongoListRef" />
</el-tab-pane>
</el-tabs>
</div>
</Pane>
</Splitpanes>
<el-dialog width="500px" :title="saveTabDialog.title" :before-close="cancelSaveTag" v-model="saveTabDialog.visible">
<el-form ref="tagForm" :rules="rules" :model="saveTabDialog.form" label-width="auto">
@@ -68,55 +137,25 @@
</template>
</el-dialog>
<el-dialog v-model="infoDialog.visible">
<el-descriptions title="节点信息" :column="2" border>
<el-descriptions-item label="code">{{ infoDialog.data.code }}</el-descriptions-item>
<el-descriptions-item label="code路径">{{ infoDialog.data.codePath }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ infoDialog.data.name }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ infoDialog.data.remark }}</el-descriptions-item>
<el-descriptions-item label="创建者">{{ infoDialog.data.creator }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ dateFormat(infoDialog.data.createTime) }}</el-descriptions-item>
<el-descriptions-item label="修改者">{{ infoDialog.data.modifier }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ dateFormat(infoDialog.data.updateTime) }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
<el-dialog :title="`[ ${resourceDialog.tagPath} ] 关联的资源`" v-model="resourceDialog.visible" width="500px">
<el-table max-height="300" :data="resourceDialog.data">
<el-table-column property="resourceType" label="资源类型" min-width="50" show-overflow-tooltip>
<template #default="scope">
{{ EnumValue.getLabelByValue(TagResourceTypeEnum, scope.row.resourceType) }}
</template>
</el-table-column>
<el-table-column property="count" label="数量" min-width="50" show-overflow-tooltip> </el-table-column>
<el-table-column label="操作" min-width="50" show-overflow-tooltip>
<template #default="scope">
<el-button v-auth="scope.row.showAuthCode" @click="showResources(scope.row.resourceType, resourceDialog.tagPath)" link type="success"
>查看</el-button
>
</template>
</el-table-column>
</el-table>
</el-dialog>
<contextmenu :dropdown="state.contextmenu.dropdown" :items="state.contextmenu.items" ref="contextmenuRef" />
</div>
</template>
<script lang="ts" setup>
import { toRefs, ref, watch, reactive, onMounted } from 'vue';
import { toRefs, ref, watch, reactive, onMounted, Ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { tagApi } from './api';
import { dateFormat } from '@/common/utils/date';
import { Contextmenu, ContextmenuItem } from '@/components/contextmenu/index';
import { TagResourceTypeEnum } from '../../../common/commonEnum';
import EnumValue from '@/common/Enum';
import { useRouter } from 'vue-router';
import { hasPerm } from '@/components/auth/auth';
import { useUserInfo } from '@/store/userInfo';
import { Splitpanes, Pane } from 'splitpanes';
import MachineList from '../machine/MachineList.vue';
import RedisList from '../redis/RedisList.vue';
import MongoList from '../mongo/MongoList.vue';
import DbList from '../db/DbList.vue';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import EnumTag from '@/components/enumtag/EnumTag.vue';
import EnumValue from '@/common/Enum';
interface Tree {
id: number;
@@ -125,18 +164,28 @@ interface Tree {
children?: Tree[];
}
const router = useRouter();
const tagForm: any = ref(null);
const tagTreeRef: any = ref(null);
const filterTag = ref('');
const contextmenuRef = ref();
const machineListRef: Ref<any> = ref(null);
const dbListRef: Ref<any> = ref(null);
const redisListRef: Ref<any> = ref(null);
const mongoListRef: Ref<any> = ref(null);
const contextmenuInfo = new ContextmenuItem('info', '详情').withIcon('view').withOnClick((data: any) => info(data));
const TagDetail = 'tagDetail';
const MachineTag = 'machineTag';
const DbTag = 'dbTag';
const RedisTag = 'redisTag';
const MongoTag = 'mongoTag';
const contextmenuAdd = new ContextmenuItem('addTag', '添加子标签')
.withIcon('circle-plus')
.withPermission('tag:save')
.withHideFunc((data: any) => {
// 非标签类型不可添加子标签
return data.type != TagResourceTypeEnum.Tag.value || (data.children && data.children?.[0].type != TagResourceTypeEnum.Tag.value);
})
.withOnClick((data: any) => showSaveTagDialog(data));
const contextmenuEdit = new ContextmenuItem('edit', '编辑')
@@ -149,18 +198,10 @@ const contextmenuDel = new ContextmenuItem('delete', '删除')
.withPermission('tag:del')
.withHideFunc((data: any) => {
// 存在子标签,则不允许删除
return data.children;
return data.children || data.type != TagResourceTypeEnum.Tag.value;
})
.withOnClick((data: any) => deleteTag(data));
const contextmenuShowRelateResource = new ContextmenuItem('showRelateResources', '查看关联资源')
.withIcon('view')
.withHideFunc((data: any) => {
// 存在子标签,则不允许查看关联资源
return data.children;
})
.withOnClick((data: any) => showRelateResource(data));
const state = reactive({
data: [],
saveTabDialog: {
@@ -168,12 +209,6 @@ const state = reactive({
visible: false,
form: { id: 0, pid: 0, code: '', name: '', remark: '' },
},
infoDialog: {
title: '',
visible: false,
// 资源类型选择是否选
data: null as any,
},
resourceDialog: {
title: '',
visible: false,
@@ -187,11 +222,14 @@ const state = reactive({
x: 0,
y: 0,
},
items: [contextmenuInfo, contextmenuEdit, contextmenuAdd, contextmenuDel, contextmenuShowRelateResource],
items: [contextmenuEdit, contextmenuAdd, contextmenuDel],
},
activeTabName: TagDetail,
currentTag: null as any,
resourceCount: {} as any,
});
const { data, saveTabDialog, infoDialog, resourceDialog, defaultExpandedKeys } = toRefs(state);
const { data, saveTabDialog, currentTag, resourceCount, defaultExpandedKeys } = toRefs(state);
const props = {
label: 'name',
@@ -199,14 +237,7 @@ const props = {
};
const rules = {
code: [
{ required: true, message: '标识符不能为空', trigger: 'blur' },
// {
// pattern: /^\w+$/g,
// message: '标识符只能为空数字字母下划线等',
// trigger: 'blur',
// },
],
code: [{ required: true, message: '标识符不能为空', trigger: 'blur' }],
name: [{ required: true, message: '名称不能为空', trigger: 'blur' }],
};
@@ -218,6 +249,75 @@ watch(filterTag, (val) => {
tagTreeRef.value!.filter(val);
});
watch(
() => state.currentTag,
(val: any) => {
if (val.type == TagResourceTypeEnum.Tag.value) {
tagApi.countTagResource.request({ tagPath: val.codePath }).then((res: any) => {
state.resourceCount = res;
});
}
setNowTabData();
}
);
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;
};
const tabChange = () => {
setNowTabData();
};
const setNowTabData = () => {
const tagPath = state.currentTag.codePath;
switch (state.activeTabName) {
case MachineTag:
machineListRef.value.search(tagPath);
break;
case DbTag:
dbListRef.value.search(tagPath);
break;
case RedisTag:
redisListRef.value.search(tagPath);
break;
case MongoTag:
mongoListRef.value.search(tagPath);
break;
default:
break;
}
};
const filterNode = (value: string, data: Tree) => {
if (!value) return true;
return data.codePath.includes(value) || data.name.includes(value);
@@ -236,16 +336,12 @@ const nodeContextmenu = (event: any, data: any) => {
contextmenuRef.value.openContextmenu(data);
};
const treeNodeClick = () => {
const treeNodeClick = (data: any) => {
state.currentTag = data;
// 关闭可能存在的右击菜单
contextmenuRef.value.closeContextmenu();
};
const info = async (data: any) => {
state.infoDialog.data = data;
state.infoDialog.visible = true;
};
const showSaveTagDialog = (data: any) => {
if (data) {
state.saveTabDialog.form.pid = data.id;
@@ -265,66 +361,6 @@ const showEditTagDialog = (data: any) => {
state.saveTabDialog.visible = true;
};
const showRelateResource = async (data: any) => {
const resourceMap = new Map();
state.resourceDialog.tagPath = data.codePath;
const tagResources = await tagApi.getTagResources.request({ tagId: data.id });
for (let tagResource of tagResources) {
const resourceType = tagResource.resourceType;
const exist = resourceMap.get(resourceType);
if (exist) {
exist.count = exist.count + 1;
continue;
}
// 相关管理页面基础权限
let showAuthCode = '';
if (resourceType == TagResourceTypeEnum.Machine.value) {
showAuthCode = 'machine';
}
if (resourceType == TagResourceTypeEnum.Db.value) {
showAuthCode = 'db';
}
if (resourceType == TagResourceTypeEnum.Redis.value) {
showAuthCode = 'redis:manage';
}
if (resourceType == TagResourceTypeEnum.Mongo.value) {
showAuthCode = 'mongo:manage:base';
}
resourceMap.set(resourceType, { resourceType, showAuthCode, count: 1, tagPath: tagResource.tagPath });
}
state.resourceDialog.data = Array.from(resourceMap.values());
state.resourceDialog.visible = true;
};
const showResources = (resourceType: any, tagPath: string) => {
hasPerm;
state.resourceDialog.visible = false;
setTimeout(() => {
let toPath = '';
if (resourceType == TagResourceTypeEnum.Machine.value) {
toPath = '/machine/machines';
}
if (resourceType == TagResourceTypeEnum.Db.value) {
toPath = '/dbms/dbs';
}
if (resourceType == TagResourceTypeEnum.Redis.value) {
toPath = '/redis/manage';
}
if (resourceType == TagResourceTypeEnum.Mongo.value) {
toPath = '/mongo/mongo-manage';
}
router.push({
path: toPath,
query: {
tagPath,
},
});
}, 350);
};
const saveTag = async () => {
tagForm.value.validate(async (valid: any) => {
if (valid) {
@@ -333,6 +369,7 @@ const saveTag = async () => {
ElMessage.success('保存成功');
search();
cancelSaveTag();
state.currentTag = null;
}
});
};
@@ -355,15 +392,6 @@ const deleteTag = (data: any) => {
});
};
// const changeStatus = async (data: any, status: any) => {
// await resourceApi.changeStatus.request({
// id: data.id,
// status: status,
// });
// data.status = status;
// ElMessage.success((status === 1 ? '启用' : '禁用') + '成功!');
// };
// 节点被展开时触发的事件
const handleNodeExpand = (data: any, node: any) => {
const id: any = node.data.id;
@@ -403,15 +431,10 @@ const removeDeafultExpandId = (id: any) => {
line-height: 40px;
}
}
}
.none-select {
moz-user-select: -moz-none;
-moz-user-select: none;
-o-user-select: none;
-khtml-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
.el-tree {
display: inline-block;
min-width: 100%;
}
}
</style>

View File

@@ -24,20 +24,66 @@
<template #action="{ data }">
<el-button @click.prevent="showMembers(data)" link type="primary">成员</el-button>
<el-button @click.prevent="showTags(data)" link type="success">标签</el-button>
<el-button v-auth="'team:save'" @click.prevent="showSaveTeamDialog(data)" link type="warning">编辑</el-button>
</template>
</page-table>
<el-dialog width="400px" title="团队编辑" :before-close="cancelSaveTeam" v-model="addTeamDialog.visible">
<el-drawer
:title="addTeamDialog.form.id ? '编辑团队' : '添加团队'"
v-model="addTeamDialog.visible"
:before-close="cancelSaveTeam"
:destroy-on-close="true"
:close-on-click-modal="false"
>
<template #header>
<DrawerHeader :header="addTeamDialog.form.id ? '编辑团队' : '添加团队'" :back="cancelSaveTeam" />
</template>
<el-form ref="teamForm" :model="addTeamDialog.form" label-width="auto">
<el-form-item prop="name" label="团队名" required>
<el-input v-model="addTeamDialog.form.name" auto-complete="off"></el-input>
<el-input :disabled="addTeamDialog.form.id" v-model="addTeamDialog.form.name" auto-complete="off"></el-input>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="addTeamDialog.form.remark" auto-complete="off"></el-input>
</el-form-item>
<el-form-item prop="tag" label="标签">
<el-scrollbar style="height: calc(100vh - 300px); border: 1px solid var(--el-border-color)">
<el-tree
ref="tagTreeRef"
style="width: 100%"
:data="state.tags"
:default-expanded-keys="state.addTeamDialog.form.tags"
:default-checked-keys="state.addTeamDialog.form.tags"
multiple
:render-after-expand="true"
show-checkbox
check-strictly
node-key="id"
:props="{
value: 'id',
label: 'codePath',
children: 'children',
disabled: 'disabled',
}"
@check="tagTreeNodeCheck"
>
<template #default="{ data }">
<span class="custom-tree-node">
<SvgIcon :name="EnumValue.getEnumByValue(TagResourceTypeEnum, data.type)?.extra.icon" />
<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>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
@@ -45,46 +91,7 @@
<el-button @click="saveTeam" type="primary"> </el-button>
</div>
</template>
</el-dialog>
<el-dialog width="500px" :title="showTagDialog.title" :before-close="closeTagDialog" v-model="showTagDialog.visible">
<el-form label-width="auto">
<el-form-item prop="tag" label="标签">
<el-tree-select
ref="tagTreeRef"
style="width: 100%"
v-model="showTagDialog.tagTreeTeams"
:data="showTagDialog.tags"
:default-expanded-keys="showTagDialog.tagTreeTeams"
multiple
:render-after-expand="true"
show-checkbox
check-strictly
node-key="id"
:props="showTagDialog.props"
@check="tagTreeNodeCheck"
>
<template #default="{ data }">
<span class="custom-tree-node">
<span style="font-size: 13px">
{{ 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-select>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="closeTagDialog()"> </el-button>
<el-button v-auth="'team:tag:save'" @click="saveTags()" type="primary"> </el-button>
</div>
</template>
</el-dialog>
</el-drawer>
<el-dialog @open="setMemebers" width="50%" :title="showMemDialog.title" v-model="showMemDialog.visible">
<page-table
@@ -128,6 +135,9 @@ import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { SearchItem } from '@/components/SearchForm';
import AccountSelectFormItem from '@/views/system/account/components/AccountSelectFormItem.vue';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import EnumValue from '@/common/Enum';
import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue';
const teamForm: any = ref(null);
const tagTreeRef: any = ref(null);
@@ -138,17 +148,19 @@ const searchItems = [SearchItem.input('name', '团队名称')];
const columns = [
TableColumn.new('name', '团队名称'),
TableColumn.new('remark', '备注'),
TableColumn.new('creator', '创建者'),
TableColumn.new('createTime', '创建时间').isTime(),
TableColumn.new('creator', '创建人'),
TableColumn.new('modifier', '修改者'),
TableColumn.new('updateTime', '修改时间').isTime(),
TableColumn.new('action', '操作').isSlot().setMinWidth(120).fixedRight().alignCenter(),
];
const state = reactive({
currentEditPermissions: false,
tags: [],
addTeamDialog: {
title: '新增团队',
visible: false,
form: { id: 0, name: '', remark: '' },
form: { id: 0, name: '', remark: '', tags: [] },
},
query: {
pageNum: 1,
@@ -184,21 +196,9 @@ const state = reactive({
},
accounts: Array(),
},
showTagDialog: {
title: '项目信息',
visible: false,
tags: [],
teamId: 0,
tagTreeTeams: [] as any,
props: {
value: 'id',
label: 'codePath',
children: 'children',
},
},
});
const { query, addTeamDialog, selectionData, showMemDialog, showTagDialog } = toRefs(state);
const { query, addTeamDialog, selectionData, showMemDialog } = toRefs(state);
onMounted(() => {});
@@ -206,13 +206,25 @@ const search = async () => {
pageTableRef.value.search();
};
const showSaveTeamDialog = (data: any) => {
const showSaveTeamDialog = async (data: any) => {
state.tags = await tagApi.getTagTrees.request(null);
if (data) {
state.addTeamDialog.form.id = data.id;
state.addTeamDialog.form.name = data.name;
state.addTeamDialog.form.remark = data.remark;
state.addTeamDialog.title = `修改 [${data.codePath}] 信息`;
state.addTeamDialog.form.tags = await tagApi.getTeamTagIds.request({ teamId: data.id });
setTimeout(() => {
const checkedNodes = tagTreeRef.value.getCheckedNodes();
console.log('check nodes: ', checkedNodes);
// 禁用选中节点的所有父节点,不可选中
for (let checkNodeData of checkedNodes) {
disableParentNodes(tagTreeRef.value.getNode(checkNodeData.id).parent);
}
}, 200);
}
state.addTeamDialog.visible = true;
};
@@ -220,6 +232,7 @@ const saveTeam = async () => {
teamForm.value.validate(async (valid: any) => {
if (valid) {
const form = state.addTeamDialog.form;
form.tags = tagTreeRef.value.getCheckedKeys(false);
await tagApi.saveTeam.request(form);
ElMessage.success('保存成功');
search();
@@ -230,8 +243,10 @@ const saveTeam = async () => {
const cancelSaveTeam = () => {
state.addTeamDialog.visible = false;
state.addTeamDialog.form = {} as any;
teamForm.value.resetFields();
setTimeout(() => {
state.addTeamDialog.form = {} as any;
}, 500);
};
const deleteTeam = () => {
@@ -288,60 +303,47 @@ const cancelAddMember = () => {
state.showMemDialog.addVisible = false;
};
/********** 标签相关 ***********/
const tagTreeNodeCheck = (data: any) => {
const node = tagTreeRef.value.getNode(data.id);
console.log('check node: ', node);
const showTags = async (team: any) => {
state.showTagDialog.tags = await tagApi.getTagTrees.request(null);
state.showTagDialog.tagTreeTeams = await tagApi.getTeamTagIds.request({ teamId: team.id });
state.showTagDialog.title = `[${team.name}] 团队标签信息`;
state.showTagDialog.teamId = team.id;
state.showTagDialog.visible = true;
if (node.checked) {
// 如果选中了子节点,则需要将父节点全部取消选中,并禁用父节点
unCheckParentNodes(node.parent);
disableParentNodes(node.parent);
} else {
// 如果取消了选中,则需要根据条件恢复父节点的选中状态
disableParentNodes(node.parent, false);
}
};
const closeTagDialog = () => {
state.showTagDialog.visible = false;
setTimeout(() => {
state.showTagDialog.tagTreeTeams = [];
}, 500);
const unCheckParentNodes = (node: any) => {
if (!node) {
return;
}
tagTreeRef.value.setChecked(node, false, false);
unCheckParentNodes(node.parent);
};
const saveTags = async () => {
await tagApi.saveTeamTags.request({
teamId: state.showTagDialog.teamId,
tagIds: state.showTagDialog.tagTreeTeams,
});
ElMessage.success('保存成功');
closeTagDialog();
/**
* 禁用该节点以及所有父节点
* @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);
};
const tagTreeNodeCheck = () => {
// const node = tagTreeRef.value.getNode(data.id);
// console.log(node);
// // state.showTagDialog.tagTreeTeams = [16]
// if (node.checked) {
// if (node.parent) {
// console.log(node.parent);
// // removeCheckedTagId(node.parent.key);
// tagTreeRef.value.setChecked(node.parent, false, false);
// }
// // // parentNode = node.parent
// // for (let parentNode of node.parent) {
// // parentNode.setChecked(false);
// // }
// }
// console.log(data);
// console.log(checkInfo);
};
// function removeCheckedTagId(id: any) {
// console.log(state.showTagDialog.tagTreeTeams);
// for (let i = 0; i < state.showTagDialog.tagTreeTeams.length; i++) {
// if (state.showTagDialog.tagTreeTeams[i] == id) {
// console.log('has id', id);
// state.showTagDialog.tagTreeTeams.splice(i, 1);
// }
// }
// console.log(state.showTagDialog.tagTreeTeams);
// }
</script>
<style lang="scss"></style>
<style lang="scss" scoped></style>

View File

@@ -7,7 +7,7 @@ export const tagApi = {
delTagTree: Api.newDelete('/tag-trees/{id}'),
getResourceTagPaths: Api.newGet('/tag-trees/resources/{resourceType}/tag-paths'),
getTagResources: Api.newGet('/tag-trees/resources'),
countTagResource: Api.newGet('/tag-trees/resources/count'),
getTeams: Api.newGet('/teams'),
saveTeam: Api.newPost('/teams'),
@@ -18,5 +18,11 @@ export const tagApi = {
delTeamMem: Api.newDelete('/teams/{teamId}/members/{accountId}'),
getTeamTagIds: Api.newGet('/teams/{teamId}/tags'),
saveTeamTags: Api.newPost('/teams/{teamId}/tags'),
};
export const resourceAuthCertApi = {
detail: Api.newGet('/auth-certs/detail'),
listByQuery: Api.newGet('/auth-certs'),
save: Api.newPost('/auth-certs'),
delete: Api.newDelete('/auth-certs/{id}'),
};

View File

@@ -0,0 +1,16 @@
import { EnumValue } from '@/common/Enum';
// 授权凭证类型
export const AuthCertTypeEnum = {
Public: EnumValue.of(2, '公共凭证').tagTypeSuccess().tagTypeSuccess(),
Private: EnumValue.of(1, '普通账号'),
Privileged: EnumValue.of(11, '特权账号').tagTypeDanger(),
PrivateDefault: EnumValue.of(12, '默认账号').tagTypeWarning(),
};
// 授权凭证密文类型
export const AuthCertCiphertextTypeEnum = {
Password: EnumValue.of(1, '密码').tagTypeSuccess(),
PrivateKey: EnumValue.of(2, '秘钥').tagTypeSuccess(),
Public: EnumValue.of(-1, '公共凭证').tagTypeSuccess(),
};

View File

@@ -44,6 +44,7 @@ export const configApi = {
export const logApi = {
list: Api.newGet('/syslogs'),
detail: Api.newGet('/syslogs/{id}'),
};
export const authApi = {

View File

@@ -1,6 +1,6 @@
<template>
<div>
<page-table ref="pageTableRef" :page-api="configApi.list" v-model:selection-data="selectionData" :columns="columns">
<page-table ref="pageTableRef" :search-items="searchItems" :page-api="configApi.list" :columns="columns" v-model:query-form="query">
<template #tableHeader>
<el-button v-auth="perms.saveConfig" type="primary" icon="plus" @click="editConfig(false)">添加</el-button>
</template>
@@ -51,10 +51,14 @@ import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { hasPerms } from '@/components/auth/auth';
import { DynamicForm } from '@/components/dynamic-form';
import { SearchItem } from '@/components/SearchForm';
const perms = {
saveConfig: 'config:save',
};
const searchItems = [SearchItem.input('key', '配置key')];
const columns = ref([
TableColumn.new('name', '配置项'),
TableColumn.new('key', '配置key'),
@@ -89,7 +93,7 @@ const state = reactive({
},
});
const { selectionData, paramsDialog, configEdit } = toRefs(state);
const { query, paramsDialog, configEdit } = toRefs(state);
onMounted(() => {
if (Object.keys(actionBtns).length > 0) {

View File

@@ -1,7 +1,7 @@
import { EnumValue } from '@/common/Enum';
export const ResourceTypeEnum = {
Menu: EnumValue.of(1, '菜单'),
Menu: EnumValue.of(1, '菜单').tagTypeSuccess(),
Permission: EnumValue.of(2, '权限'),
};
@@ -18,4 +18,5 @@ export const RoleStatusEnum = {
export const LogTypeEnum = {
Success: EnumValue.of(1, '成功').tagTypeSuccess(),
Error: EnumValue.of(2, '失败').tagTypeDanger(),
Running: EnumValue.of(-1, '执行中'),
};

View File

@@ -1,52 +1,103 @@
<template>
<div class="card system-resouce-list">
<div class="card pd10 flex-justify-between">
<div>
<el-input v-model="filterResource" clearable placeholder="输入关键字过滤(右击进行操作)" style="width: 220px; margin-right: 10px" />
<el-button v-auth="perms.addResource" type="primary" icon="plus" @click="addResource(false)">添加</el-button>
</div>
<Splitpanes class="default-theme">
<Pane size="25" min-size="20" max-size="30">
<div class="card pd5 mr5">
<el-input v-model="filterResource" clearable placeholder="输入关键字过滤(右击操作)" style="width: 200px; margin-right: 10px" />
<el-button v-auth="perms.addResource" type="primary" icon="plus" @click="addResource(false)"></el-button>
<div>
<span> <SvgIcon name="info-filled" />红色橙色字体表示禁用状态 (右击资源进行操作) </span>
</div>
</div>
<el-scrollbar class="tree-data">
<el-tree
ref="resourceTreeRef"
class="none-select"
:indent="24"
node-key="id"
:props="props"
:data="data"
@node-expand="handleNodeExpand"
@node-collapse="handleNodeCollapse"
@node-contextmenu="nodeContextmenu"
@node-click="treeNodeClick"
:default-expanded-keys="defaultExpandedKeys"
:expand-on-click-node="true"
draggable
:allow-drop="allowDrop"
@node-drop="handleDrop"
:filter-node-method="filterNode"
>
<template #default="{ data }">
<span class="custom-tree-node">
<span style="font-size: 13px" v-if="data.type === menuTypeValue">
<span style="color: #3c8dbc"></span>
<span v-if="data.status == 1">{{ data.name }}</span>
<span v-if="data.status == -1" style="color: #e6a23c">{{ data.name }}</span>
<span style="color: #3c8dbc"></span>
<el-tag v-if="data.children !== null" size="small">{{ data.children.length }}</el-tag>
</span>
<span style="font-size: 13px" v-if="data.type === permissionTypeValue">
<span style="color: #3c8dbc"></span>
<span :style="data.status == 1 ? 'color: #67c23a;' : 'color: #f67c6c;'">{{ data.name }}</span>
<span style="color: #3c8dbc"></span>
</span>
</span>
</template>
</el-tree>
</el-scrollbar>
<div class="fr">
<el-tooltip placement="top">
<template #content> 红色橙色字体表示禁用状态 (右击资源进行操作) </template>
<span> <SvgIcon name="question-filled" /> </span>
</el-tooltip>
</div>
</div>
<el-scrollbar class="tree-data">
<el-tree
ref="resourceTreeRef"
class="none-select"
:indent="24"
node-key="id"
:props="props"
:data="data"
highlight-current
@node-expand="handleNodeExpand"
@node-collapse="handleNodeCollapse"
@node-contextmenu="nodeContextmenu"
@node-click="treeNodeClick"
:default-expanded-keys="defaultExpandedKeys"
:expand-on-click-node="false"
draggable
:allow-drop="allowDrop"
@node-drop="handleDrop"
:filter-node-method="filterNode"
>
<template #default="{ data }">
<span class="custom-tree-node">
<span style="font-size: 13px" v-if="data.type === menuTypeValue">
<span style="color: #3c8dbc"></span>
<span v-if="data.status == 1">{{ data.name }}</span>
<span v-if="data.status == -1" style="color: #e6a23c">{{ data.name }}</span>
<span style="color: #3c8dbc"></span>
<el-tag v-if="data.children !== null" size="small">{{ data.children.length }}</el-tag>
</span>
<span style="font-size: 13px" v-if="data.type === permissionTypeValue">
<span style="color: #3c8dbc"></span>
<span :style="data.status == 1 ? 'color: #67c23a;' : 'color: #f67c6c;'">{{ data.name }}</span>
<span style="color: #3c8dbc"></span>
</span>
</span>
</template>
</el-tree>
</el-scrollbar>
</Pane>
<Pane min-size="40">
<div class="ml10">
<el-tabs v-model="state.activeTabName" v-if="currentResource">
<el-tab-pane label="菜单资源详情" :name="ResourceDetail">
<el-descriptions title="资源信息" :column="2" border>
<el-descriptions-item label="类型">
<enum-tag :enums="ResourceTypeEnum" :value="currentResource?.type" />
</el-descriptions-item>
<el-descriptions-item label="名称">{{ currentResource.name }}</el-descriptions-item>
<el-descriptions-item label="code[菜单path]">{{ currentResource.code }}</el-descriptions-item>
<el-descriptions-item v-if="currentResource.type == menuTypeValue" label="图标">
<SvgIcon :name="currentResource.meta.icon" />
</el-descriptions-item>
<el-descriptions-item v-if="currentResource.type == menuTypeValue" label="路由名">
{{ currentResource.meta.routeName }}
</el-descriptions-item>
<el-descriptions-item v-if="currentResource.type == menuTypeValue" label="组件路径">
{{ currentResource.meta.component }}
</el-descriptions-item>
<el-descriptions-item v-if="currentResource.type == menuTypeValue" label="是否缓存">
{{ currentResource.meta.isKeepAlive ? '' : '' }}
</el-descriptions-item>
<el-descriptions-item v-if="currentResource.type == menuTypeValue" label="是否隐藏">
{{ currentResource.meta.isHide ? '' : '' }}
</el-descriptions-item>
<el-descriptions-item v-if="currentResource.type == menuTypeValue" label="tag不可删除">
{{ currentResource.meta.isAffix ? '' : '' }}
</el-descriptions-item>
<el-descriptions-item v-if="currentResource.type == menuTypeValue" label="外链">
{{ currentResource.meta.linkType ? '' : '' }}
</el-descriptions-item>
<el-descriptions-item v-if="currentResource.type == menuTypeValue && currentResource.meta.linkType > 0" label="外链">
{{ currentResource.meta.link }}
</el-descriptions-item>
<el-descriptions-item label="创建者">{{ currentResource.creator }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ dateFormat(currentResource.createTime) }} </el-descriptions-item>
<el-descriptions-item label="修改者">{{ currentResource.modifier }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ dateFormat(currentResource.updateTime) }} </el-descriptions-item>
</el-descriptions>
</el-tab-pane>
</el-tabs>
</div>
</Pane>
</Splitpanes>
<ResourceEdit
:title="dialogForm.title"
@@ -58,45 +109,6 @@
@val-change="valChange"
/>
<el-dialog v-model="infoDialog.visible">
<el-descriptions title="资源信息" :column="2" border>
<el-descriptions-item label="类型">
<el-tag size="small">{{ EnumValue.getLabelByValue(ResourceTypeEnum, infoDialog.data.type) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="名称">{{ infoDialog.data.name }}</el-descriptions-item>
<el-descriptions-item label="code[菜单path]">{{ infoDialog.data.code }}</el-descriptions-item>
<el-descriptions-item v-if="infoDialog.data.type == menuTypeValue" label="图标">
<SvgIcon :name="infoDialog.data.meta.icon" />
</el-descriptions-item>
<el-descriptions-item v-if="infoDialog.data.type == menuTypeValue" label="路由名">
{{ infoDialog.data.meta.routeName }}
</el-descriptions-item>
<el-descriptions-item v-if="infoDialog.data.type == menuTypeValue" label="组件路径">
{{ infoDialog.data.meta.component }}
</el-descriptions-item>
<el-descriptions-item v-if="infoDialog.data.type == menuTypeValue" label="是否缓存">
{{ infoDialog.data.meta.isKeepAlive ? '' : '' }}
</el-descriptions-item>
<el-descriptions-item v-if="infoDialog.data.type == menuTypeValue" label="是否隐藏">
{{ infoDialog.data.meta.isHide ? '' : '' }}
</el-descriptions-item>
<el-descriptions-item v-if="infoDialog.data.type == menuTypeValue" label="tag不可删除">
{{ infoDialog.data.meta.isAffix ? '' : '' }}
</el-descriptions-item>
<el-descriptions-item v-if="infoDialog.data.type == menuTypeValue" label="外链">
{{ infoDialog.data.meta.linkType ? '' : '' }}
</el-descriptions-item>
<el-descriptions-item v-if="infoDialog.data.type == menuTypeValue && infoDialog.data.meta.linkType > 0" label="外链">
{{ infoDialog.data.meta.link }}
</el-descriptions-item>
<el-descriptions-item label="创建者">{{ infoDialog.data.creator }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ dateFormat(infoDialog.data.createTime) }} </el-descriptions-item>
<el-descriptions-item label="修改者">{{ infoDialog.data.modifier }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ dateFormat(infoDialog.data.updateTime) }} </el-descriptions-item>
</el-descriptions>
</el-dialog>
<contextmenu :dropdown="state.contextmenu.dropdown" :items="state.contextmenu.items" ref="contextmenuRef" />
</div>
</template>
@@ -108,8 +120,9 @@ import ResourceEdit from './ResourceEdit.vue';
import { ResourceTypeEnum } from '../enums';
import { resourceApi } from '../api';
import { dateFormat } from '@/common/utils/date';
import EnumValue from '@/common/Enum';
import EnumTag from '@/components/enumtag/EnumTag.vue';
import { Contextmenu, ContextmenuItem } from '@/components/contextmenu';
import { Splitpanes, Pane } from 'splitpanes';
const menuTypeValue = ResourceTypeEnum.Menu.value;
const permissionTypeValue = ResourceTypeEnum.Permission.value;
@@ -130,7 +143,7 @@ const contextmenuRef = ref();
const filterResource = ref();
const resourceTreeRef = ref();
const contextmenuInfo = new ContextmenuItem('info', '详情').withIcon('View').withOnClick((data: any) => info(data));
const ResourceDetail = 'resourceDetail';
const contextmenuAdd = new ContextmenuItem('add', '添加子资源')
.withIcon('circle-plus')
@@ -166,7 +179,7 @@ const state = reactive({
x: 0,
y: 0,
},
items: [contextmenuInfo, contextmenuAdd, contextmenuEdit, contextmenuEnable, contextmenuDisable, contextmenuDel],
items: [contextmenuAdd, contextmenuEdit, contextmenuEnable, contextmenuDisable, contextmenuDel],
},
//弹出框对象
dialogForm: {
@@ -177,29 +190,15 @@ const state = reactive({
// 资源类型选择是否选
typeDisabled: true,
},
//资源信息弹出框对象
infoDialog: {
title: '',
visible: false,
// 资源类型选择是否选
data: {
meta: {} as any,
name: '',
type: null,
creator: '',
modifier: '',
createTime: '',
updateTime: '',
code: '',
},
},
data: [],
// 展开的节点
defaultExpandedKeys: [] as any[],
activeTabName: ResourceDetail,
currentResource: null as any,
});
const { dialogForm, infoDialog, data, defaultExpandedKeys } = toRefs(state);
const { currentResource, dialogForm, data, defaultExpandedKeys } = toRefs(state);
onMounted(() => {
search();
@@ -229,9 +228,15 @@ const nodeContextmenu = (event: any, data: any) => {
contextmenuRef.value.openContextmenu(data);
};
const treeNodeClick = () => {
const treeNodeClick = async (data: any) => {
// 关闭可能存在的右击菜单
contextmenuRef.value.closeContextmenu();
let info = await resourceApi.detail.request({ id: data.id });
state.currentResource = info;
if (info.meta && info.meta != '') {
state.currentResource.meta = JSON.parse(info.meta);
}
};
const deleteMenu = (data: any) => {
@@ -390,15 +395,6 @@ const removeDeafultExpandId = (id: any) => {
state.defaultExpandedKeys.splice(index, 1);
}
};
const info = async (data: any) => {
let info = await resourceApi.detail.request({ id: data.id });
state.infoDialog.data = info;
if (info.meta && info.meta != '') {
state.infoDialog.data.meta = JSON.parse(info.meta);
}
state.infoDialog.visible = true;
};
</script>
<style lang="scss">
.system-resouce-list {
@@ -410,6 +406,11 @@ const info = async (data: any) => {
.tree-data {
height: calc(100vh - 202px);
}
.el-tree {
display: inline-block;
min-width: 100%;
}
}
.none-select {

View File

@@ -40,10 +40,10 @@ const searchItems = [
const columns = [
TableColumn.new('creator', '操作人').isSlot().noShowOverflowTooltip(),
TableColumn.new('createTime', '操作时间').isTime(),
TableColumn.new('type', '结果').typeTag(LogTypeEnum),
TableColumn.new('description', '描述'),
TableColumn.new('type', '结果').typeTag(LogTypeEnum),
TableColumn.new('reqParam', '操作信息').canBeautify(),
TableColumn.new('resp', '响应信息'),
TableColumn.new('resp', '响应信息').canBeautify(),
];
const state = reactive({

View File

@@ -3,44 +3,46 @@ module mayfly-go
go 1.22
require (
gitee.com/chunanyong/dm v1.8.13
gitee.com/chunanyong/dm v1.8.14
gitee.com/liuzongyang/libpq v1.0.9
github.com/buger/jsonparser v1.1.1
github.com/emirpasic/gods v1.18.1
github.com/gin-gonic/gin v1.9.1
github.com/glebarez/sqlite v1.10.0
github.com/glebarez/sqlite v1.11.0
github.com/go-gormigrate/gormigrate/v2 v2.1.0
github.com/go-ldap/ldap/v3 v3.4.6
github.com/go-playground/locales v0.14.1
github.com/go-playground/universal-translator v0.18.1
github.com/go-playground/validator/v10 v10.14.0
github.com/go-sql-driver/mysql v1.7.1
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.3.1
github.com/go-sql-driver/mysql v1.8.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.1
github.com/kanzihuang/vitess/go/vt/sqlparser v0.0.0-20231018071450-ac8d9f0167e9
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20230712084735-068dc2aee82d
github.com/microsoft/go-mssqldb v1.6.0
github.com/may-fly/cast v1.6.1
github.com/microsoft/go-mssqldb v1.7.0
github.com/mojocn/base64Captcha v1.3.6 //
github.com/pkg/errors v0.9.1
github.com/pkg/sftp v1.13.6
github.com/pquerna/otp v1.4.0
github.com/redis/go-redis/v9 v9.5.1
github.com/robfig/cron/v3 v3.0.1 //
github.com/sijms/go-ora/v2 v2.8.9
github.com/sijms/go-ora/v2 v2.8.10
github.com/stretchr/testify v1.8.4
go.mongodb.org/mongo-driver v1.14.0 // mongo
golang.org/x/crypto v0.20.0 // ssh
golang.org/x/oauth2 v0.17.0
golang.org/x/crypto v0.22.0 // ssh
golang.org/x/oauth2 v0.18.0
golang.org/x/sync v0.6.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
// gorm
gorm.io/driver/mysql v1.5.4
gorm.io/gorm v1.25.7
gorm.io/driver/mysql v1.5.6
gorm.io/gorm v1.25.9
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bytedance/sonic v1.9.1 // indirect
@@ -86,8 +88,8 @@ require (
golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20230519143937-03e91628a987 // indirect
golang.org/x/image v0.13.0 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/sys v0.17.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20230131230820-1c016267d619 // indirect

View File

@@ -2,8 +2,9 @@ package config
import (
sysapp "mayfly-go/internal/sys/application"
"mayfly-go/pkg/utils/conv"
"mayfly-go/pkg/utils/stringx"
"github.com/may-fly/cast"
)
const (
@@ -27,8 +28,8 @@ func GetAccountLoginSecurity() *AccountLoginSecurity {
als := new(AccountLoginSecurity)
als.UseCaptcha = c.ConvBool(jm["useCaptcha"], true)
als.UseOtp = c.ConvBool(jm["useOtp"], false)
als.LoginFailCount = conv.Str2Int(jm["loginFailCount"], 5)
als.LoginFailMin = conv.Str2Int(jm["loginFailMin"], 10)
als.LoginFailCount = cast.ToIntD(jm["loginFailCount"], 5)
als.LoginFailMin = cast.ToIntD(jm["loginFailMin"], 10)
otpIssuer := jm["otpIssuer"]
if otpIssuer == "" {
otpIssuer = "mayfly-go"

View File

@@ -16,10 +16,10 @@ const (
// RedisConnExpireTime = 2 * time.Minute
// MongoConnExpireTime = 2 * time.Minute
TagResourceTypeMachine = 1
TagResourceTypeDb = 2
TagResourceTypeRedis = 3
TagResourceTypeMongo = 4
ResourceTypeMachine int8 = 1
ResourceTypeDb int8 = 2
ResourceTypeRedis int8 = 3
ResourceTypeMongo int8 = 4
// 删除机器的事件主题名
DeleteMachineEventTopic = "machine:delete"

View File

@@ -15,7 +15,7 @@ type Dashbord struct {
func (m *Dashbord) Dashbord(rc *req.Ctx) {
accountId := rc.GetLoginAccount().Id
dbNum := len(m.TagTreeApp.GetAccountResourceCodes(accountId, consts.TagResourceTypeDb, ""))
dbNum := len(m.TagTreeApp.GetAccountTagCodes(accountId, consts.ResourceTypeDb, ""))
rc.ResData = collx.M{
"dbNum": dbNum,

View File

@@ -9,6 +9,7 @@ import (
"mayfly-go/internal/db/api/form"
"mayfly-go/internal/db/api/vo"
"mayfly-go/internal/db/application"
"mayfly-go/internal/db/config"
"mayfly-go/internal/db/dbm/dbi"
"mayfly-go/internal/db/domain/entity"
msgapp "mayfly-go/internal/msg/application"
@@ -23,11 +24,11 @@ import (
"mayfly-go/pkg/utils/collx"
"mayfly-go/pkg/utils/stringx"
"mayfly-go/pkg/ws"
"strconv"
"strings"
"time"
"github.com/kanzihuang/vitess/go/vt/sqlparser"
"github.com/may-fly/cast"
)
type Db struct {
@@ -43,7 +44,7 @@ func (d *Db) Dbs(rc *req.Ctx) {
queryCond, page := req.BindQueryAndPage[*entity.DbQuery](rc, new(entity.DbQuery))
// 不存在可访问标签id即没有可操作数据
codes := d.TagApp.GetAccountResourceCodes(rc.GetLoginAccount().Id, consts.TagResourceTypeDb, queryCond.TagPath)
codes := d.TagApp.GetAccountTagCodes(rc.GetLoginAccount().Id, consts.ResourceTypeDb, queryCond.TagPath)
if len(codes) == 0 {
rc.ResData = model.EmptyPageResult[any]()
return
@@ -55,7 +56,7 @@ func (d *Db) Dbs(rc *req.Ctx) {
biz.ErrIsNil(err)
// 填充标签信息
d.TagApp.FillTagInfo(collx.ArrayMap(dbvos, func(dbvo *vo.DbListVO) tagentity.ITagResource {
d.TagApp.FillTagInfo(tagentity.TagType(consts.ResourceTypeDb), collx.ArrayMap(dbvos, func(dbvo *vo.DbListVO) tagentity.ITagResource {
return dbvo
})...)
@@ -78,9 +79,8 @@ func (d *Db) DeleteDb(rc *req.Ctx) {
ctx := rc.MetaCtx
for _, v := range ids {
value, err := strconv.Atoi(v)
biz.ErrIsNilAppendErr(err, "string类型转换为int异常: %s")
dbId := uint64(value)
dbId := cast.ToUint64(v)
biz.NotBlank(dbId, "存在错误dbId")
d.DbApp.Delete(ctx, dbId)
// 删除该库的sql执行记录
d.DbSqlExecApp.DeleteBy(ctx, &entity.DbSqlExec{DbId: dbId})
@@ -112,11 +112,10 @@ func (d *Db) ExecSql(rc *req.Ctx) {
DbConn: dbConn,
}
// 比前端超时时间稍微快一点,可以提示到前端
ctx, cancel := context.WithTimeout(rc.MetaCtx, 58*time.Second)
ctx, cancel := context.WithTimeout(rc.MetaCtx, time.Duration(config.GetDbms().SqlExecTl)*time.Second)
defer cancel()
sqls, err := sqlparser.SplitStatementToPieces(sql, sqlparser.WithDialect(dbConn.Info.Type.Dialect()))
sqls, err := sqlparser.SplitStatementToPieces(sql, sqlparser.WithDialect(dbConn.GetMetaData().GetSqlParserDialect()))
biz.ErrIsNil(err, "SQL解析错误,请检查您的执行SQL")
isMulti := len(sqls) > 1
var execResAll *application.DbSqlExecRes
@@ -197,7 +196,7 @@ func (d *Db) ExecSqlFile(rc *req.Ctx) {
var sql string
tokenizer := sqlparser.NewReaderTokenizer(file,
sqlparser.WithCacheInBuffer(), sqlparser.WithDialect(dbConn.Info.Type.Dialect()))
sqlparser.WithCacheInBuffer(), sqlparser.WithDialect(dbConn.GetMetaData().GetSqlParserDialect()))
executedStatements := 0
progressId := stringx.Rand(32)
@@ -262,7 +261,7 @@ func (d *Db) ExecSqlFile(rc *req.Ctx) {
// 数据库dump
func (d *Db) DumpSql(rc *req.Ctx) {
dbId := getDbId(rc)
dbNamesStr := rc.Query("db")
dbName := rc.Query("db")
dumpType := rc.Query("type")
tablesStr := rc.Query("tables")
extName := rc.Query("extName")
@@ -281,111 +280,44 @@ func (d *Db) DumpSql(rc *req.Ctx) {
la := rc.GetLoginAccount()
db, err := d.DbApp.GetById(new(entity.Db), dbId)
biz.ErrIsNil(err, "该数据库不存在")
biz.ErrIsNilAppendErr(d.TagApp.CanAccess(la.Id, d.TagApp.ListTagPathByResource(consts.TagResourceTypeDb, db.Code)...), "%s")
biz.ErrIsNilAppendErr(d.TagApp.CanAccess(la.Id, d.TagApp.ListTagPathByTypeAndCode(consts.ResourceTypeDb, db.Code)...), "%s")
now := time.Now()
filename := fmt.Sprintf("%s.%s.sql%s", db.Name, now.Format("20060102150405"), extName)
filename := fmt.Sprintf("%s-%s.%s.sql%s", db.Name, dbName, now.Format("20060102150405"), extName)
rc.Header("Content-Type", "application/octet-stream")
rc.Header("Content-Disposition", "attachment; filename="+filename)
if extName != ".gz" {
rc.Header("Content-Encoding", "gzip")
}
var dbNames, tables []string
if len(dbNamesStr) > 0 {
dbNames = strings.Split(dbNamesStr, ",")
}
if len(dbNames) == 1 && len(tablesStr) > 0 {
var tables []string
if len(tablesStr) > 0 {
tables = strings.Split(tablesStr, ",")
}
writer := newGzipWriter(rc.GetWriter())
defer func() {
msg := anyx.ToString(recover())
if len(msg) > 0 {
msg = "数据库导出失败: " + msg
writer.WriteString(msg)
rc.GetWriter().Write([]byte(msg))
d.MsgApp.CreateAndSend(la, msgdto.ErrSysMsg("数据库导出失败", msg))
}
writer.Close()
}()
for _, dbName := range dbNames {
d.dumpDb(rc.MetaCtx, writer, dbId, dbName, tables, needStruct, needData)
}
biz.ErrIsNil(d.DbApp.DumpDb(rc.MetaCtx, &application.DumpDbReq{
DbId: dbId,
DbName: dbName,
Tables: tables,
DumpDDL: needStruct,
DumpData: needData,
Writer: rc.GetWriter(),
}))
rc.ReqParam = collx.Kvs("db", db, "databases", dbNamesStr, "tables", tablesStr, "dumpType", dumpType)
}
func (d *Db) dumpDb(ctx context.Context, writer *gzipWriter, dbId uint64, dbName string, tables []string, needStruct bool, needData bool) {
dbConn, err := d.DbApp.GetDbConn(dbId, dbName)
biz.ErrIsNil(err)
writer.WriteString("\n-- ----------------------------")
writer.WriteString("\n-- 导出平台: mayfly-go")
writer.WriteString(fmt.Sprintf("\n-- 导出时间: %s ", time.Now().Format("2006-01-02 15:04:05")))
writer.WriteString(fmt.Sprintf("\n-- 导出数据库: %s ", dbName))
writer.WriteString("\n-- ----------------------------\n\n")
writer.WriteString(dbConn.Info.Type.StmtUseDatabase(dbName))
writer.WriteString(dbConn.Info.Type.StmtSetForeignKeyChecks(false))
dbMeta := dbConn.GetDialect()
if len(tables) == 0 {
ti, err := dbMeta.GetTables()
biz.ErrIsNil(err)
tables = make([]string, len(ti))
for i, table := range ti {
tables[i] = table.TableName
}
}
for _, table := range tables {
writer.TryFlush()
quotedTable := dbConn.Info.Type.QuoteIdentifier(table)
if needStruct {
writer.WriteString(fmt.Sprintf("\n-- ----------------------------\n-- 表结构: %s \n-- ----------------------------\n", table))
writer.WriteString(fmt.Sprintf("DROP TABLE IF EXISTS %s;\n", quotedTable))
ddl, err := dbMeta.GetTableDDL(table)
biz.ErrIsNil(err)
writer.WriteString(ddl + "\n")
}
if !needData {
continue
}
writer.WriteString(fmt.Sprintf("\n-- ----------------------------\n-- 表记录: %s \n-- ----------------------------\n", table))
// 达梦不支持begin语句
if dbConn.Info.Type != dbi.DbTypeDM {
writer.WriteString("BEGIN;\n")
}
insertSql := "INSERT INTO %s VALUES (%s);\n"
dbConn.WalkTableRows(ctx, table, func(record map[string]any, columns []*dbi.QueryColumn) error {
var values []string
writer.TryFlush()
for _, column := range columns {
value := record[column.Name]
if value == nil {
values = append(values, "NULL")
continue
}
strValue, ok := value.(string)
if ok {
strValue = dbConn.Info.Type.QuoteLiteral(strValue)
values = append(values, strValue)
} else {
values = append(values, anyx.ToString(value))
}
}
writer.WriteString(fmt.Sprintf(insertSql, quotedTable, strings.Join(values, ", ")))
return nil
})
writer.WriteString("COMMIT;\n")
}
writer.WriteString(dbConn.Info.Type.StmtSetForeignKeyChecks(true))
rc.ReqParam = collx.Kvs("db", db, "database", dbName, "tables", tablesStr, "dumpType", dumpType)
}
func (d *Db) TableInfos(rc *req.Ctx) {
res, err := d.getDbConn(rc).GetDialect().GetTables()
res, err := d.getDbConn(rc).GetMetaData().GetTables()
biz.ErrIsNilAppendErr(err, "获取表信息失败: %s")
rc.ResData = res
}
@@ -393,7 +325,7 @@ func (d *Db) TableInfos(rc *req.Ctx) {
func (d *Db) TableIndex(rc *req.Ctx) {
tn := rc.Query("tableName")
biz.NotEmpty(tn, "tableName不能为空")
res, err := d.getDbConn(rc).GetDialect().GetTableIndex(tn)
res, err := d.getDbConn(rc).GetMetaData().GetTableIndex(tn)
biz.ErrIsNilAppendErr(err, "获取表索引信息失败: %s")
rc.ResData = res
}
@@ -404,7 +336,7 @@ func (d *Db) ColumnMA(rc *req.Ctx) {
biz.NotEmpty(tn, "tableName不能为空")
dbi := d.getDbConn(rc)
res, err := dbi.GetDialect().GetColumns(tn)
res, err := dbi.GetMetaData().GetColumns(tn)
biz.ErrIsNilAppendErr(err, "获取数据库列信息失败: %s")
rc.ResData = res
}
@@ -413,9 +345,9 @@ func (d *Db) ColumnMA(rc *req.Ctx) {
func (d *Db) HintTables(rc *req.Ctx) {
dbi := d.getDbConn(rc)
dm := dbi.GetDialect()
metadata := dbi.GetMetaData()
// 获取所有表
tables, err := dm.GetTables()
tables, err := metadata.GetTables()
biz.ErrIsNil(err)
tableNames := make([]string, 0)
for _, v := range tables {
@@ -431,7 +363,7 @@ func (d *Db) HintTables(rc *req.Ctx) {
}
// 获取所有表下的所有列信息
columnMds, err := dm.GetColumns(tableNames...)
columnMds, err := metadata.GetColumns(tableNames...)
biz.ErrIsNil(err)
for _, v := range columnMds {
tName := v.TableName
@@ -439,7 +371,7 @@ func (d *Db) HintTables(rc *req.Ctx) {
res[tName] = make([]string, 0)
}
columnName := fmt.Sprintf("%s [%s]", v.ColumnName, v.ColumnType)
columnName := fmt.Sprintf("%s [%s]", v.ColumnName, v.GetColumnType())
comment := v.ColumnComment
// 如果字段备注不为空,则加上备注信息
if comment != "" {
@@ -454,13 +386,13 @@ func (d *Db) HintTables(rc *req.Ctx) {
func (d *Db) GetTableDDL(rc *req.Ctx) {
tn := rc.Query("tableName")
biz.NotEmpty(tn, "tableName不能为空")
res, err := d.getDbConn(rc).GetDialect().GetTableDDL(tn)
res, err := d.getDbConn(rc).GetMetaData().GetTableDDL(tn, false)
biz.ErrIsNilAppendErr(err, "获取表ddl失败: %s")
rc.ResData = res
}
func (d *Db) GetSchemas(rc *req.Ctx) {
res, err := d.getDbConn(rc).GetDialect().GetSchemas()
res, err := d.getDbConn(rc).GetMetaData().GetSchemas()
biz.ErrIsNilAppendErr(err, "获取schemas失败: %s")
rc.ResData = res
}

View File

@@ -66,7 +66,7 @@ func (d *DataSyncTask) ChangeStatus(rc *req.Ctx) {
if task.Status == entity.DataSyncTaskStatusEnable {
task, err := d.DataSyncTaskApp.GetById(new(entity.DataSyncTask), task.Id)
biz.ErrIsNil(err, "该任务不存在")
d.DataSyncTaskApp.AddCronJob(task)
d.DataSyncTaskApp.AddCronJob(rc.MetaCtx, task)
} else {
d.DataSyncTaskApp.RemoveCronJobById(task.Id)
}
@@ -75,13 +75,13 @@ func (d *DataSyncTask) ChangeStatus(rc *req.Ctx) {
}
func (d *DataSyncTask) Run(rc *req.Ctx) {
taskId := getTaskId(rc)
taskId := d.getTaskId(rc)
rc.ReqParam = taskId
_ = d.DataSyncTaskApp.RunCronJob(taskId)
_ = d.DataSyncTaskApp.RunCronJob(rc.MetaCtx, taskId)
}
func (d *DataSyncTask) Stop(rc *req.Ctx) {
taskId := getTaskId(rc)
taskId := d.getTaskId(rc)
rc.ReqParam = taskId
task := new(entity.DataSyncTask)
@@ -91,12 +91,12 @@ func (d *DataSyncTask) Stop(rc *req.Ctx) {
}
func (d *DataSyncTask) GetTask(rc *req.Ctx) {
taskId := getTaskId(rc)
taskId := d.getTaskId(rc)
dbEntity, _ := d.DataSyncTaskApp.GetById(new(entity.DataSyncTask), taskId)
rc.ResData = dbEntity
}
func getTaskId(rc *req.Ctx) uint64 {
func (d *DataSyncTask) getTaskId(rc *req.Ctx) uint64 {
instanceId := rc.PathParamInt("taskId")
biz.IsTrue(instanceId > 0, "instanceId 错误")
return uint64(instanceId)

View File

@@ -1,28 +1,41 @@
package api
import (
"mayfly-go/internal/common/consts"
"mayfly-go/internal/db/api/form"
"mayfly-go/internal/db/api/vo"
"mayfly-go/internal/db/application"
"mayfly-go/internal/db/domain/entity"
tagapp "mayfly-go/internal/tag/application"
tagentity "mayfly-go/internal/tag/domain/entity"
"mayfly-go/pkg/biz"
"mayfly-go/pkg/req"
"mayfly-go/pkg/utils/cryptox"
"mayfly-go/pkg/utils/collx"
"strconv"
"strings"
)
type Instance struct {
InstanceApp application.Instance `inject:"DbInstanceApp"`
DbApp application.Db `inject:""`
InstanceApp application.Instance `inject:"DbInstanceApp"`
DbApp application.Db `inject:""`
ResourceAuthCertApp tagapp.ResourceAuthCert `inject:""`
}
// Instances 获取数据库实例信息
// @router /api/instances [get]
func (d *Instance) Instances(rc *req.Ctx) {
queryCond, page := req.BindQueryAndPage[*entity.InstanceQuery](rc, new(entity.InstanceQuery))
res, err := d.InstanceApp.GetPageList(queryCond, page, new([]vo.InstanceListVO))
var instvos []*vo.InstanceListVO
res, err := d.InstanceApp.GetPageList(queryCond, page, &instvos)
biz.ErrIsNil(err)
// 填充授权凭证信息
d.ResourceAuthCertApp.FillAuthCert(consts.ResourceTypeDb, collx.ArrayMap(instvos, func(vos *vo.InstanceListVO) tagentity.IAuthCert {
return vos
})...)
rc.ResData = res
}
@@ -30,12 +43,7 @@ func (d *Instance) TestConn(rc *req.Ctx) {
form := &form.InstanceForm{}
instance := req.BindJsonAndCopyTo[*entity.DbInstance](rc, form, new(entity.DbInstance))
// 密码解密,并使用解密后的赋值
originPwd, err := cryptox.DefaultRsaDecrypt(form.Password, true)
biz.ErrIsNilAppendErr(err, "解密密码错误: %s")
instance.Password = originPwd
biz.ErrIsNil(d.InstanceApp.TestConn(instance))
biz.ErrIsNil(d.InstanceApp.TestConn(instance, form.AuthCerts[0]))
}
// SaveInstance 保存数据库实例信息
@@ -44,15 +52,11 @@ func (d *Instance) SaveInstance(rc *req.Ctx) {
form := &form.InstanceForm{}
instance := req.BindJsonAndCopyTo[*entity.DbInstance](rc, form, new(entity.DbInstance))
// 密码解密,并使用解密后的赋值
originPwd, err := cryptox.DefaultRsaDecrypt(form.Password, true)
biz.ErrIsNilAppendErr(err, "解密密码错误: %s")
instance.Password = originPwd
// 密码脱敏记录日志
form.Password = "****"
rc.ReqParam = form
biz.ErrIsNil(d.InstanceApp.Save(rc.MetaCtx, instance))
biz.ErrIsNil(d.InstanceApp.SaveDbInstance(rc.MetaCtx, &application.SaveDbInstanceParam{
DbInstance: instance,
AuthCerts: form.AuthCerts,
}))
}
// GetInstance 获取数据库实例密码,由于数据库是加密存储,故提供该接口展示原文密码
@@ -61,20 +65,9 @@ func (d *Instance) GetInstance(rc *req.Ctx) {
dbId := getInstanceId(rc)
dbEntity, err := d.InstanceApp.GetById(new(entity.DbInstance), dbId)
biz.ErrIsNil(err, "获取数据库实例错误")
dbEntity.Password = ""
rc.ResData = dbEntity
}
// GetInstancePwd 获取数据库实例密码,由于数据库是加密存储,故提供该接口展示原文密码
// @router /api/instances/:instance/pwd [GET]
func (d *Instance) GetInstancePwd(rc *req.Ctx) {
instanceId := getInstanceId(rc)
instanceEntity, err := d.InstanceApp.GetById(new(entity.DbInstance), instanceId, "Password")
biz.ErrIsNil(err, "获取数据库实例错误")
biz.ErrIsNil(instanceEntity.PwdDecrypt())
rc.ResData = instanceEntity.Password
}
// DeleteInstance 删除数据库实例信息
// @router /api/instances/:instance [DELETE]
func (d *Instance) DeleteInstance(rc *req.Ctx) {
@@ -94,10 +87,13 @@ func (d *Instance) DeleteInstance(rc *req.Ctx) {
// 获取数据库实例的所有数据库名
func (d *Instance) GetDatabaseNames(rc *req.Ctx) {
instanceId := getInstanceId(rc)
instance, err := d.InstanceApp.GetById(new(entity.DbInstance), instanceId, "Password")
authCertName := rc.Query("authCertName")
biz.NotEmpty(authCertName, "授权凭证名不能为空")
instance, err := d.InstanceApp.GetById(new(entity.DbInstance), instanceId)
biz.ErrIsNil(err, "获取数据库实例错误")
biz.ErrIsNil(instance.PwdDecrypt())
res, err := d.InstanceApp.GetDatabases(instance)
res, err := d.InstanceApp.GetDatabases(instance, authCertName)
biz.ErrIsNil(err)
rc.ResData = res
}
@@ -107,7 +103,7 @@ func (d *Instance) GetDbServer(rc *req.Ctx) {
instanceId := getInstanceId(rc)
conn, err := d.DbApp.GetDbConnByInstanceId(instanceId)
biz.ErrIsNil(err)
res, err := conn.GetDialect().GetDbServer()
res, err := conn.GetMetaData().GetDbServer()
biz.ErrIsNil(err)
rc.ResData = res
}

View File

@@ -6,7 +6,9 @@ import (
"mayfly-go/pkg/biz"
"mayfly-go/pkg/req"
"mayfly-go/pkg/utils/collx"
"mayfly-go/pkg/utils/conv"
"github.com/may-fly/cast"
"strings"
)
@@ -19,7 +21,7 @@ func (d *DbSqlExec) DbSqlExecs(rc *req.Ctx) {
if statusStr := rc.Query("status"); statusStr != "" {
queryCond.Status = collx.ArrayMap[string, int8](strings.Split(statusStr, ","), func(val string) int8 {
return int8(conv.Str2Int(val, 0))
return cast.ToInt8(val)
})
}
res, err := d.DbSqlExecApp.GetPageList(queryCond, page, new([]entity.DbSqlExec))

View File

@@ -0,0 +1,54 @@
package api
import (
"mayfly-go/internal/db/api/form"
"mayfly-go/internal/db/api/vo"
"mayfly-go/internal/db/application"
"mayfly-go/internal/db/domain/entity"
"mayfly-go/pkg/biz"
"mayfly-go/pkg/req"
"strconv"
"strings"
)
type DbTransferTask struct {
DbTransferTask application.DbTransferTask `inject:"DbTransferTaskApp"`
}
func (d *DbTransferTask) Tasks(rc *req.Ctx) {
queryCond, page := req.BindQueryAndPage[*entity.DbTransferTaskQuery](rc, new(entity.DbTransferTaskQuery))
res, err := d.DbTransferTask.GetPageList(queryCond, page, new([]vo.DbTransferTaskListVO))
biz.ErrIsNil(err)
rc.ResData = res
}
func (d *DbTransferTask) SaveTask(rc *req.Ctx) {
reqForm := &form.DbTransferTaskForm{}
task := req.BindJsonAndCopyTo[*entity.DbTransferTask](rc, reqForm, new(entity.DbTransferTask))
rc.ReqParam = reqForm
biz.ErrIsNil(d.DbTransferTask.Save(rc.MetaCtx, task))
}
func (d *DbTransferTask) DeleteTask(rc *req.Ctx) {
taskId := rc.PathParam("taskId")
rc.ReqParam = taskId
ids := strings.Split(taskId, ",")
for _, v := range ids {
value, err := strconv.Atoi(v)
biz.ErrIsNilAppendErr(err, "string类型转换为int异常: %s")
biz.ErrIsNil(d.DbTransferTask.Delete(rc.MetaCtx, uint64(value)))
}
}
func (d *DbTransferTask) Run(rc *req.Ctx) {
taskId := uint64(rc.PathParamInt("taskId"))
logId, _ := d.DbTransferTask.CreateLog(rc.MetaCtx, taskId)
go d.DbTransferTask.Run(rc.MetaCtx, taskId, logId)
rc.ResData = logId
}
func (d *DbTransferTask) Stop(rc *req.Ctx) {
biz.ErrIsNil(d.DbTransferTask.Stop(rc.MetaCtx, uint64(rc.PathParamInt("taskId"))))
}

View File

@@ -2,6 +2,7 @@ package form
type DbForm struct {
Id uint64 `json:"id"`
Code string `binding:"required" json:"code"`
Name string `binding:"required" json:"name"`
Database string `json:"database"`
Remark string `json:"remark"`

View File

@@ -0,0 +1,19 @@
package form
type DbTransferTaskForm struct {
Id uint64 `json:"id"`
CheckedKeys string `binding:"required" json:"checkedKeys"` // 选中需要迁移的表
DeleteTable int `binding:"required" json:"deleteTable"` // 创建表前是否删除表 1是 2否
NameCase int `binding:"required" json:"nameCase"` // 表名、字段大小写转换 1无 2大写 3小写
Strategy int `binding:"required" json:"strategy"` // 迁移策略 1全量 2增量
SrcDbId int `binding:"required" json:"srcDbId"` // 源库id
SrcDbName string `binding:"required" json:"srcDbName"` // 源库名
SrcDbType string `binding:"required" json:"srcDbType"` // 源库类型
SrcInstName string `binding:"required" json:"srcInstName"` // 源库实例名
SrcTagPath string `binding:"required" json:"srcTagPath"` // 源库tagPath
TargetDbId int `binding:"required" json:"targetDbId"` // 目标库id
TargetDbName string `binding:"required" json:"targetDbName"` // 目标库名
TargetDbType string `binding:"required" json:"targetDbType"` // 目标库类型
TargetInstName string `binding:"required" json:"targetInstName"` // 目标库实例名
TargetTagPath string `binding:"required" json:"targetTagPath"` // 目标库tagPath
}

View File

@@ -1,15 +1,18 @@
package form
import tagentity "mayfly-go/internal/tag/domain/entity"
type InstanceForm struct {
Id uint64 `json:"id"`
Code string `binding:"pattern=resource_code" json:"code"`
Name string `binding:"required" json:"name"`
Type string `binding:"required" json:"type"` // 类型mysql oracle等
Host string `binding:"required" json:"host"`
Port int `json:"port"`
Sid string `json:"sid"`
Username string `json:"username"`
Password string `json:"password"`
Extra string `json:"extra"`
Params string `json:"params"`
Remark string `json:"remark"`
SshTunnelMachineId int `json:"sshTunnelMachineId"`
AuthCerts []*tagentity.ResourceAuthCert `json:"authCerts" binding:"required"` // 资产授权凭证信息列表
}

View File

@@ -15,11 +15,12 @@ type DbListVO struct {
Remark *string `json:"remark"`
InstanceId *int64 `json:"instanceId"`
AuthCertName string `json:"authCertName"`
Username string `json:"username"`
InstanceName *string `json:"instanceName"`
InstanceType *string `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
FlowProcdefKey string `json:"flowProcdefKey"`

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