🔨 重构主机模块.

This commit is contained in:
lijiahang
2024-07-22 19:36:02 +08:00
parent b7608fccb3
commit 4bd2de4ce2
64 changed files with 1062 additions and 603 deletions

View File

@@ -1,5 +1,5 @@
import type { HostGroupQueryResponse } from '@/api/asset/host-group';
import type { HostQueryResponse } from './host';
import type { HostQueryResponse, HostType } from './host';
import type { HostKeyQueryResponse } from './host-key';
import type { HostIdentityQueryResponse } from './host-identity';
import axios from 'axios';
@@ -17,7 +17,7 @@ export interface AuthorizedHostQueryResponse {
/**
* 查询当前用户已授权的主机
*/
export function getCurrentAuthorizedHost(type: string) {
export function getCurrentAuthorizedHost(type: HostType) {
return axios.get<AuthorizedHostQueryResponse>('/asset/authorized-data/current-host', { params: { type } });
}

View File

@@ -1,52 +0,0 @@
import axios from 'axios';
/**
* 主机配置请求
*/
export interface HostConfigRequest {
hostId?: number;
type?: string;
version?: number;
status?: number;
config?: string;
}
/**
* 主机配置查询响应
*/
export interface HostConfigQueryResponse {
id: number;
hostId: number;
type: string;
version: number;
status: number;
config: Record<string, any>;
}
/**
* 查询主机配置
*/
export function getHostConfig(params: HostConfigRequest) {
return axios.get<HostConfigQueryResponse>('/asset/host-config/get', { params });
}
/**
* 查询全部主机配置
*/
export function getHostConfigList(hostId: number) {
return axios.get<Array<HostConfigQueryResponse>>('/asset/host-config/list', { params: { hostId } });
}
/**
* 更新主机配置
*/
export function updateHostConfig(request: HostConfigRequest) {
return axios.put('/asset/host-config/update', request);
}
/**
* 更新主机配置状态
*/
export function updateHostConfigStatus(request: HostConfigRequest) {
return axios.put('/asset/host-config/update-status', request);
}

View File

@@ -3,13 +3,18 @@ import type { TableData } from '@arco-design/web-vue/es/table/interface';
import axios from 'axios';
import qs from 'query-string';
// 主机类型
export type HostType = 'SSH' | string | undefined;
/**
* 主机创建请求
*/
export interface HostCreateRequest {
type?: string;
name?: string;
code?: string;
address?: string;
port?: number;
tags?: Array<number>;
groupIdList?: Array<number>;
}
@@ -21,15 +26,33 @@ export interface HostUpdateRequest extends HostCreateRequest {
id?: number;
}
/**
* 主机更新状态请求
*/
export interface HostUpdateStatusRequest {
id: number;
status: string;
}
/**
* 主机更新配置请求
*/
export interface HostUpdateConfigRequest {
id: number;
config: string;
}
/**
* 主机查询请求
*/
export interface HostQueryRequest extends Pagination {
searchValue?: string;
id?: number;
type?: string;
name?: string;
code?: string;
address?: string;
status?: string;
tags?: Array<number>;
queryTag?: boolean;
}
@@ -39,9 +62,12 @@ export interface HostQueryRequest extends Pagination {
*/
export interface HostQueryResponse extends TableData, HostQueryResponseExtra {
id: number;
type: string;
name: string;
code: string;
address: string;
port: string;
status: string;
createTime: number;
updateTime: number;
creator: string;
@@ -62,6 +88,22 @@ export interface HostQueryResponseExtra {
modCount: number;
}
/**
* 主机 配置查询响应
*/
export interface HostConfigQueryResponse extends HostConfigQueryResponseExtra {
id: number;
type: string;
config: Record<string, any>;
}
/**
* 主机配置拓展
*/
export interface HostConfigQueryResponseExtra {
current: number;
}
/**
* 创建主机
*/
@@ -77,7 +119,21 @@ export function updateHost(request: HostUpdateRequest) {
}
/**
* 通过 id 查询主机
* 通过 id 更新主机状态
*/
export function updateHostStatus(request: HostUpdateStatusRequest) {
return axios.put('/asset/host/update-status', request);
}
/**
* 通过 id 更新主机配置
*/
export function updateHostConfig(request: HostUpdateConfigRequest) {
return axios.put('/asset/host/update-config', request);
}
/**
* 查询主机
*/
export function getHost(id: number) {
return axios.get<HostQueryResponse>('/asset/host/get', { params: { id } });
@@ -86,8 +142,15 @@ export function getHost(id: number) {
/**
* 查询全部主机
*/
export function getHostList() {
return axios.get<Array<HostQueryResponse>>('/asset/host/list');
export function getHostList(type: HostType) {
return axios.get<Array<HostQueryResponse>>('/asset/host/list', { params: { type } });
}
/**
* 通过 id 查询主机配置
*/
export function getHostConfig(id: number) {
return axios.get<HostConfigQueryResponse>('/asset/host/get-config', { params: { id } });
}
/**

View File

@@ -30,12 +30,20 @@
border-radius: 50%;
background-color: rgb(var(--blue-6));
&.normal {
color: rgb(var(--arcoblue-6));
}
&.pass {
background-color: rgb(var(--green-6));
color: rgb(var(--green-6));
}
&.warn {
color: rgb(var(--orange-6));
}
&.error {
background-color: rgb(var(--red-6));
color: rgb(var(--red-6));
}
}
}

View File

@@ -67,7 +67,7 @@
}
// -- drawer
.drawer-form-small{
.drawer-form-small {
padding: 20px 20px 2px 20px;
}
@@ -185,3 +185,31 @@
background: var(--color-bg-2);
border-radius: 4px;
}
// -- doption
.more-doption {
min-width: 42px;
padding: 0 4px;
font-size: 12px;
display: inline-block;
svg {
margin-right: 2px;
}
&.normal {
color: rgb(var(--arcoblue-6));
}
&.pass {
color: rgb(var(--green-6));
}
&.warn {
color: rgb(var(--orange-6));
}
&.error {
color: rgb(var(--red-6));
}
}

View File

@@ -18,7 +18,7 @@
<host-table class="host-list"
v-model:selected-keys="selectedKeysValue"
:host-list="hostList"
empty-message="当前分组内无授权主机/主机未启用 SSH 配置!" />
empty-message="当前分组内无授权主机!" />
</div>
</template>

View File

@@ -79,6 +79,7 @@
import type { SelectOptionData } from '@arco-design/web-vue';
import type { AuthorizedHostQueryResponse } from '@/api/asset/asset-authorized-data';
import type { HostQueryResponse } from '@/api/asset/host';
import type { HostType } from '@/api/asset/host';
import { onMounted, ref, watch, computed } from 'vue';
import { dataColor } from '@/utils';
import { dictKeys, NewConnectionType, newConnectionTypeKey } from './types/const';
@@ -92,6 +93,12 @@
import HostTable from './components/host-table.vue';
import HostGroup from './components/host-group.vue';
const props = withDefaults(defineProps<Partial<{
type: HostType;
}>>(), {
type: undefined,
});
const emits = defineEmits(['selected']);
const { toRadioOptions, loadKeys } = useDictStore();
@@ -110,10 +117,10 @@
const emptyMessage = computed(() => {
if (newConnectionType.value === NewConnectionType.LIST) {
// 列表
return '无授权主机/主机未启用 SSH 配置!';
return '无授权主机!';
} else if (newConnectionType.value === NewConnectionType.FAVORITE) {
// 收藏
return '无收藏主机/主机未启用 SSH 配置!';
return '无收藏主机!';
} else if (newConnectionType.value === NewConnectionType.LATEST) {
// 最近连接
return '暂无连接记录!';
@@ -144,7 +151,7 @@
setLoading(true);
try {
// 加载主机列表
const { data } = await getCurrentAuthorizedHost('ssh');
const { data } = await getCurrentAuthorizedHost(props.type);
hosts.value = data;
// 禁用别名
data.hostList.forEach(s => s.alias = undefined as unknown as string);

View File

@@ -2,6 +2,7 @@
<a-select v-model:model-value="value"
:options="optionData"
:loading="loading"
:multiple="multiple"
placeholder="请选择主机"
allow-clear />
</template>
@@ -14,22 +15,30 @@
<script lang="ts" setup>
import type { SelectOptionData } from '@arco-design/web-vue';
import type { HostType } from '@/api/asset/host';
import { computed, onBeforeMount, ref } from 'vue';
import { useCacheStore } from '@/store';
import useLoading from '@/hooks/loading';
const props = defineProps<Partial<{
modelValue: number;
}>>();
const props = withDefaults(defineProps<Partial<{
type: HostType;
status: string | undefined;
modelValue: number | Array<number>;
multiple: boolean;
}>>(), {
type: undefined,
status: undefined,
multiple: false,
});
const emits = defineEmits(['update:modelValue']);
const { loading, setLoading } = useLoading();
const cacheStore = useCacheStore();
const value = computed<number>({
const value = computed({
get() {
return props.modelValue as number;
return props.modelValue;
},
set(e) {
if (e) {
@@ -45,8 +54,9 @@
onBeforeMount(async () => {
setLoading(true);
try {
const hosts = await cacheStore.loadHosts();
optionData.value = hosts.map(s => {
const hosts = await cacheStore.loadHosts(props.type);
optionData.value = hosts.filter(s => !props.status || s.status === props.status)
.map(s => {
return {
label: `${s.name} - ${s.address}`,
value: s.id,

View File

@@ -1,9 +1,6 @@
import type { IDisposable, ITerminalInitOnlyOptions, ITerminalOptions, Terminal } from '@xterm/xterm';
import type { FitAddon } from '@xterm/addon-fit';
import type { SearchAddon } from '@xterm/addon-search';
import type { WebLinksAddon } from '@xterm/addon-web-links';
import type { WebglAddon } from '@xterm/addon-webgl';
import type { Unicode11Addon } from '@xterm/addon-unicode11';
import type { XtermAddons } from '@/types/xterm';
import { defaultFontFamily, defaultTheme } from '@/types/xterm';
import type { ITerminalInitOnlyOptions, ITerminalOptions, Terminal } from '@xterm/xterm';
// 执行类型
export type ExecType = 'BATCH' | 'JOB';
@@ -47,11 +44,7 @@ export const dictKeys = [execStatusKey, execHostStatusKey];
// appender 配置
export const LogAppenderOptions: ITerminalOptions & ITerminalInitOnlyOptions = {
theme: {
foreground: '#FFFFFF',
background: '#1C1C1C',
selectionBackground: '#444444',
},
theme: defaultTheme,
cols: 30,
rows: 8,
rightClickSelectsWord: true,
@@ -63,7 +56,7 @@ export const LogAppenderOptions: ITerminalOptions & ITerminalInitOnlyOptions = {
lineHeight: 1.12,
convertEol: true,
allowProposedApi: true,
fontFamily: 'Courier New, Monaco, courier, monospace',
fontFamily: defaultFontFamily,
};
// dom 引用
@@ -79,16 +72,7 @@ export interface LogAppenderConf {
el: HTMLElement;
openSearch: () => {};
terminal: Terminal;
addons: LogAddons;
}
// appender 插件
export interface LogAddons extends Record<string, IDisposable> {
fit: FitAddon;
webgl: WebglAddon;
search: SearchAddon;
weblink: WebLinksAddon;
unicode: Unicode11Addon;
addons: XtermAddons;
}
// 执行日志 appender 定义

View File

@@ -1,5 +1,6 @@
import type { ExecType, ILogAppender, LogAddons, LogAppenderConf, LogDomRef } from '../const';
import type { ExecType, ILogAppender, LogAppenderConf, LogDomRef } from '../const';
import { LogAppenderOptions } from '../const';
import type { XtermAddons } from '@/types/xterm';
import type { ExecLogTailRequest } from '@/api/exec/exec-log';
import { openExecLogChannel } from '@/api/exec/exec-log';
import { getExecCommandLogTailToken } from '@/api/exec/exec-command-log';
@@ -43,13 +44,13 @@ export default class LogAppender implements ILogAppender {
// 初始化
async init(logDomRefs: Array<LogDomRef>) {
// 初始化 appender
this.initAppender(logDomRefs);
await this.initAppender(logDomRefs);
// 初始化 client
await this.openClient();
}
// 初始化 appender
initAppender(logDomRefs: Array<LogDomRef>) {
async initAppender(logDomRefs: Array<LogDomRef>) {
// 打开 log-view
for (let logDomRef of logDomRefs) {
// 初始化 terminal
@@ -126,7 +127,7 @@ export default class LogAppender implements ILogAppender {
}
// 初始化插件
initAddons(terminal: Terminal): LogAddons {
initAddons(terminal: Terminal): XtermAddons {
const fit = new FitAddon();
const search = new SearchAddon();
const webgl = new WebglAddon();
@@ -144,7 +145,7 @@ export default class LogAppender implements ILogAppender {
webgl,
weblink,
unicode
};
} as XtermAddons;
}
// 初始化 client

View File

@@ -89,8 +89,8 @@
import useEmitter from '@/hooks/emitter';
const props = defineProps<CardProps & {
index: number,
item: CardRecord,
index: number;
item: CardRecord;
}>();
const emits = defineEmits(['emitter']);

View File

@@ -40,15 +40,15 @@ export const builtinParams: Array<TemplateParam> = [
}, {
name: 'hostAddress',
desc: '执行主机地址'
}, {
name: 'hostPort',
desc: '主机端口'
}, {
name: 'hostUsername',
desc: '执行主机用户名'
}, {
name: 'osType',
desc: '执行主机系统版本'
}, {
name: 'port',
desc: 'SSH 端口'
}, {
name: 'charset',
desc: 'SSH 编码集'

View File

@@ -2,6 +2,8 @@ import type { CacheState } from './types';
import type { AxiosResponse } from 'axios';
import type { TagType } from '@/api/meta/tag';
import { getTagList } from '@/api/meta/tag';
import type { HostType } from '@/api/asset/host';
import { getHostList } from '@/api/asset/host';
import type { PreferenceType } from '@/api/user/preference';
import { getPreference } from '@/api/user/preference';
import { defineStore } from 'pinia';
@@ -10,7 +12,6 @@ import { getRoleList } from '@/api/user/role';
import { getDictKeyList } from '@/api/system/dict-key';
import { getHostKeyList } from '@/api/asset/host-key';
import { getHostIdentityList } from '@/api/asset/host-identity';
import { getHostList } from '@/api/asset/host';
import { getHostGroupTree } from '@/api/asset/host-group';
import { getMenuList } from '@/api/system/menu';
import { getCurrentAuthorizedHostIdentity, getCurrentAuthorizedHostKey } from '@/api/asset/asset-authorized-data';
@@ -19,7 +20,7 @@ import { getExecJobList } from '@/api/job/exec-job';
import { getPathBookmarkGroupList } from '@/api/asset/path-bookmark-group';
export type CacheType = 'users' | 'menus' | 'roles'
| 'hosts' | 'hostGroups' | 'hostKeys' | 'hostIdentities'
| 'hostGroups' | 'hostKeys' | 'hostIdentities'
| 'dictKeys'
| 'authorizedHostKeys' | 'authorizedHostIdentities'
| 'commandSnippetGroups' | 'pathBookmarkGroups'
@@ -84,8 +85,8 @@ export default defineStore('cache', {
},
// 获取主机列表
async loadHosts(force = false) {
return await this.load('hosts', getHostList, force);
async loadHosts(type: HostType, force = false) {
return await this.load(`host_${type}`, () => getHostList(type), force);
},
// 获取主机密钥列表

View File

@@ -128,7 +128,7 @@ export default defineStore('terminal', {
if (this.hosts.hostList?.length) {
return;
}
const { data } = await getCurrentAuthorizedHost('ssh');
const { data } = await getCurrentAuthorizedHost('SSH');
Object.keys(data).forEach(k => {
this.hosts[k as keyof AuthorizedHostQueryResponse] = data[k as keyof AuthorizedHostQueryResponse] as any;
});

View File

@@ -98,8 +98,8 @@ export const useColLayout = (): ColResponsiveValue => {
sm: 12,
md: 8,
lg: 8,
xl: 6,
xxl: 4,
xl: 8,
xxl: 6,
};
};

View File

@@ -0,0 +1,242 @@
// 终端协议
export interface Protocol {
type: string;
template: string[];
[key: string]: unknown;
}
// 终端输入消息内容
export interface InputPayload {
type?: string;
sessionId?: string;
[key: string]: unknown;
}
// 终端输出消息内容
export interface OutputPayload {
type: string;
sessionId: string;
[key: string]: string;
}
// 分隔符
export const SEPARATOR = '|';
// 输入协议
export const InputProtocol = {
// 主机连接检查
CHECK: {
type: 'ck',
template: ['type', 'sessionId', 'hostId', 'connectType']
},
// 连接主机
CONNECT: {
type: 'co',
template: ['type', 'sessionId', 'terminalType', 'cols', 'rows']
},
// 关闭连接
CLOSE: {
type: 'cl',
template: ['type', 'sessionId']
},
// ping
PING: {
type: 'p',
template: ['type']
},
// SSH 修改大小
SSH_RESIZE: {
type: 'rs',
template: ['type', 'sessionId', 'cols', 'rows']
},
// SSH 输入
SSH_INPUT: {
type: 'i',
template: ['type', 'sessionId', 'command']
},
// SFTP 文件列表
SFTP_LIST: {
type: 'ls',
template: ['type', 'sessionId', 'showHiddenFile', 'path']
},
// SFTP 创建文件夹
SFTP_MKDIR: {
type: 'mk',
template: ['type', 'sessionId', 'path']
},
// SFTP 创建文件
SFTP_TOUCH: {
type: 'to',
template: ['type', 'sessionId', 'path']
},
// SFTP 移动文件
SFTP_MOVE: {
type: 'mv',
template: ['type', 'sessionId', 'path', 'target']
},
// SFTP 删除文件
SFTP_REMOVE: {
type: 'rm',
template: ['type', 'sessionId', 'path']
},
// SFTP 修改文件权限
SFTP_CHMOD: {
type: 'cm',
template: ['type', 'sessionId', 'path', 'mod']
},
// SFTP 修改文件权限
SFTP_DOWNLOAD_FLAT_DIRECTORY: {
type: 'df',
template: ['type', 'sessionId', 'currentPath', 'path']
},
// SFTP 获取内容
SFTP_GET_CONTENT: {
type: 'gc',
template: ['type', 'sessionId', 'path']
},
// SFTP 修改内容
SFTP_SET_CONTENT: {
type: 'sc',
template: ['type', 'sessionId', 'path', 'content']
},
};
// 输出协议
export const OutputProtocol = {
// 主机连接检查
CHECK: {
type: 'ck',
template: ['type', 'sessionId', 'result', 'msg'],
processMethod: 'processCheck'
},
// 主机连接
CONNECT: {
type: 'co',
template: ['type', 'sessionId', 'result', 'msg'],
processMethod: 'processConnect'
},
// 主机连接关闭
CLOSE: {
type: 'cl',
template: ['type', 'sessionId', 'forceClose', 'msg'],
processMethod: 'processClose'
},
// pong
PONG: {
type: 'p',
template: ['type'],
processMethod: 'processPong'
},
// SSH 输出
SSH_OUTPUT: {
type: 'o',
template: ['type', 'sessionId', 'body'],
processMethod: 'processSshOutput'
},
// SFTP 文件列表
SFTP_LIST: {
type: 'ls',
template: ['type', 'sessionId', 'path', 'result', 'msg', 'body'],
processMethod: 'processSftpList'
},
// SFTP 创建文件夹
SFTP_MKDIR: {
type: 'mk',
template: ['type', 'sessionId', 'result', 'msg'],
processMethod: 'processSftpMkdir'
},
// SFTP 创建文件
SFTP_TOUCH: {
type: 'to',
template: ['type', 'sessionId', 'result', 'msg'],
processMethod: 'processSftpTouch'
},
// SFTP 移动文件
SFTP_MOVE: {
type: 'mv',
template: ['type', 'sessionId', 'result', 'msg'],
processMethod: 'processSftpMove'
},
// SFTP 删除文件
SFTP_REMOVE: {
type: 'rm',
template: ['type', 'sessionId', 'result', 'msg'],
processMethod: 'processSftpRemove'
},
// SFTP 修改文件权限
SFTP_CHMOD: {
type: 'cm',
template: ['type', 'sessionId', 'result', 'msg'],
processMethod: 'processSftpChmod'
},
// SFTP 修改文件权限
SFTP_DOWNLOAD_FLAT_DIRECTORY: {
type: 'df',
template: ['type', 'sessionId', 'currentPath', 'result', 'msg', 'body'],
processMethod: 'processDownloadFlatDirectory'
},
// SFTP 获取文件内容
SFTP_GET_CONTENT: {
type: 'gc',
template: ['type', 'sessionId', 'path', 'result', 'msg', 'content'],
processMethod: 'processSftpGetContent'
},
// SFTP 修改文件内容
SFTP_SET_CONTENT: {
type: 'sc',
template: ['type', 'sessionId', 'result', 'msg'],
processMethod: 'processSftpSetContent'
},
};
// 解析参数
export const parse = (payload: string) => {
const protocols = Object.values(OutputProtocol);
const useProtocol = protocols.find(p => payload.startsWith(p.type + SEPARATOR) || p.type === payload);
if (!useProtocol) {
return undefined;
}
const template = useProtocol.template;
const res = {} as OutputPayload;
let curr = 0;
let len = payload.length;
for (let i = 0, pl = template.length; i < pl; i++) {
if (i == pl - 1) {
// 最后一次
res[template[i]] = payload.substring(curr, len);
} else {
// 非最后一次
let tmp = '';
for (; curr < len; curr++) {
const c = payload.charAt(curr);
if (c == SEPARATOR) {
res[template[i]] = tmp;
curr++;
break;
} else {
tmp += c;
}
}
}
}
return res;
};
// 格式化参数
export const format = (protocol: Protocol, payload: InputPayload | OutputPayload) => {
payload.type = protocol.type;
return protocol.template
.map(i => getPayloadValueString(payload[i]))
.join(SEPARATOR);
};
// 获取默认值
export const getPayloadValueString = (value: unknown): any => {
if (value === undefined || value === null) {
return '';
}
return value;
};

View File

@@ -0,0 +1,29 @@
import type { ITerminalAddon } from '@xterm/xterm';
import type { FitAddon } from '@xterm/addon-fit';
import type { WebglAddon } from '@xterm/addon-webgl';
import type { CanvasAddon } from '@xterm/addon-canvas';
import type { WebLinksAddon } from '@xterm/addon-web-links';
import type { SearchAddon } from '@xterm/addon-search';
import type { ImageAddon } from '@xterm/addon-image';
import type { Unicode11Addon } from '@xterm/addon-unicode11';
// 默认字体
export const defaultFontFamily = 'Courier New, Monaco, courier, monospace';
// 默认主题
export const defaultTheme = {
foreground: '#FFFFFF',
background: '#1C1C1C',
selectionBackground: '#444444',
};
// xterm 插件
export interface XtermAddons extends Record<string, ITerminalAddon> {
fit: FitAddon;
webgl: WebglAddon;
canvas: CanvasAddon;
weblink: WebLinksAddon;
search: SearchAddon;
image: ImageAddon;
unicode: Unicode11Addon;
}

View File

@@ -41,7 +41,7 @@
// 重置缓存
onUnmounted(() => {
const cacheStore = useCacheStore();
cacheStore.reset('users', 'hosts');
cacheStore.reset('users');
});
</script>

View File

@@ -29,7 +29,7 @@
// 重置缓存
onUnmounted(() => {
const cacheStore = useCacheStore();
cacheStore.reset('users', 'hosts');
cacheStore.reset('users');
});
</script>

View File

@@ -16,6 +16,7 @@
<!-- 操作主机 -->
<a-form-item field="hostId" label="操作主机">
<host-selector v-model="formModel.hostId"
type="SSH"
placeholder="请选择主机"
allow-clear />
</a-form-item>

View File

@@ -29,7 +29,7 @@
// 重置缓存
onUnmounted(() => {
const cacheStore = useCacheStore();
cacheStore.reset('users', 'hosts');
cacheStore.reset('users');
});
</script>

View File

@@ -58,7 +58,7 @@
try {
setLoading(true);
const { data } = await getHostGroupRelList(groupId as number);
const hosts = await cacheStore.loadHosts();
const hosts = await cacheStore.loadHosts(undefined);
selectedGroupHosts.value = data.map(s => hosts.find(h => h.id === s) as HostQueryResponse)
.filter(Boolean);
} catch (e) {

View File

@@ -51,7 +51,7 @@
// 卸载时清除 cache
onUnmounted(() => {
cacheStore.reset('users', 'roles', 'hosts', 'hostGroups', 'hostKeys', 'hostIdentities');
cacheStore.reset('users', 'roles', 'hostGroups', 'hostKeys', 'hostIdentities');
});
</script>

View File

@@ -1,20 +1,25 @@
<template>
<a-card class="general-card"
:body-style="{ padding: config.status === EnabledStatus.ENABLED ? '' : '0' }">
<a-card class="general-card" :body-style="{ padding: '0 16px 0 20px'}">
<!-- 标题 -->
<template #title>
<div class="config-title-wrapper">
<span>SSH 配置</span>
<a-switch v-model="config.status"
:disabled="loading"
type="round"
:checked-value="EnabledStatus.ENABLED"
:unchecked-value="EnabledStatus.DISABLED"
:before-change="s => updateStatus(s as number)" />
<span class="title">SSH 配置</span>
<!-- 操作按钮 -->
<a-space>
<a-button size="small"
@click="emits('reset')">
重置
</a-button>
<a-button type="primary"
size="small"
@click="saveConfig">
保存
</a-button>
</a-space>
</div>
</template>
<!-- 表单 -->
<a-spin v-show="config.status" :loading="loading" class="config-form-wrapper">
<div class="config-form-wrapper full">
<!-- 表单 -->
<a-form :model="formModel"
ref="formRef"
@@ -29,14 +34,6 @@
:options="toOptions(sshOsTypeKey)"
placeholder="请选择系统类型" />
</a-form-item>
<!-- SSH 端口 -->
<a-form-item field="port"
label="SSH端口"
:hide-asterisk="true">
<a-input-number v-model="formModel.port"
placeholder="请输入SSH端口"
hide-button />
</a-form-item>
<!-- 用户名 -->
<a-form-item field="username"
label="用户名"
@@ -85,7 +82,8 @@
<host-identity-selector v-model="formModel.identityId" />
</a-form-item>
<!-- 连接超时时间 -->
<a-form-item field="connectTimeout"
<a-form-item class="mt4"
field="connectTimeout"
label="连接超时时间"
:hide-asterisk="true">
<a-input-number v-model="formModel.connectTimeout"
@@ -96,15 +94,8 @@
</template>
</a-input-number>
</a-form-item>
<!-- 其他配置 -->
<a-collapse :bordered="false">
<a-collapse-item key="1">
<template #header>
<span class="usn">其他配置</span>
</template>
<!-- SSH 输出编码 -->
<a-form-item class="mt4"
field="charset"
<a-form-item field="charset"
label="SSH输出编码"
:hide-asterisk="true">
<a-input v-model="formModel.charset" placeholder="请输入 SSH 输出编码" />
@@ -121,24 +112,8 @@
:hide-asterisk="true">
<a-input v-model="formModel.fileContentCharset" placeholder="请输入 SFTP 文件内容编码" />
</a-form-item>
</a-collapse-item>
</a-collapse>
</a-form>
<!-- 操作按钮 -->
<div class="config-button-group">
<a-space>
<a-button size="small"
@click="resetConfig">
重置
</a-button>
<a-button type="primary"
size="small"
@click="saveConfig">
保存
</a-button>
</a-space>
</div>
</a-spin>
</a-card>
</template>
@@ -151,38 +126,26 @@
<script lang="ts" setup>
import type { FieldRule } from '@arco-design/web-vue';
import type { HostSshConfig } from '../types/const';
import { reactive, ref, watch } from 'vue';
import { updateHostConfigStatus, updateHostConfig } from '@/api/asset/host-config';
import { sshAuthTypeKey, sshOsTypeKey, SshAuthType, SshOsType } from '../types/const';
import rules from '../types/ssh-form.rules';
import { Message } from '@arco-design/web-vue';
import useLoading from '@/hooks/loading';
import type { HostConfigQueryResponse } from '@/api/asset/host';
import { ref, watch } from 'vue';
import { sshAuthTypeKey, sshOsTypeKey, SshAuthType } from '../types/const';
import { useDictStore } from '@/store';
import { EnabledStatus } from '@/types/const';
import { HostConfigType } from '../types/const';
import rules from '../types/ssh-form.rules';
import HostKeySelector from '@/components/asset/host-key/selector/index.vue';
import HostIdentitySelector from '@/components/asset/host-identity/selector/index.vue';
const { loading, setLoading } = useLoading();
const { toOptions, toRadioOptions } = useDictStore();
const props = defineProps<{
content: any;
hostId: number;
hostConfig: HostConfigQueryResponse;
}>();
const emits = defineEmits(['submitted']);
const config = reactive({
status: undefined,
version: undefined,
});
const emits = defineEmits(['save', 'reset']);
const formRef = ref();
const formModel = ref<HostSshConfig>({
osType: SshOsType.LINUX,
osType: undefined,
username: undefined,
port: undefined,
password: undefined,
authType: SshAuthType.PASSWORD,
keyId: undefined,
@@ -196,10 +159,10 @@
});
// 监听数据变化
watch(() => props.content, (v: any) => {
config.status = v?.status;
config.version = v?.version;
resetConfig();
watch(() => props.hostConfig.current, () => {
formModel.value = Object.assign({}, props.hostConfig.config);
// 使用新密码默认为不包含密码
formModel.value.useNewPassword = !formModel.value.hasPassword;
});
// 用户名验证
@@ -230,77 +193,20 @@
}
}] as FieldRule[];
// 修改状态
const updateStatus = async (e: number) => {
setLoading(true);
return updateHostConfigStatus({
hostId: props?.hostId,
type: HostConfigType.SSH,
status: e,
version: config.version
}).then(({ data }) => {
config.version = data;
setLoading(false);
return true;
}).catch(() => {
setLoading(false);
return false;
});
};
// 重置配置
const resetConfig = () => {
formModel.value = Object.assign({}, props.content?.config);
// 使用新密码默认为不包含密码
formModel.value.useNewPassword = !formModel.value.hasPassword;
};
// 保存配置
const saveConfig = async () => {
try {
// 验证参数
const error = await formRef.value.validate();
if (error) {
return false;
}
setLoading(true);
// 更新
const { data } = await updateHostConfig({
hostId: props?.hostId,
type: HostConfigType.SSH,
version: config.version,
config: JSON.stringify(formModel.value)
});
config.version = data;
setLoading(false);
Message.success('修改成功');
// 回调 props
emits('submitted', { ...props.content, ...config, config: { ...formModel.value } });
} catch (e) {
} finally {
setLoading(false);
}
// 回调
emits('save', { ...formModel.value });
};
</script>
<style lang="less" scoped>
.config-title-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
}
.config-form-wrapper {
width: 100%;
}
.config-button-group {
display: flex;
align-items: center;
justify-content: flex-end;
}
.auth-type-group {
width: 100%;
display: flex;
@@ -312,13 +218,4 @@
margin-left: 8px;
}
:deep(.arco-collapse-item-content) {
background: unset;
padding: 0;
}
:deep(.arco-collapse-item-header) {
border: none;
}
</style>

View File

@@ -11,15 +11,15 @@
<!-- 标题 -->
<template #title>
<span class="host-title-text">
主机配置 <span class="host-name-title-text">{{ record.name }}</span>
主机配置 <span class="host-name-title-text">{{ record?.name }}</span>
</span>
</template>
<a-spin :loading="loading" class="host-config-container">
<!-- SSH 配置 -->
<ssh-config-form class="host-config-wrapper"
:host-id="record.id"
:content="config.ssh"
@submitted="(e) => config.ssh = e" />
:host-config="hostConfig"
@save="save"
@reset="reset" />
</a-spin>
</a-drawer>
</template>
@@ -31,14 +31,14 @@
</script>
<script lang="ts" setup>
import type { HostConfigWrapper, HostSshConfig } from '../types/const';
import type { HostConfigQueryResponse } from '@/api/asset/host';
import { ref } from 'vue';
import useVisible from '@/hooks/visible';
import useLoading from '@/hooks/loading';
import { Message } from '@arco-design/web-vue';
import { getHostConfigList } from '@/api/asset/host-config';
import { useCacheStore, useDictStore } from '@/store';
import { dictKeys } from '../types/const';
import { getHostConfig, updateHostConfig } from '@/api/asset/host';
import SshConfigForm from '../components/ssh-config-form.vue';
const { visible, setVisible } = useVisible();
@@ -46,13 +46,12 @@
const cacheStore = useCacheStore();
const record = ref({} as any);
const config = ref<HostConfigWrapper>({
ssh: {} as HostSshConfig
});
const hostConfig = ref<HostConfigQueryResponse>({} as HostConfigQueryResponse);
// 打开
const open = async (e: any) => {
record.value = { ...e };
hostConfig.value = { config: {} } as HostConfigQueryResponse;
try {
setLoading(true);
setVisible(true);
@@ -60,10 +59,9 @@
const dictStore = useDictStore();
await dictStore.loadKeys(dictKeys);
// 加载配置
const { data } = await getHostConfigList(record.value.id);
data.forEach(s => {
config.value[s.type] = s;
});
const { data } = await getHostConfig(record.value.id);
data.current = Date.now();
hostConfig.value = data;
} catch ({ message }) {
Message.error(`配置加载失败 ${message}`);
setVisible(false);
@@ -72,14 +70,38 @@
}
};
defineExpose({ open });
// 保存
const save = async (conf: Record<string, any>) => {
try {
setLoading(true);
// 更新
await updateHostConfig({
id: hostConfig.value.id,
config: JSON.stringify(conf)
});
// 设置参数
hostConfig.value.config = conf;
Message.success('修改成功');
} catch (e) {
} finally {
setLoading(false);
}
};
// 重置
const reset = () => {
// 修改 current 让子组件重新渲染
hostConfig.value.current = Date.now();
};
// 关闭
const handleCancel = () => {
setLoading(false);
setVisible(false);
};
defineExpose({ open });
</script>
<style lang="less" scoped>
@@ -120,4 +142,21 @@
margin: 18px;
}
:deep(.config-title-wrapper) {
display: flex;
align-items: center;
justify-content: space-between;
.title{
font-weight: 600;
user-select: none;
}
}
:deep(.config-button-group) {
display: flex;
align-items: center;
justify-content: flex-end;
}
</style>

View File

@@ -1,19 +1,11 @@
// 主机所有配置
export interface HostConfigWrapper {
ssh: HostSshConfig;
[key: string]: unknown;
}
// 主机 SSH 配置
export interface HostSshConfig {
osType?: string;
port?: number;
username?: string;
password?: string;
authType?: string;
identityId?: number;
keyId?: number;
identityId?: number;
connectTimeout?: number;
charset?: string;
fileNameCharset?: string;
@@ -32,17 +24,6 @@ export const SshAuthType = {
IDENTITY: 'IDENTITY'
};
// 主机系统版本
export const SshOsType = {
LINUX: 'LINUX',
WINDOWS: 'WINDOWS',
};
// 主机配置类型
export const HostConfigType = {
SSH: 'ssh'
};
// 主机验证方式 字典项
export const sshAuthTypeKey = 'hostSshAuthType';

View File

@@ -5,16 +5,6 @@ export const osType = [{
message: '请选择系统类型'
}] as FieldRule[];
export const port = [{
required: true,
message: '请输入SSH端口'
}, {
type: 'number',
min: 1,
max: 65535,
message: '输入的端口不合法'
}] as FieldRule[];
export const authType = [{
required: true,
message: '请选择认证方式'
@@ -66,7 +56,6 @@ export const fileContentCharset = [{
export default {
osType,
port,
authType,
keyId,
identityId,

View File

@@ -112,7 +112,7 @@
});
onMounted(() => {
cacheStore.loadHosts().then(hosts => {
cacheStore.loadHosts(undefined).then(hosts => {
data.value = hosts.map(s => {
return {
value: String(s.id),

View File

@@ -2,7 +2,7 @@
<card-list v-model:searchValue="formModel.searchValue"
search-input-placeholder="输入 id / 名称 / 用户名"
:loading="loading"
:fieldConfig="fieldConfig"
:field-config="fieldConfig"
:list="list"
:pagination="pagination"
:card-layout-cols="cardColLayout"
@@ -119,36 +119,22 @@
<!-- 修改 -->
<a-doption v-permission="['asset:host-identity:update']"
@click="emits('openUpdate', record)">
<icon-edit />
修改
<span class="more-doption normal">
<icon-edit /> 修改
</span>
</a-doption>
<!-- 删除 -->
<a-doption v-permission="['asset:host-identity:delete']"
class="span-red"
@click="deleteRow(record.id)">
<icon-delete />
删除
<span class="more-doption error">
<icon-delete /> 删除
</span>
</a-doption>
</template>
</a-dropdown>
</a-space>
</template>
<!-- 右键菜单 -->
<template #contextMenu="{ record }">
<!-- 修改 -->
<a-doption v-permission="['asset:host-identity:update']"
@click="emits('openUpdate', record)">
<icon-edit />
修改
</a-doption>
<!-- 删除 -->
<a-doption v-permission="['asset:host-identity:delete']"
class="span-red"
@click="deleteRow(record.id)">
<icon-delete />
删除
</a-doption>
</template>
</card-list>
</template>

View File

@@ -2,7 +2,7 @@
<card-list v-model:searchValue="formModel.searchValue"
search-input-placeholder="输入 id / 名称"
:loading="loading"
:fieldConfig="fieldConfig"
:field-config="fieldConfig"
:list="list"
:pagination="pagination"
:card-layout-cols="cardColLayout"
@@ -47,48 +47,29 @@
<!-- 详情 -->
<a-doption v-permission="['asset:host-key:detail', 'asset:host-key:update']"
@click="emits('openView', record)">
<icon-list />
详情
<span class="more-doption normal">
<icon-list /> 详情
</span>
</a-doption>
<!-- 修改 -->
<a-doption v-permission="['asset:host-key:update']"
@click="emits('openUpdate', record)">
<icon-edit />
修改
<span class="more-doption normal">
<icon-edit /> 修改
</span>
</a-doption>
<!-- 删除 -->
<a-doption v-permission="['asset:host-key:delete']"
class="span-red"
@click="deleteRow(record.id)">
<icon-delete />
删除
<span class="more-doption error">
<icon-delete /> 删除
</span>
</a-doption>
</template>
</a-dropdown>
</a-space>
</template>
<!-- 右键菜单 -->
<template #contextMenu="{ record }">
<!-- 详情 -->
<a-doption v-permission="['asset:host-key:detail', 'asset:host-key:update']"
@click="emits('openView', record)">
<icon-list />
详情
</a-doption>
<!-- 修改 -->
<a-doption v-permission="['asset:host-key:update']"
@click="emits('openUpdate', record)">
<icon-edit />
修改
</a-doption>
<!-- 删除 -->
<a-doption v-permission="['asset:host-key:delete']"
class="span-red"
@click="deleteRow(record.id)">
<icon-delete />
删除
</a-doption>
</template>
</card-list>
</template>

View File

@@ -2,7 +2,7 @@
<card-list v-model:searchValue="formModel.searchValue"
search-input-placeholder="输入 id / 名称 / 编码 / 地址"
:loading="loading"
:fieldConfig="fieldConfig"
:field-config="fieldConfig"
:list="list"
:pagination="pagination"
:card-layout-cols="cardColLayout"
@@ -70,6 +70,20 @@
<a-form-item field="address" label="主机地址">
<a-input v-model="formModel.address" placeholder="请输入主机地址" allow-clear />
</a-form-item>
<!-- 主机类型 -->
<a-form-item field="type" label="主机类型">
<a-select v-model="formModel.type"
:options="toOptions(hostTypeKey)"
placeholder="请选择主机类型"
allow-clear />
</a-form-item>
<!-- 主机状态 -->
<a-form-item field="status" label="主机状态">
<a-select v-model="formModel.status"
:options="toOptions(hostStatusKey)"
placeholder="请选择主机状态"
allow-clear />
</a-form-item>
<!-- 主机标签 -->
<a-form-item field="tags" label="主机标签">
<tag-multi-selector v-model="formModel.tags"
@@ -88,11 +102,30 @@
<template #code="{ record }">
{{ record.code }}
</template>
<!-- 主机类型 -->
<template #type="{ record }">
<a-tag :color="getDictValue(hostTypeKey, record.type, 'color')">
{{ getDictValue(hostTypeKey, record.type) }}
</a-tag>
</template>
<!-- 地址 -->
<template #address="{ record }">
<span class="span-blue text-copy" @click="copy(record.address)">
<span class="span-blue text-copy host-address"
title="复制"
@click="copy(record.address)">
{{ record.address }}
</span>
<span class="span-blue text-copy"
title="复制"
@click="copy(record.port)">
{{ record.port }}
</span>
</template>
<!-- 主机状态 -->
<template #status="{ record }">
<a-tag :color="getDictValue(hostStatusKey, record.status, 'color')">
{{ getDictValue(hostStatusKey, record.status) }}
</a-tag>
</template>
<!-- 标签 -->
<template #tags="{ record }">
@@ -114,48 +147,44 @@
<!-- 修改 -->
<a-doption v-permission="['asset:host:update']"
@click="emits('openUpdate', record)">
<icon-edit />
修改
<span class="more-doption normal">
<icon-edit /> 修改
</span>
</a-doption>
<!-- 配置 -->
<a-doption v-permission="['asset:host:update-config']"
@click="emits('openUpdateConfig', record)">
<icon-settings />
配置
<span class="more-doption normal">
<icon-settings /> 配置
</span>
</a-doption>
<!-- 修改状态 -->
<a-doption v-permission="['asset:host:update-status']"
@click="updateStatus(record as HostQueryResponse)">
<span class="more-doption"
:class="[toggleDictValue(hostStatusKey, record.status, 'status')]">
<icon-sync /> {{ toggleDictValue(hostStatusKey, record.status, 'label') }}
</span>
</a-doption>
<!-- 复制 -->
<a-doption v-permission="['asset:host:create']"
@click="emits('openCopy', record)">
<span class="more-doption normal">
<icon-copy /> 复制
</span>
</a-doption>
<!-- 删除 -->
<a-doption v-permission="['asset:host:delete']"
class="span-red"
@click="deleteRow(record.id)">
<icon-delete />
删除
<span class="more-doption error">
<icon-delete /> 删除
</span>
</a-doption>
</template>
</a-dropdown>
</a-space>
</template>
<!-- 右键菜单 -->
<template #contextMenu="{ record }">
<!-- 修改 -->
<a-doption v-permission="['asset:host:update']"
@click="emits('openUpdate', record)">
<icon-edit />
修改
</a-doption>
<!-- 配置 -->
<a-doption v-permission="['asset:host:update-config']"
@click="emits('openUpdateConfig', record)">
<icon-settings />
配置
</a-doption>
<!-- 删除 -->
<a-doption v-permission="['asset:host:delete']"
class="span-red"
@click="deleteRow(record.id)">
<icon-delete />
删除
</a-doption>
</template>
</card-list>
</template>
@@ -169,24 +198,25 @@
import type { HostQueryRequest, HostQueryResponse } from '@/api/asset/host';
import { usePagination, useColLayout } from '@/types/card';
import { computed, reactive, ref, onMounted } from 'vue';
import useLoading from '@/hooks/loading';
import { dataColor, objectTruthKeyCount, resetObject } from '@/utils';
import fieldConfig from '../types/card.fields';
import { deleteHost, getHostPage } from '@/api/asset/host';
import { deleteHost, getHostPage, updateHostStatus } from '@/api/asset/host';
import { Message, Modal } from '@arco-design/web-vue';
import { tagColor } from '../types/const';
import { hostStatusKey, hostTypeKey, tagColor } from '../types/const';
import { copy } from '@/hooks/copy';
import { useDictStore } from '@/store';
import { GrantKey, GrantRouteName } from '@/views/asset/grant/types/const';
import useLoading from '@/hooks/loading';
import fieldConfig from '../types/card.fields';
import TagMultiSelector from '@/components/meta/tag/multi-selector/index.vue';
const emits = defineEmits(['openAdd', 'openUpdate', 'openUpdateConfig', 'openHostGroup']);
const list = ref<HostQueryResponse[]>([]);
const emits = defineEmits(['openAdd', 'openUpdate', 'openUpdateConfig', 'openHostGroup', 'openCopy']);
const cardColLayout = useColLayout();
const pagination = usePagination();
const { loading, setLoading } = useLoading();
const { toOptions, getDictValue, toggleDictValue, toggleDict } = useDictStore();
const list = ref<HostQueryResponse[]>([]);
const formRef = ref();
const formModel = reactive<HostQueryRequest>({
searchValue: undefined,
@@ -194,6 +224,8 @@
name: undefined,
code: undefined,
address: undefined,
type: undefined,
status: undefined,
tags: undefined,
queryTag: true
});
@@ -203,6 +235,33 @@
return objectTruthKeyCount(formModel, ['searchValue', 'queryTag']);
});
// 更新状态
const updateStatus = async (record: HostQueryResponse) => {
const dict = toggleDict(hostStatusKey, record.status);
Modal.confirm({
title: `${dict.label}确认`,
titleAlign: 'start',
content: `确定要${dict.label}该主机吗?`,
okText: '确定',
onOk: async () => {
try {
setLoading(true);
const newStatus = dict.value as string;
// 调用修改接口
await updateHostStatus({
id: record.id,
status: newStatus,
});
record.status = newStatus;
Message.success(`${dict.label}`);
} catch (e) {
} finally {
setLoading(false);
}
}
});
};
// 删除当前行
const deleteRow = (id: number) => {
Modal.confirm({
@@ -273,4 +332,8 @@
</script>
<style lang="less" scoped>
.host-address::after {
content: ':';
user-select: text;
}
</style>

View File

@@ -18,6 +18,15 @@
label-align="right"
:auto-label-width="true"
:rules="formRules">
<!-- 主机类型 -->
<a-form-item v-if="isAddHandle"
field="type"
label="主机类型"
help="主机创建后, 类型则无法修改">
<a-select v-model="formModel.type"
placeholder="请选择主机类型"
:options="toOptions(hostTypeKey)" />
</a-form-item>
<!-- 主机名称 -->
<a-form-item field="name" label="主机名称">
<a-input v-model="formModel.name" placeholder="请输入主机名称" />
@@ -30,6 +39,12 @@
<a-form-item field="address" label="主机地址">
<a-input v-model="formModel.address" placeholder="请输入主机地址" />
</a-form-item>
<!-- 主机端口 -->
<a-form-item field="port" label="主机端口">
<a-input-number v-model="formModel.port"
placeholder="请输入主机端口"
hide-button />
</a-form-item>
<!-- 主机分组 -->
<a-form-item field="groupIdList" label="主机分组">
<host-group-tree-selector v-model="formModel.groupIdList"
@@ -65,10 +80,12 @@
import { createHost, getHost, updateHost } from '@/api/asset/host';
import { Message } from '@arco-design/web-vue';
import { pick } from 'lodash';
import { tagColor } from '@/views/asset/host-list/types/const';
import { tagColor, hostType, hostTypeKey } from '../types/const';
import { useDictStore } from '@/store';
import TagMultiSelector from '@/components/meta/tag/multi-selector/index.vue';
import HostGroupTreeSelector from '@/components/asset/host-group/tree-selector/index.vue';
const { toOptions } = useDictStore();
const { visible, setVisible } = useVisible();
const { loading, setLoading } = useLoading();
@@ -78,9 +95,11 @@
const defaultForm = (): HostUpdateRequest => {
return {
id: undefined,
type: hostType.SSH.type,
name: undefined,
code: undefined,
address: undefined,
port: hostType.SSH.default.port,
tags: undefined,
groupIdList: undefined,
};
@@ -108,13 +127,22 @@
await fetchHostRender(id);
};
// 打开复制
const openCopy = async (id: number) => {
title.value = '复制主机';
isAddHandle.value = true;
renderForm({ ...defaultForm() });
setVisible(true);
await fetchHostRender(id);
};
// 渲染主机
const fetchHostRender = async (id: number) => {
try {
setLoading(true);
const { data } = await getHost(id);
const detail = Object.assign({} as Record<string, any>,
pick(data, 'id', 'name', 'code', 'address', 'groupIdList'));
pick(data, 'id', 'type', 'name', 'code', 'address', 'port', 'status', 'groupIdList'));
// tag
const tags = (data.tags || []).map(s => s.id);
// 渲染
@@ -130,7 +158,7 @@
formModel.value = Object.assign({}, record);
};
defineExpose({ openAdd, openUpdate });
defineExpose({ openAdd, openUpdate, openCopy });
// tag 超出所选限制
const onLimitedTag = (count: number, message: string) => {

View File

@@ -25,6 +25,20 @@
<a-form-item field="address" label="主机地址">
<a-input v-model="formModel.address" placeholder="请输入主机地址" allow-clear />
</a-form-item>
<!-- 主机类型 -->
<a-form-item field="type" label="主机类型">
<a-select v-model="formModel.type"
:options="toOptions(hostTypeKey)"
placeholder="请选择主机类型"
allow-clear />
</a-form-item>
<!-- 主机状态 -->
<a-form-item field="status" label="主机状态">
<a-select v-model="formModel.status"
:options="toOptions(hostStatusKey)"
placeholder="请选择主机状态"
allow-clear />
</a-form-item>
<!-- 主机标签 -->
<a-form-item field="tags" label="主机标签">
<tag-multi-selector v-model="formModel.tags"
@@ -115,17 +129,34 @@
:bordered="false"
@page-change="(page) => fetchTableData(page, pagination.pageSize)"
@page-size-change="(size) => fetchTableData(1, size)">
<!-- 编码 -->
<!-- 主机类型 -->
<template #type="{ record }">
<a-tag :color="getDictValue(hostTypeKey, record.type, 'color')">
{{ getDictValue(hostTypeKey, record.type) }}
</a-tag>
</template>
<!-- 主机编码 -->
<template #code="{ record }">
<a-tag>{{ record.code }}</a-tag>
</template>
<!-- 地址 -->
<template #address="{ record }">
<span class="span-blue text-copy"
<span class="span-blue text-copy host-address"
title="复制"
@click="copy(record.address)">
{{ record.address }}
</span>
<span class="span-blue text-copy"
title="复制"
@click="copy(record.port)">
{{ record.port }}
</span>
</template>
<!-- 主机状态 -->
<template #status="{ record }">
<a-tag :color="getDictValue(hostStatusKey, record.status, 'color')">
{{ getDictValue(hostStatusKey, record.status) }}
</a-tag>
</template>
<!-- 标签 -->
<template #tags="{ record }">
@@ -149,13 +180,6 @@
@click="emits('openUpdate', record)">
修改
</a-button>
<!-- 配置 -->
<a-button type="text"
size="mini"
v-permission="['asset:host:update-config']"
@click="emits('openUpdateConfig', record)">
配置
</a-button>
<!-- 删除 -->
<a-popconfirm content="确认删除这条记录吗?"
position="left"
@@ -168,6 +192,36 @@
删除
</a-button>
</a-popconfirm>
<!-- 更多 -->
<a-dropdown trigger="hover">
<a-button type="text" size="mini">
更多
</a-button>
<template #content>
<!-- 配置 -->
<a-doption v-permission="['asset:host:update-config']"
@click="emits('openUpdateConfig', record)">
<span class="more-doption normal">
配置
</span>
</a-doption>
<!-- 修改状态 -->
<a-doption v-permission="['asset:host:update-status']"
@click="updateStatus(record)">
<span class="more-doption"
:class="[toggleDictValue(hostStatusKey, record.status, 'status')]">
{{ toggleDictValue(hostStatusKey, record.status, 'label') }}
</span>
</a-doption>
<!-- 复制 -->
<a-doption v-permission="['asset:host:create']"
@click="emits('openCopy', record)">
<span class="more-doption normal">
复制
</span>
</a-doption>
</template>
</a-dropdown>
</div>
</template>
</a-table>
@@ -183,43 +237,72 @@
<script lang="ts" setup>
import type { HostQueryRequest, HostQueryResponse } from '@/api/asset/host';
import { reactive, ref, onMounted } from 'vue';
import { deleteHost, batchDeleteHost, getHostPage } from '@/api/asset/host';
import { Message } from '@arco-design/web-vue';
import { tagColor } from '../types/const';
import { deleteHost, batchDeleteHost, getHostPage, updateHostStatus } from '@/api/asset/host';
import { Message, Modal } from '@arco-design/web-vue';
import { tagColor, hostTypeKey, hostStatusKey } from '../types/const';
import { usePagination, useRowSelection } from '@/types/table';
import useLoading from '@/hooks/loading';
import { useDictStore } from '@/store';
import { copy } from '@/hooks/copy';
import columns from '../types/table.columns';
import { dataColor } from '@/utils';
import useLoading from '@/hooks/loading';
import columns from '../types/table.columns';
import { GrantKey, GrantRouteName } from '@/views/asset/grant/types/const';
import TagMultiSelector from '@/components/meta/tag/multi-selector/index.vue';
const emits = defineEmits(['openAdd', 'openUpdate', 'openUpdateConfig', 'openHostGroup']);
const emits = defineEmits(['openCopy', 'openAdd', 'openUpdate', 'openUpdateConfig', 'openHostGroup']);
const pagination = usePagination();
const rowSelection = useRowSelection();
const { loading, setLoading } = useLoading();
const { toOptions, getDictValue, toggleDictValue, toggleDict } = useDictStore();
const tagSelector = ref();
const selectedKeys = ref<number[]>([]);
const tableRenderData = ref<HostQueryResponse[]>([]);
const selectedKeys = ref<Array<number>>([]);
const tableRenderData = ref<Array<HostQueryResponse>>([]);
const formModel = reactive<HostQueryRequest>({
id: undefined,
name: undefined,
code: undefined,
address: undefined,
type: undefined,
status: undefined,
tags: undefined,
queryTag: true
});
// 更新状态
const updateStatus = async (record: HostQueryResponse) => {
const dict = toggleDict(hostStatusKey, record.status);
Modal.confirm({
title: `${dict.label}确认`,
titleAlign: 'start',
content: `确定要${dict.label}该主机吗?`,
okText: '确定',
onOk: async () => {
try {
setLoading(true);
const newStatus = dict.value as string;
// 调用修改接口
await updateHostStatus({
id: record.id,
status: newStatus,
});
record.status = newStatus;
Message.success(`已${dict.label}`);
} catch (e) {
} finally {
setLoading(false);
}
}
});
};
// 删除当前行
const deleteRow = async ({ id }: {
id: number
}) => {
const deleteRow = async (record: HostQueryResponse) => {
try {
setLoading(true);
// 调用删除接口
await deleteHost(id);
await deleteHost(record.id);
Message.success('删除成功');
// 重新加载数据
fetchTableData();
@@ -292,4 +375,10 @@
display: flex;
align-items: center;
}
.host-address::after {
content: ':';
user-select: text;
}
</style>

View File

@@ -4,6 +4,7 @@
<host-table v-if="renderTable"
ref="table"
@open-host-group="() => hostGroup.open()"
@open-copy="(e) => modal.openCopy(e.id)"
@open-add="() => modal.openAdd()"
@open-update="(e) => modal.openUpdate(e.id)"
@open-update-config="(e) => hostConfig.open(e)" />
@@ -11,6 +12,7 @@
<host-card-list v-else
ref="card"
@open-host-group="() => hostGroup.open()"
@open-copy="(e) => modal.openCopy(e.id)"
@open-add="() => modal.openAdd()"
@open-update="(e) => modal.openUpdate(e.id)"
@open-update-config="(e) => hostConfig.open(e)" />
@@ -32,8 +34,9 @@
</script>
<script lang="ts" setup>
import { computed, ref, onUnmounted } from 'vue';
import { useAppStore, useCacheStore } from '@/store';
import { computed, ref, onUnmounted, onBeforeMount } from 'vue';
import { useAppStore, useCacheStore, useDictStore } from '@/store';
import { dictKeys } from './types/const';
import HostTable from './components/host-table.vue';
import HostCardList from './components/host-card-list.vue';
import HostFormModal from './components/host-form-modal.vue';
@@ -69,9 +72,15 @@
}
};
// 加载字典配置
onBeforeMount(async () => {
const dictStore = useDictStore();
await dictStore.loadKeys(dictKeys);
});
// 卸载时清除 cache
onUnmounted(() => {
cacheStore.reset('hosts', 'hostKeys', 'hostIdentities', 'hostGroups', 'HOST_Tags');
cacheStore.reset('hostKeys', 'hostIdentities', 'hostGroups', 'HOST_Tags');
});
</script>

View File

@@ -9,6 +9,10 @@ const fieldConfig = {
label: 'id',
dataIndex: 'id',
slotName: 'id',
}, {
label: '主机类型',
dataIndex: 'type',
slotName: 'type',
}, {
label: '主机编码',
dataIndex: 'code',
@@ -18,6 +22,10 @@ const fieldConfig = {
dataIndex: 'address',
slotName: 'address',
tooltip: true,
}, {
label: '主机状态',
dataIndex: 'status',
slotName: 'status',
}, {
label: '主机标签',
dataIndex: 'tags',

View File

@@ -6,3 +6,22 @@ export const tagColor = [
'pinkpurple',
'magenta'
];
// 主机类型
export const hostType = {
SSH: {
type: 'SSH',
default: {
port: 22,
}
}
};
// 主机类型 字典项
export const hostTypeKey = 'hostType';
// 主机状态 字典项
export const hostStatusKey = 'hostStatus';
// 加载的字典值
export const dictKeys = [hostTypeKey, hostStatusKey];

View File

@@ -1,5 +1,10 @@
import type { FieldRule } from '@arco-design/web-vue';
export const type = [{
required: true,
message: '请选择主机类型'
}] as FieldRule[];
export const name = [{
required: true,
message: '请输入主机名称'
@@ -24,14 +29,26 @@ export const address = [{
message: '主机地址长度不能大于128位'
}] as FieldRule[];
export const port = [{
required: true,
message: '请输入主机端口'
}, {
type: 'number',
min: 1,
max: 65535,
message: '输入的端口不合法'
}] as FieldRule[];
export const tags = [{
maxLength: 5,
message: '最多选择5个标签'
}] as FieldRule[];
export default {
type,
name,
code,
address,
port,
tags,
} as Record<string, FieldRule | FieldRule[]>;

View File

@@ -5,7 +5,7 @@ const columns = [
title: 'id',
dataIndex: 'id',
slotName: 'id',
width: 100,
width: 68,
align: 'left',
fixed: 'left',
}, {
@@ -13,20 +13,37 @@ const columns = [
dataIndex: 'name',
slotName: 'name',
ellipsis: true,
tooltip: true
tooltip: true,
minWidth: 238,
}, {
title: '主机编码',
dataIndex: 'code',
slotName: 'code',
minWidth: 120,
}, {
title: '主机类型',
dataIndex: 'type',
slotName: 'type',
align: 'center',
width: 88,
}, {
title: '主机地址',
dataIndex: 'address',
slotName: 'address',
minWidth: 238
}, {
title: '主机标签',
dataIndex: 'tags',
slotName: 'tags',
align: 'left',
minWidth: 148,
}, {
title: '主机状态',
dataIndex: 'status',
slotName: 'status',
width: 88,
align: 'center',
fixed: 'right',
}, {
title: '操作',
slotName: 'handle',

View File

@@ -38,6 +38,7 @@
</template>
<!-- 主机模态框 -->
<authorized-host-modal ref="hostModal"
type="SSH"
@selected="setSelectedHost" />
</a-spin>
</template>

View File

@@ -107,6 +107,7 @@
@selected="setWithTemplate" />
<!-- 主机模态框 -->
<authorized-host-modal ref="hostModal"
type="SSH"
@selected="setSelectedHost" />
</a-spin>
</template>

View File

@@ -15,6 +15,7 @@
@open-host="(e) => openHostModal('exec', e)" />
<!-- 主机模态框 -->
<authorized-host-modal ref="hostModal"
type="SSH"
@selected="hostSelected" />
</div>
</template>

View File

@@ -17,7 +17,7 @@
<!-- 主机列表 -->
<host-list-view class="host-list"
:hostList="hostList"
empty-value="当前分组内无授权主机/主机未启用 SSH 配置!" />
empty-value="当前分组内无授权主机!" />
</div>
</template>

View File

@@ -49,7 +49,7 @@
const emptyMessage = computed(() => {
if (props.newConnectionType === NewConnectionType.LIST) {
// 列表
return '无授权主机/主机未启用 SSH 配置!';
return '无授权主机!';
} else if (props.newConnectionType === NewConnectionType.FAVORITE) {
// 收藏
return '无收藏记录, 快去点击主机右侧的⭐进行收藏吧!';

View File

@@ -49,7 +49,7 @@
const { toOptions } = useDictStore();
const formModel = ref<LabelExtraSettingModel>({
color: ''
color: '',
});
// 渲染表单

View File

@@ -216,7 +216,7 @@
selectedFiles: Array<string>;
}>();
const emits = defineEmits(['update:selectedFiles', 'loadFile', 'download', 'setLoading']);
const emits = defineEmits(['loadFile', 'download', 'deleteFile', 'setLoading']);
const showHiddenFile = ref(false);
const analysisPaths = ref<Array<PathAnalysis>>([]);
@@ -295,9 +295,12 @@
// 删除选中文件
const deleteSelectFiles = () => {
if (props.selectedFiles?.length) {
props.session?.remove(props.selectedFiles);
}
emits('deleteFile', [...props.selectedFiles]);
};
// 下载文件
const downloadFile = () => {
emits('download', [...props.selectedFiles], true);
};
// 重新连接
@@ -309,12 +312,6 @@
}
};
// 下载文件
const downloadFile = () => {
emits('download', [...props.selectedFiles]);
emits('update:selectedFiles', []);
};
</script>
<style lang="less" scoped>

View File

@@ -161,7 +161,7 @@
selectedFiles: Array<string>;
}>();
const emits = defineEmits(['update:selectedFiles', 'loadFile', 'editFile', 'download']);
const emits = defineEmits(['update:selectedFiles', 'loadFile', 'editFile', 'deleteFile', 'download']);
const openSftpMoveModal = inject(openSftpMoveModalKey) as (sessionId: string, path: string) => void;
const openSftpChmodModal = inject(openSftpChmodModalKey) as (sessionId: string, path: string, permission: number) => void;
@@ -239,7 +239,7 @@
if (!props.session?.connected) {
return;
}
props.session?.remove([path]);
emits('deleteFile', [path]);
};
// 下载文件
@@ -248,7 +248,7 @@
if (!props.session?.connected) {
return;
}
emits('download', [path]);
emits('download', [path], false);
};
// 移动文件

View File

@@ -4,16 +4,17 @@
title-align="start"
title="文件上传"
ok-text="上传"
:body-style="{ padding: '20px' }"
:body-style="{ padding: 0 }"
:align-center="false"
:mask-closable="false"
:unmount-on-close="true"
:on-before-ok="handlerOk"
@cancel="handleClose">
<div class="upload-container">
<div class="parent-wrapper mb16">
<span class="parent-label">上传至文件夹:</span>
<a-input class="parent-input"
<!-- 上传目录 -->
<div class="item-wrapper">
<div class="form-item">
<span class="item-label">上传至文件夹</span>
<a-input class="item-input"
v-model="parentPath"
placeholder="上传目录" />
</div>
@@ -80,8 +81,8 @@
import { ref } from 'vue';
import { useTerminalStore } from '@/store';
import { Message } from '@arco-design/web-vue';
import useVisible from '@/hooks/visible';
import { getFileSize } from '@/utils/file';
import useVisible from '@/hooks/visible';
const { visible, setVisible } = useVisible();
const { transferManager } = useTerminalStore();
@@ -100,7 +101,7 @@
defineExpose({ open });
// 确定
const handlerOk = () => {
const handlerOk = async () => {
if (!parentPath.value) {
Message.error('请输入上传目录');
return false;
@@ -109,8 +110,9 @@
Message.error('请选择文件');
return false;
}
// 添加到上传列表
// 获取上传的文件
const files = fileList.value.map(s => s.file as File);
// 上传
transferManager.addUpload(hostId.value, parentPath.value, files);
Message.success('已开始上传, 点击右侧传输列表查看进度');
// 清空
@@ -132,25 +134,37 @@
<style lang="less" scoped>
@file-size-width: 82px;
@item-label: 104px;
.upload-container {
width: 100%;
}
.parent-wrapper {
.item-wrapper {
margin-bottom: 24px;
display: flex;
flex-direction: column;
.form-item {
display: flex;
justify-content: space-between;
align-items: center;
.parent-label {
width: 98px;
}
.parent-input {
width: 386px;
.item-label {
width: @item-label;
padding-right: 8px;
display: flex;
justify-content: flex-end;
user-select: none;
&:after {
content: ':';
margin-left: 2px;
}
}
.item-input {
width: 376px;
}
}
.file-list-uploader {
margin-top: 24px;
@@ -160,7 +174,7 @@
:deep(.arco-upload-list) {
padding: 0 12px 0 0;
max-height: calc(100vh - 386px);
max-height: calc(100vh - 496px);
overflow-x: hidden;
overflow-y: auto;
}

View File

@@ -11,13 +11,14 @@
:hide-icon="true">
<!-- 表头 -->
<sftp-table-header class="sftp-table-header"
v-model:selected-files="selectFiles"
:selected-files="selectFiles"
:close-message="closeMessage"
:current-path="currentPath"
:session="session"
@load-file="loadFiles"
@download="downloadFiles"
@set-loading="setTableLoading" />
@set-loading="setTableLoading"
@delete-file="deleteFile"
@download="downloadFiles" />
<!-- 表格 -->
<sftp-table class="sftp-table-wrapper"
v-model:selected-files="selectFiles"
@@ -26,6 +27,7 @@
:loading="tableLoading"
@load-file="loadFiles"
@edit-file="editFile"
@delete-file="deleteFile"
@download="downloadFiles" />
</a-spin>
</template>
@@ -145,17 +147,30 @@
editorFilePath.value = '';
};
// 下载文件
const downloadFiles = (paths: Array<string>) => {
// 删除文件
const deleteFile = (paths: Array<string>) => {
if (!paths.length) {
return;
}
// 删除
selectFiles.value = [];
session.value?.remove(paths);
};
// 下载文件
const downloadFiles = (paths: Array<string>, clear: boolean) => {
if (!paths.length) {
return;
}
Message.success('已开始下载, 点击右侧传输列表查看进度');
// 映射为文件
const files = fileList.value.filter(s => paths.includes(s.path))
.map(s => {
return { ...s };
});
if (clear) {
selectFiles.value = [];
}
Message.success('已开始下载, 点击右侧传输列表查看进度');
// 添加普通文件到下载队列
const normalFiles = files.filter(s => !s.isDir);
transferManager.addDownload(props.tab.hostId as number, currentPath.value, normalFiles);
@@ -193,9 +208,9 @@
};
// 接收列表回调
const resolveList = (result: string, path: string, list: Array<SftpFile>) => {
const resolveList = (path: string, result: string, msg: string, list: Array<SftpFile>) => {
setTableLoading(false);
if (!checkResult(result, '查询失败')) {
if (!checkResult(result, msg)) {
return;
}
currentPath.value = path;
@@ -216,11 +231,11 @@
};
// 接收获取文件内容响应
const resolveSftpGetContent = (path: string, result: string, content: string) => {
const resolveSftpGetContent = (path: string, result: string, msg: string, content: string) => {
setTableLoading(false);
setEditorLoading(false);
// 检查结果
if (!checkResult(result, '加载失败')) {
if (!checkResult(result, msg)) {
return;
}
editorRef.value?.setValue(content);
@@ -237,8 +252,12 @@
};
// 接收下载文件夹展开文件响应
const resolveDownloadFlatDirectory = (currentPath: string, list: Array<SftpFile>) => {
const resolveDownloadFlatDirectory = (currentPath: string, result: string, msg: string, list: Array<SftpFile>) => {
setTableLoading(false);
// 检查结果
if (!checkResult(result, msg)) {
return;
}
transferManager.addDownload(props.tab.hostId as number, currentPath, list);
};
@@ -273,7 +292,7 @@
.sftp-container {
width: 100%;
height: calc(100vh - var(--header-height) - var(--panel-nav-height));
height: 100%;
position: relative;
.split-view {

View File

@@ -40,7 +40,7 @@ const columns = [
sortable: {
sortDirections: ['ascend', 'descend'],
},
width: 234,
width: 264,
cellClass: 'action-cell',
},
] as TableColumnData[];

View File

@@ -1,5 +1,5 @@
import type { ISftpSession, ISftpSessionResolver, ITerminalChannel, TerminalPanelTabItem } from '../types/terminal.type';
import { InputProtocol } from '../types/terminal.protocol';
import { InputProtocol } from '@/types/protocol/terminal.protocol';
import { PanelSessionType } from '../types/terminal.const';
import { Modal } from '@arco-design/web-vue';
import BaseSession from './base-session';

View File

@@ -1,5 +1,5 @@
import type { SftpTransferItem } from '../types/terminal.type';
import { TransferStatus, TransferType } from '../types/terminal.const';
import { TransferStatus } from '../types/terminal.const';
import { getFileName, openDownloadFile } from '@/utils/file';
import { saveAs } from 'file-saver';
import { getDownloadTransferUrl } from '@/api/asset/host-sftp';
@@ -8,8 +8,8 @@ import SftpTransferHandler from './sftp-transfer-handler';
// sftp 下载器实现
export default class SftpTransferDownloader extends SftpTransferHandler {
constructor(item: SftpTransferItem, client: WebSocket) {
super(TransferType.DOWNLOAD, item, client);
constructor(type: string, item: SftpTransferItem, client: WebSocket) {
super(type, item, client);
}
// 开始回调

View File

@@ -27,7 +27,7 @@ export default abstract class SftpTransferHandler implements ISftpTransferHandle
operator: TransferOperator.START,
type: this.type,
path: getPath(this.item.parentPath + '/' + this.item.name),
hostId: this.item.hostId
hostId: this.item.hostId,
}));
};
@@ -81,9 +81,14 @@ export default abstract class SftpTransferHandler implements ISftpTransferHandle
};
// 进度回调
onProgress(size: number) {
if (this.item && size) {
this.item.currentSize = size;
onProgress(totalSize: number | undefined, currentSize: number | undefined) {
if (this.item) {
if (totalSize) {
this.item.totalSize = totalSize;
}
if (currentSize) {
this.item.currentSize = currentSize;
}
}
};

View File

@@ -13,7 +13,7 @@ export default class SftpTransferManager implements ISftpTransferManager {
private run: boolean;
private progressIntervalId?: number;
private progressIntervalId?: any;
private currentItem?: SftpTransferItem;
@@ -45,9 +45,7 @@ export default class SftpTransferManager implements ISftpTransferManager {
});
this.transferList.push(...items);
// 开始传输
if (!this.run) {
this.openClient();
}
this.startTransfer(items);
}
// 添加下载任务
@@ -67,6 +65,12 @@ export default class SftpTransferManager implements ISftpTransferManager {
status: TransferStatus.WAITING,
};
}) as Array<SftpTransferItem>;
// 开始传输
this.startTransfer(items);
}
// 开始传输
startTransfer(items: Array<SftpTransferItem>) {
this.transferList.push(...items);
// 开始传输
if (!this.run) {
@@ -154,13 +158,8 @@ export default class SftpTransferManager implements ISftpTransferManager {
// 获取任务
this.currentItem = this.transferList.find(s => s.status === TransferStatus.WAITING);
if (this.currentItem) {
if (this.currentItem.type === TransferType.UPLOAD) {
// 上传
this.currentTransfer = new SftpTransferUploader(this.currentItem, this.client as WebSocket);
} else {
// 下载
this.currentTransfer = new SftpTransferDownloader(this.currentItem, this.client as WebSocket);
}
// 创建传输器
this.currentTransfer = this.createTransfer();
// 开始
this.currentTransfer?.start();
} else {
@@ -169,6 +168,20 @@ export default class SftpTransferManager implements ISftpTransferManager {
}
}
// 创建传输器
private createTransfer(): ISftpTransferHandler | undefined {
if (!this.currentItem) {
return undefined;
}
if (this.currentItem.type === TransferType.UPLOAD) {
// 上传
return new SftpTransferUploader(TransferType.UPLOAD, this.currentItem, this.client as WebSocket);
} else if (this.currentItem.type === TransferType.DOWNLOAD) {
// 下载
return new SftpTransferDownloader(TransferType.DOWNLOAD, this.currentItem, this.client as WebSocket);
}
}
// 接收消息
private async resolveMessage(message: MessageEvent) {
// 文本消息
@@ -181,7 +194,7 @@ export default class SftpTransferManager implements ISftpTransferManager {
this.currentTransfer?.onStart(data.channelId as string, data.transferToken as string);
} else if (data.type === TransferReceiver.PROGRESS) {
// 进度回调
this.currentTransfer?.onProgress(data.currentSize as number);
this.currentTransfer?.onProgress(data.totalSize, data.currentSize);
} else if (data.type === TransferReceiver.FINISH) {
// 完成回调
this.currentTransfer?.onFinish();

View File

@@ -1,5 +1,4 @@
import type { SftpTransferItem } from '../types/terminal.type';
import { TransferType } from '../types/terminal.const';
import SftpTransferHandler from './sftp-transfer-handler';
// 512 KB
@@ -12,8 +11,8 @@ export default class SftpTransferUploader extends SftpTransferHandler {
private readonly totalPart: number;
private file: File;
constructor(item: SftpTransferItem, client: WebSocket) {
super(TransferType.UPLOAD, item, client);
constructor(type: string, item: SftpTransferItem, client: WebSocket) {
super(type, item, client);
this.file = item.file;
this.currentPart = 0;
this.totalPart = Math.ceil(item.file.size / PART_SIZE);

View File

@@ -2,10 +2,12 @@ import type { UnwrapRef } from 'vue';
import type { ISearchOptions } from '@xterm/addon-search';
import { SearchAddon } from '@xterm/addon-search';
import type { TerminalPreference } from '@/store/modules/terminal/types';
import type { ISshSession, ISshSessionHandler, ITerminalChannel, TerminalPanelTabItem, XtermAddons, XtermDomRef } from '../types/terminal.type';
import type { ISshSession, ISshSessionHandler, ITerminalChannel, TerminalPanelTabItem, XtermDomRef } from '../types/terminal.type';
import type { XtermAddons } from '@/types/xterm';
import { defaultFontFamily } from '@/types/xterm';
import { useTerminalStore } from '@/store';
import { InputProtocol } from '../types/terminal.protocol';
import { fontFamilySuffix, PanelSessionType, TerminalShortcutType, TerminalStatus, } from '../types/terminal.const';
import { InputProtocol } from '@/types/protocol/terminal.protocol';
import { PanelSessionType, TerminalShortcutType, TerminalStatus } from '../types/terminal.const';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
@@ -44,6 +46,7 @@ export default class SshSession extends BaseSession implements ISshSession {
// 初始化
init(domRef: XtermDomRef): void {
const { preference } = useTerminalStore();
const fontFamily = preference.displaySetting.fontFamily;
// 初始化实例
this.inst = new Terminal({
...(preference.displaySetting as any),
@@ -52,7 +55,7 @@ export default class SshSession extends BaseSession implements ISshSession {
altClickMovesCursor: !!preference.interactSetting.altClickMovesCursor,
rightClickSelectsWord: !!preference.interactSetting.rightClickSelectsWord,
wordSeparator: preference.interactSetting.wordSeparator,
fontFamily: preference.displaySetting.fontFamily + fontFamilySuffix,
fontFamily: fontFamily === '_' ? defaultFontFamily : `${fontFamily}, ${defaultFontFamily}`,
scrollback: preference.sessionSetting.scrollBackLine,
allowProposedApi: true,
});
@@ -219,6 +222,11 @@ export default class SshSession extends BaseSession implements ISshSession {
this.inst.write(value);
}
// 修改大小
resize(cols: number, rows: number): void {
this.inst.resize(cols, rows);
}
// 聚焦
focus(): void {
this.inst.focus();

View File

@@ -1,5 +1,6 @@
import type { InputPayload, ITerminalChannel, ITerminalOutputProcessor, ITerminalSessionManager, Protocol, } from '../types/terminal.type';
import { format, OutputProtocol, parse } from '../types/terminal.protocol';
import type { ITerminalChannel, ITerminalOutputProcessor, ITerminalSessionManager } from '../types/terminal.type';
import type { InputPayload, Protocol } from '@/types/protocol/terminal.protocol';
import { format, OutputProtocol, parse } from '@/types/protocol/terminal.protocol';
import { sessionCloseMsg } from '../types/terminal.const';
import { getTerminalAccessToken, openHostTerminalChannel } from '@/api/asset/host-terminal';
import { Message } from '@arco-design/web-vue';

View File

@@ -1,13 +1,6 @@
import type {
ISftpSession,
ISshSession,
ITerminalChannel,
ITerminalOutputProcessor,
ITerminalSession,
ITerminalSessionManager,
OutputPayload
} from '../types/terminal.type';
import { InputProtocol } from '../types/terminal.protocol';
import type { ISftpSession, ISshSession, ITerminalChannel, ITerminalOutputProcessor, ITerminalSession, ITerminalSessionManager } from '../types/terminal.type';
import type { OutputPayload } from '@/types/protocol/terminal.protocol';
import { InputProtocol } from '@/types/protocol/terminal.protocol';
import { PanelSessionType, TerminalStatus } from '../types/terminal.const';
import { useTerminalStore } from '@/store';
import { Message } from '@arco-design/web-vue';
@@ -43,7 +36,7 @@ export default class TerminalOutputProcessor implements ITerminalOutputProcessor
});
} else {
// 未成功展示错误信息
ssh.write(`${msg || ''}\r\n输入回车重新连接...\r\n\r\n`);
ssh.write(`${msg || ''}\r\n输入回车重新连接...\r\n\r\n`);
ssh.status = TerminalStatus.CLOSED;
}
}, sftp => {
@@ -109,7 +102,7 @@ export default class TerminalOutputProcessor implements ITerminalOutputProcessor
// ssh 拼接关闭消息
ssh.write(`\r\n\r\n${msg || ''}\r\n`);
if (!isForceClose) {
ssh.write(`${msg || ''}\r\n输入回车重新连接...\r\n\r\n`);
ssh.write('输入回车重新连接...\r\n\r\n');
}
// 设置状态
ssh.status = TerminalStatus.CLOSED;
@@ -135,10 +128,10 @@ export default class TerminalOutputProcessor implements ITerminalOutputProcessor
}
// 处理 SFTP 文件列表
processSftpList({ sessionId, result, path, body }: OutputPayload): void {
processSftpList({ sessionId, result, path, msg, body }: OutputPayload): void {
// 获取会话
const session = this.sessionManager.getSession<ISftpSession>(sessionId);
session && session.resolver.resolveList(result, path, JSON.parse(body));
session && session.resolver.resolveList(path, result, msg, JSON.parse(body));
}
// 处理 SFTP 创建文件夹
@@ -177,17 +170,17 @@ export default class TerminalOutputProcessor implements ITerminalOutputProcessor
}
// 处理 SFTP 下载文件夹展开文件
processDownloadFlatDirectory({ sessionId, currentPath, body }: OutputPayload): void {
processDownloadFlatDirectory({ sessionId, currentPath, result, msg, body }: OutputPayload): void {
// 获取会话
const session = this.sessionManager.getSession<ISftpSession>(sessionId);
session && session.resolver.resolveDownloadFlatDirectory(currentPath, JSON.parse(body));
session && session.resolver.resolveDownloadFlatDirectory(currentPath, result, msg, JSON.parse(body));
}
// 处理 SFTP 获取文件内容
processSftpGetContent({ sessionId, path, result, content }: OutputPayload): void {
processSftpGetContent({ sessionId, path, result, msg, content }: OutputPayload): void {
// 获取会话
const session = this.sessionManager.getSession<ISftpSession>(sessionId);
session && session.resolver.resolveSftpGetContent(path, result, content);
session && session.resolver.resolveSftpGetContent(path, result, msg, content);
}
// 处理 SFTP 修改文件内容

View File

@@ -4,10 +4,9 @@ import TerminalTabManager from '../handler/terminal-tab-manager';
// 终端面板管理器实现
export default class TerminalPanelManager implements ITerminalPanelManager {
// 当前面板
active: number;
// 面板列表
panels: Array<TerminalTabManager<TerminalPanelTabItem>>;
public active: number;
public panels: Array<TerminalTabManager<TerminalPanelTabItem>>;
constructor() {
this.active = 0;
@@ -31,17 +30,18 @@ export default class TerminalPanelManager implements ITerminalPanelManager {
// 移除面板
removePanel(index: number) {
this.panels.splice(index, 1);
this.panels[index].clear();
this.active = index >= this.panels.length ? this.panels.length - 1 : index;
};
// 重置
reset() {
this.active = 0;
if (this.panels) {
for (let panel of this.panels) {
panel.clear();
}
this.active = 0;
this.panels = [new TerminalTabManager()];
}
};
}

View File

@@ -8,7 +8,7 @@ import type {
XtermDomRef
} from '../types/terminal.type';
import { sleep } from '@/utils';
import { InputProtocol } from '../types/terminal.protocol';
import { InputProtocol } from '@/types/protocol/terminal.protocol';
import { PanelSessionType } from '../types/terminal.const';
import { useDebounceFn } from '@vueuse/core';
import { addEventListen, removeEventListen } from '@/utils/event';
@@ -132,7 +132,7 @@ export default class TerminalSessionManager implements ITerminalSessionManager {
}
// 调度重置大小
private dispatchResize() {
dispatchResize() {
// 对所有已连接的会话重置大小
Object.values(this.sessions)
.filter(s => s?.type === PanelSessionType.SSH.type)

View File

@@ -56,8 +56,8 @@ export const NewConnectionType = {
// 主机额外配置项
export const ExtraSettingItems = {
SSH: 'ssh',
LABEL: 'label',
SSH: 'SSH',
LABEL: 'LABEL',
};
// 主机额外配置 ssh 认证方式
@@ -324,7 +324,7 @@ export const TransferStatus = {
// 传输类型
export const TransferType = {
UPLOAD: 'upload',
DOWNLOAD: 'download'
DOWNLOAD: 'download',
};
// 传输操作
@@ -363,9 +363,6 @@ export const openSftpChmodModalKey = Symbol();
// 打开 sftpUploadModal key
export const openSftpUploadModalKey = Symbol();
// 字体后缀 兜底
export const fontFamilySuffix = ', Courier New, Monaco, courier, monospace';
// 终端字体样式
export const fontFamilyKey = 'terminalFontFamily';

View File

@@ -1,13 +1,8 @@
import type { Terminal } from '@xterm/xterm';
import type { FitAddon } from '@xterm/addon-fit';
import type { CanvasAddon } from '@xterm/addon-canvas';
import type { WebglAddon } from '@xterm/addon-webgl';
import type { WebLinksAddon } from '@xterm/addon-web-links';
import type { ISearchOptions, SearchAddon } from '@xterm/addon-search';
import type { ImageAddon } from '@xterm/addon-image';
import type { Unicode11Addon } from '@xterm/addon-unicode11';
import type { ISearchOptions } from '@xterm/addon-search';
import type { CSSProperties } from 'vue';
import type { HostQueryResponse } from '@/api/asset/host';
import type { InputPayload, OutputPayload, Protocol } from '@/types/protocol/terminal.protocol';
// 终端 tab 元素
export interface TerminalTabItem {
@@ -81,30 +76,6 @@ export interface PanelSessionTabType {
icon: string;
}
// 终端协议
export interface Protocol {
type: string;
template: string[];
[key: string]: unknown;
}
// 终端输入消息内容
export interface InputPayload {
type?: string;
sessionId?: string;
[key: string]: unknown;
}
// 终端输出消息内容
export interface OutputPayload {
type: string;
sessionId: string;
[key: string]: string;
}
// 终端 tab 管理器定义
export interface ITerminalTabManager<T extends TerminalTabItem = TerminalTabItem> {
// 当前 tab
@@ -166,6 +137,8 @@ export interface ITerminalSessionManager {
getSession: <T extends ITerminalSession>(sessionId: string) => T;
// 关闭终端会话
closeSession: (sessionId: string) => void;
// 重置大小
dispatchResize: () => void;
// 重置
reset: () => void;
}
@@ -221,17 +194,6 @@ export interface XtermDomRef {
editorModal: any;
}
// xterm 插件
export interface XtermAddons {
fit: FitAddon;
webgl: WebglAddon;
canvas: CanvasAddon;
weblink: WebLinksAddon;
search: SearchAddon;
image: ImageAddon;
unicode: Unicode11Addon;
}
// 终端会话定义
export interface ITerminalSession {
type: string;
@@ -269,6 +231,8 @@ export interface ISshSession extends ITerminalSession {
init: (domRef: XtermDomRef) => void;
// 写入数据
write: (value: string) => void;
// 修改大小
resize: (cols: number, rows: number) => void;
// 聚焦
focus: () => void;
// 失焦
@@ -368,7 +332,7 @@ export interface ISftpSessionResolver {
// 关闭回调
onClose: (forceClose: boolean, msg: string) => void;
// 接受文件列表响应
resolveList: (result: string, path: string, list: Array<SftpFile>) => void;
resolveList: (path: string, result: string, msg: string, list: Array<SftpFile>) => void;
// 接收创建文件夹响应
resolveSftpMkdir: (result: string, msg: string) => void;
// 接收创建文件响应
@@ -380,9 +344,9 @@ export interface ISftpSessionResolver {
// 接收修改文件权限响应
resolveSftpChmod: (result: string, msg: string) => void;
// 接收下载文件夹展开文件响应
resolveDownloadFlatDirectory: (currentPath: string, list: Array<SftpFile>) => void;
resolveDownloadFlatDirectory: (currentPath: string, result: string, msg: string, list: Array<SftpFile>) => void;
// 接收获取文件内容响应
resolveSftpGetContent: (path: string, result: string, content: string) => void;
resolveSftpGetContent: (path: string, result: string, msg: string, content: string) => void;
// 接收修改文件内容响应
resolveSftpSetContent: (result: string, msg: string) => void;
}
@@ -414,7 +378,6 @@ export interface ISftpTransferManager {
cancelAllTransfer: () => void;
}
// sftp 传输处理回调定义
export interface ISftpTransferCallback {
// 下一分片回调
@@ -422,7 +385,7 @@ export interface ISftpTransferCallback {
// 开始回调
onStart: (channelId: string, token: string) => void;
// 进度回调
onProgress: (size: number) => void;
onProgress: (totalSize: number | undefined, currentSize: number | undefined) => void;
// 失败回调
onError: (msg: string | undefined) => void;
// 完成回调
@@ -475,4 +438,5 @@ export interface TransferOperatorResponse {
transferToken?: string;
success: boolean;
msg?: string;
totalSize?: number;
}

View File

@@ -26,6 +26,7 @@
@selected="(e) => drawer.setWithTemplate(e)" />
<!-- 主机模态框 -->
<authorized-host-modal ref="hostModal"
type="SSH"
@selected="(e) => drawer.setSelectedHost(e)" />
</div>
</template>

View File

@@ -106,7 +106,7 @@
<!-- 额外参数 -->
<template #extraSchema="{ record }">
<template v-if="record.extraSchema">
<a-space>
<a-space style="margin-bottom: -8px;" :wrap="true">
<template v-for="item in JSON.parse(record.extraSchema)" :key="item.name">
<a-tag :color="getDictValue(dictValueTypeKey, item.type, 'color')">
{{ item.name }}

View File

@@ -6,7 +6,7 @@ const columns = [
title: 'id',
dataIndex: 'id',
slotName: 'id',
width: 80,
width: 68,
align: 'left',
fixed: 'left',
}, {