🔖 项目重命名.

This commit is contained in:
lijiahangmax
2024-05-16 00:03:30 +08:00
parent f7189e34cb
commit d3a045ec20
1511 changed files with 4199 additions and 4128 deletions

View File

@@ -0,0 +1,151 @@
<template>
<a-spin :loading="loading" class="grant-container">
<!-- 角色列表 -->
<router-roles v-if="type === GrantType.ROLE"
outer-class="router-wrapper"
v-model="subjectId"
@change="fetchAuthorizedData" />
<!-- 角色列表 -->
<router-users v-else-if="type === GrantType.USER"
outer-class="router-wrapper"
v-model="subjectId"
@change="fetchAuthorizedData" />
<!-- 数据列表 -->
<div class="data-container">
<!-- 顶部 -->
<div class="data-header">
<!-- 提示信息 -->
<a-alert class="alert-wrapper" :show-icon="false">
<span class="alert-message" v-if="currentSubject">
<!-- 角色提示信息 -->
<template v-if="type === GrantType.ROLE">
当前选择的角色为 <span class="span-blue mr4">{{ currentSubject.text }}</span>
</template>
<!-- 用户提示信息 -->
<template v-else-if="type === GrantType.USER">
当前选择的用户为 <span class="span-blue mr4">{{ currentSubject.text }}</span>
</template>
</span>
</a-alert>
<!-- 操作按钮 -->
<a-space>
<!-- 全选 -->
<a-button @click="emits('selectAll')">
全选
<template #icon>
<icon-select-all />
</template>
</a-button>
<!-- 反选 -->
<a-button @click="emits('reverse')">
反选
<template #icon>
<icon-list />
</template>
</a-button>
<!-- 授权 -->
<a-button type="primary" @click="doGrant">
授权
<template #icon>
<icon-safe />
</template>
</a-button>
</a-space>
</div>
<!-- 主体部分 -->
<div class="data-main">
<slot />
</div>
</div>
</a-spin>
</template>
<script lang="ts">
export default {
name: 'grantLayout'
};
</script>
<script lang="ts" setup>
import type { TabRouterItem } from '@/components/view/tab-router/types';
import type { AssetAuthorizedDataQueryRequest, AssetDataGrantRequest } from '@/api/asset/asset-data-grant';
import { ref } from 'vue';
import { GrantType } from '../types/const';
import RouterRoles from './router-roles.vue';
import RouterUsers from './router-users.vue';
const props = defineProps<{
type: string;
loading: boolean;
}>();
const emits = defineEmits(['fetch', 'grant', 'selectAll', 'reverse']);
const subjectId = ref();
const currentSubject = ref();
// 获取授权列表
const fetchAuthorizedData = async (id: number, subject: TabRouterItem) => {
currentSubject.value = subject;
if (props.type === GrantType.ROLE) {
emits('fetch', { roleId: id } as AssetAuthorizedDataQueryRequest);
} else if (props.type === GrantType.USER) {
emits('fetch', { userId: id } as AssetAuthorizedDataQueryRequest);
}
};
// 授权
const doGrant = async () => {
if (props.type === GrantType.ROLE) {
emits('grant', { roleId: subjectId.value } as AssetDataGrantRequest);
} else if (props.type === GrantType.USER) {
emits('grant', { userId: subjectId.value } as AssetDataGrantRequest);
}
};
</script>
<style lang="less" scoped>
.grant-container {
width: 100%;
height: 100%;
display: flex;
padding: 0 12px 12px 0;
position: absolute;
.router-wrapper {
margin-right: 16px;
border-right: 1px var(--color-neutral-3) solid;
}
.data-container {
position: relative;
width: 100%;
height: 100%;
.data-header {
display: flex;
justify-content: space-between;
margin-bottom: 16px;
align-items: center;
.alert-wrapper {
padding: 4px 16px;
margin-right: 12px;
.alert-message {
display: block;
height: 22px;
}
}
}
.data-main {
display: flex;
position: absolute;
width: 100%;
height: calc(100% - 48px);
}
}
}
</style>

View File

@@ -0,0 +1,128 @@
<template>
<grant-layout :type="type"
:loading="loading"
@fetch="fetchAuthorizedData"
@grant="doGrant"
@select-all="selectAll"
@reverse="reverseSelect">
<!-- 分组 -->
<host-group-tree v-model:checked-keys="checkedGroups"
ref="tree"
outer-class="group-main-tree"
:checkable="true"
:editable="false"
:loading="loading"
@set-loading="setLoading"
@selected-node="(e) => selectedGroup = e"
@on-selected="clickGroup" />
<!-- 主机列表 -->
<host-list class="group-main-hosts sticky-list" :group="selectedGroup" />
</grant-layout>
</template>
<script lang="ts">
export default {
name: 'hostGroupGrant'
};
</script>
<script lang="ts" setup>
import type { TreeNodeData } from '@arco-design/web-vue';
import type { AssetAuthorizedDataQueryRequest, AssetDataGrantRequest } from '@/api/asset/asset-data-grant';
import { ref } from 'vue';
import useLoading from '@/hooks/loading';
import { getAuthorizedHostGroup, grantHostGroup } from '@/api/asset/asset-data-grant';
import { useCacheStore } from '@/store';
import { flatNodeKeys } from '@/utils/tree';
import { Message } from '@arco-design/web-vue';
import HostGroupTree from '@/components/asset/host-group/tree/index.vue';
import HostList from './host-list.vue';
import GrantLayout from './grant-layout.vue';
const props = defineProps<{
type: string;
}>();
const cacheStore = useCacheStore();
const { loading, setLoading } = useLoading();
const tree = ref();
const authorizedGroups = ref<Array<number>>([]);
const checkedGroups = ref<Array<number>>([]);
const selectedGroup = ref<TreeNodeData>({});
// 点击分组
const clickGroup = (groups: Array<number>) => {
if (groups && groups.length) {
const group = groups[0];
const index = checkedGroups.value.findIndex((s) => s === group);
if (index === -1) {
checkedGroups.value.push(group);
} else {
checkedGroups.value.splice(index, 1);
}
}
};
// 获取授权列表
const fetchAuthorizedData = async (request: AssetAuthorizedDataQueryRequest) => {
setLoading(true);
try {
const { data } = await getAuthorizedHostGroup(request);
authorizedGroups.value = data;
checkedGroups.value = data;
} catch (e) {
} finally {
setLoading(false);
}
};
// 授权
const doGrant = async (request: AssetDataGrantRequest) => {
setLoading(true);
try {
// 执行授权
await grantHostGroup({
...request,
idList: checkedGroups.value
});
Message.success('授权成功');
} catch (e) {
} finally {
setLoading(false);
}
// 查询数据
await fetchAuthorizedData(request);
};
// 全选
const selectAll = async () => {
// 从缓存中查询全部分组
const groups = await cacheStore.loadHostGroups();
const groupKeys: number[] = [];
flatNodeKeys(groups, groupKeys);
checkedGroups.value = groupKeys;
};
// 反选
const reverseSelect = async () => {
// 从缓存中查询全部分组
const groups = await cacheStore.loadHostGroups();
const groupKeys: number[] = [];
flatNodeKeys(groups, groupKeys);
checkedGroups.value = groupKeys.filter(s => !checkedGroups.value.includes(s));
};
</script>
<style lang="less" scoped>
.group-main-tree {
width: calc(60% - 16px);
height: 100%;
margin-right: 16px;
}
.group-main-hosts {
width: 40%;
}
</style>

View File

@@ -0,0 +1,149 @@
<template>
<grant-layout :type="type"
:loading="loading"
@fetch="fetchAuthorizedData"
@grant="doGrant"
@select-all="selectAll"
@reverse="reverseSelect">
<!-- 主机身份表格 -->
<a-table row-key="id"
class="host-identity-main-table"
:columns="hostIdentityColumns"
v-model:selected-keys="selectedKeys"
:row-selection="rowSelection"
row-class="pointer"
:sticky-header="true"
:data="hostIdentities"
:pagination="false"
:bordered="false"
@row-click="clickRow">
<!-- 类型 -->
<template #type="{ record }">
<a-tag :color="getDictValue(identityTypeKey, record.type, 'color')">
{{ getDictValue(identityTypeKey, record.type) }}
</a-tag>
</template>
<!-- 秘钥名称 -->
<template #keyId="{ record }">
<!-- 有秘钥 -->
<template v-if="record.keyId && record.type === 'KEY'">
<a-tag color="arcoblue" v-if="record.keyId">
{{ hostKeys.find(s => s.id === record.keyId)?.name }}
</a-tag>
</template>
<!-- 无秘钥 -->
<template v-else>
<span>-</span>
</template>
</template>
</a-table>
</grant-layout>
</template>
<script lang="ts">
export default {
name: 'hostIdentityGrant'
};
</script>
<script lang="ts" setup>
import type { TableData } from '@arco-design/web-vue/es/table/interface';
import type { AssetAuthorizedDataQueryRequest, AssetDataGrantRequest } from '@/api/asset/asset-data-grant';
import type { HostIdentityQueryResponse } from '@/api/asset/host-identity';
import type { HostKeyQueryResponse } from '@/api/asset/host-key';
import { ref, onMounted } from 'vue';
import useLoading from '@/hooks/loading';
import { useRowSelection } from '@/types/table';
import { getAuthorizedHostIdentity, grantHostIdentity } from '@/api/asset/asset-data-grant';
import { useCacheStore, useDictStore } from '@/store';
import { hostIdentityColumns } from '../types/table.columns';
import { identityTypeKey } from '../types/const';
import { Message } from '@arco-design/web-vue';
import GrantLayout from './grant-layout.vue';
const props = defineProps<{
type: string;
}>();
const cacheStore = useCacheStore();
const rowSelection = useRowSelection();
const { getDictValue } = useDictStore();
const { loading, setLoading } = useLoading();
const selectedKeys = ref<Array<number>>([]);
const hostIdentities = ref<Array<HostIdentityQueryResponse>>([]);
const hostKeys = ref<Array<HostKeyQueryResponse>>([]);
// 获取授权列表
const fetchAuthorizedData = async (request: AssetAuthorizedDataQueryRequest) => {
setLoading(true);
try {
const { data } = await getAuthorizedHostIdentity(request);
selectedKeys.value = data;
} catch (e) {
} finally {
setLoading(false);
}
};
// 授权
const doGrant = async (request: AssetDataGrantRequest) => {
setLoading(true);
try {
// 执行授权
await grantHostIdentity({
...request,
idList: selectedKeys.value
});
Message.success('授权成功');
} catch (e) {
} finally {
setLoading(false);
}
// 查询数据
await fetchAuthorizedData(request);
};
// 全选
const selectAll = () => {
selectedKeys.value = hostIdentities.value.map(s => s.id);
};
// 反选
const reverseSelect = () => {
selectedKeys.value = hostIdentities.value.map(s => s.id)
.filter(s => !selectedKeys.value.includes(s));
};
// 点击行
const clickRow = ({ id }: TableData) => {
const index = selectedKeys.value.indexOf(id);
if (index === -1) {
selectedKeys.value.push(id);
} else {
selectedKeys.value.splice(index, 1);
}
};
// 初始化身份数据
onMounted(async () => {
setLoading(true);
try {
// 加载主机身份
hostIdentities.value = await cacheStore.loadHostIdentities();
} catch (e) {
} finally {
setLoading(false);
}
});
// 初始化秘钥数据
onMounted(async () => {
// 加载主机秘钥
hostKeys.value = await cacheStore.loadHostKeys();
});
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,118 @@
<template>
<grant-layout :type="type"
:loading="loading"
@fetch="fetchAuthorizedData"
@grant="doGrant"
@select-all="selectAll"
@reverse="reverseSelect">
<!-- 主机秘钥表格 -->
<a-table row-key="id"
class="host-key-main-table"
:columns="hostKeyColumns"
v-model:selected-keys="selectedKeys"
:row-selection="rowSelection"
row-class="pointer"
:sticky-header="true"
:data="hostKeys"
:pagination="false"
:bordered="false"
@row-click="clickRow" />
</grant-layout>
</template>
<script lang="ts">
export default {
name: 'hostKeyGrant'
};
</script>
<script lang="ts" setup>
import type { TableData } from '@arco-design/web-vue/es/table/interface';
import type { AssetAuthorizedDataQueryRequest, AssetDataGrantRequest } from '@/api/asset/asset-data-grant';
import type { HostKeyQueryResponse } from '@/api/asset/host-key';
import { ref, onMounted } from 'vue';
import { getAuthorizedHostKey, grantHostKey } from '@/api/asset/asset-data-grant';
import useLoading from '@/hooks/loading';
import { useRowSelection } from '@/types/table';
import { useCacheStore } from '@/store';
import { Message } from '@arco-design/web-vue';
import { hostKeyColumns } from '../types/table.columns';
import GrantLayout from './grant-layout.vue';
const props = defineProps<{
type: string;
}>();
const cacheStore = useCacheStore();
const rowSelection = useRowSelection();
const { loading, setLoading } = useLoading();
const selectedKeys = ref<Array<number>>([]);
const hostKeys = ref<Array<HostKeyQueryResponse>>([]);
// 获取授权列表
const fetchAuthorizedData = async (request: AssetAuthorizedDataQueryRequest) => {
setLoading(true);
try {
const { data } = await getAuthorizedHostKey(request);
selectedKeys.value = data;
} catch (e) {
} finally {
setLoading(false);
}
};
// 授权
const doGrant = async (request: AssetDataGrantRequest) => {
setLoading(true);
try {
// 执行授权
await grantHostKey({
...request,
idList: selectedKeys.value
});
Message.success('授权成功');
} catch (e) {
} finally {
setLoading(false);
}
// 查询数据
await fetchAuthorizedData(request);
};
// 全选
const selectAll = () => {
selectedKeys.value = hostKeys.value.map(s => s.id);
};
// 反选
const reverseSelect = () => {
selectedKeys.value = hostKeys.value.map(s => s.id)
.filter(s => !selectedKeys.value.includes(s));
};
// 点击行
const clickRow = ({ id }: TableData) => {
const index = selectedKeys.value.indexOf(id);
if (index === -1) {
selectedKeys.value.push(id);
} else {
selectedKeys.value.splice(index, 1);
}
};
// 初始化数据
onMounted(async () => {
setLoading(true);
try {
hostKeys.value = await cacheStore.loadHostKeys();
} catch (e) {
} finally {
setLoading(false);
}
});
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,112 @@
<template>
<a-list size="small"
max-height="100%"
:hoverable="true"
:data="selectedGroupHosts"
:loading="loading">
<!-- 表头 -->
<template #header>
<span class="hosts-header-title">组内数据</span>
<span class="span-blue">{{ group?.title }}</span>
</template>
<!-- 空数据 -->
<template #empty>
<span class="host-list-empty">当前分组未配置主机</span>
</template>
<!-- 数据 -->
<template #item="{ item }">
<a-tooltip :content="`${item.name} - ${item.address}`">
<a-list-item>
<icon-desktop class="host-list-icon" />
<span>{{ `${item.name} - ` }}</span>
<span class="span-blue">{{ item.address }}</span>
</a-list-item>
</a-tooltip>
</template>
</a-list>
</template>
<script lang="ts">
export default {
name: 'hostList'
};
</script>
<script lang="ts" setup>
import type { TreeNodeData } from '@arco-design/web-vue';
import type { HostQueryResponse } from '@/api/asset/host';
import useLoading from '@/hooks/loading';
import { useCacheStore } from '@/store';
import { ref, watch } from 'vue';
import { getHostGroupRelList } from '@/api/asset/host-group';
const props = defineProps<Partial<{
group: TreeNodeData;
}>>();
const cacheStore = useCacheStore();
const { loading, setLoading } = useLoading();
const selectedGroupHosts = ref<Array<HostQueryResponse>>([]);
// 监听分组变化 加载组内数据
watch(() => props.group?.key, async (groupId) => {
if (!groupId) {
return;
}
// 加载组内数据
try {
setLoading(true);
const { data } = await getHostGroupRelList(groupId as number);
const hosts = await cacheStore.loadHosts();
selectedGroupHosts.value = data.map(s => hosts.find(h => h.id === s) as HostQueryResponse)
.filter(Boolean);
} catch (e) {
} finally {
setLoading(false);
}
});
</script>
<style lang="less" scoped>
.hosts-header-title {
&:after {
content: '-';
margin: 0 8px;
}
}
.host-list-empty {
padding: 16px 24px;
text-align: center;
color: var(--color-text-2);
display: block;
}
:deep(.arco-scrollbar) {
width: 100%;
height: 100%;
}
:deep(.arco-list-item-content) {
display: flex;
align-items: center;
color: var(--color-text-1);
overflow: hidden;
word-break: keep-all;
white-space: pre;
.host-list-icon {
font-size: 24px;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-white);
background: rgb(var(--blue-6));
margin-right: 8px;
border-radius: 4px;
}
}
</style>

View File

@@ -0,0 +1,109 @@
<template>
<a-scrollbar>
<div class="role-container">
<!-- 角色列表 -->
<tab-router v-if="rolesRouter.length"
class="role-router"
v-model="value"
:items="rolesRouter"
@change="(key: number, item: any) => emits('change', key, item)" />
<!-- 加载中 -->
<a-skeleton v-else-if="loading"
class="skeleton-wrapper"
:animation="true">
<a-skeleton-line :rows="4" />
</a-skeleton>
<!-- 暂无数据 -->
<a-empty v-else class="role-empty">
<div slot="description">
暂无角色数据
</div>
</a-empty>
</div>
</a-scrollbar>
</template>
<script lang="ts">
export default {
name: 'routerRoles'
};
</script>
<script lang="ts" setup>
import type { TabRouterItem } from '@/components/view/tab-router/types';
import { computed, onMounted, ref } from 'vue';
import { useCacheStore } from '@/store';
import { Message } from '@arco-design/web-vue';
import useLoading from '@/hooks/loading';
const props = defineProps<Partial<{
modelValue: number;
}>>();
const emits = defineEmits(['update:modelValue', 'change']);
const { loading, setLoading } = useLoading();
const cacheStore = useCacheStore();
const rolesRouter = ref<Array<TabRouterItem>>([]);
const value = computed({
get() {
return props.modelValue;
},
set(e) {
emits('update:modelValue', e);
}
});
// 加载角色列表
const loadRoleList = async () => {
setLoading(true);
try {
const roles = await cacheStore.loadRoles();
rolesRouter.value = roles.map(s => {
return {
key: s.id,
text: `${s.name} (${s.code})`,
code: s.code
};
});
} catch (e) {
Message.error('角色列表加载失败');
} finally {
setLoading(false);
}
};
// 加载角色列表
onMounted(() => {
loadRoleList();
});
</script>
<style lang="less" scoped>
@width: 198px;
@height: 198px;
:deep(.arco-scrollbar-container) {
height: 100%;
overflow-y: auto;
}
.role-container {
overflow: hidden;
.role-router {
height: 100%;
min-width: @width;
width: max-content;
}
.skeleton-wrapper, .role-empty {
width: @width;
height: 200px;
padding: 8px;
}
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<a-scrollbar>
<div class="user-container">
<!-- 用户列表 -->
<tab-router v-if="usersRouter.length"
class="user-router"
v-model="value"
:items="usersRouter"
@change="(key: number, item: any) => emits('change', key, item)" />
<!-- 加载中 -->
<a-skeleton v-else-if="loading"
class="skeleton-wrapper"
:animation="true">
<a-skeleton-line :rows="4" />
</a-skeleton>
<!-- 暂无数据 -->
<a-empty v-else class="user-empty">
<div slot="description">
暂无用户数据
</div>
</a-empty>
</div>
</a-scrollbar>
</template>
<script lang="ts">
export default {
name: 'routerUsers'
};
</script>
<script lang="ts" setup>
import type { TabRouterItem } from '@/components/view/tab-router/types';
import { computed, onMounted, ref } from 'vue';
import { useCacheStore } from '@/store';
import { Message } from '@arco-design/web-vue';
import useLoading from '@/hooks/loading';
const props = defineProps<Partial<{
modelValue: number;
}>>();
const emits = defineEmits(['update:modelValue', 'change']);
const { loading, setLoading } = useLoading();
const cacheStore = useCacheStore();
const usersRouter = ref<Array<TabRouterItem>>([]);
const value = computed({
get() {
return props.modelValue;
},
set(e) {
emits('update:modelValue', e);
}
});
// 加载用户列表
const loadUserList = async () => {
setLoading(true);
try {
const users = await cacheStore.loadUsers();
usersRouter.value = users.map(s => {
return {
key: s.id,
text: `${s.nickname} (${s.username})`
};
});
} catch (e) {
Message.error('用户列表加载失败');
} finally {
setLoading(false);
}
};
// 加载用户列表
onMounted(() => {
loadUserList();
});
</script>
<style lang="less" scoped>
@width: 198px;
@height: 198px;
:deep(.arco-scrollbar-container) {
height: 100%;
overflow-y: auto;
}
.user-container {
overflow: hidden;
.user-router {
height: 100%;
min-width: @width;
width: max-content;
}
.skeleton-wrapper, .user-empty {
width: @width;
height: @height;
padding: 8px;
}
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<div class="view-container">
<a-tabs v-model:active-key="activeKey"
class="tabs-container simple-card"
size="large"
:justify="true"
:lazy-load="true">
<a-tab-pane v-for="tab in GrantTabs"
v-permission="tab.permission"
:key="tab.key">
<template #title>
<component :is="tab.icon" />
{{ tab.title }}
</template>
<component :is="tab.component" :type="tab.type" />
</a-tab-pane>
</a-tabs>
</div>
</template>
<script lang="ts">
export default {
name: 'assetGrant'
};
</script>
<script lang="ts" setup>
import { onBeforeMount, onUnmounted, ref } from 'vue';
import { useCacheStore, useDictStore } from '@/store';
import { GrantTabs, dictKeys } from './types/const';
import { useRoute } from 'vue-router';
const route = useRoute();
const cacheStore = useCacheStore();
const activeKey = ref();
// 加载字典项
onBeforeMount(async () => {
const dictStore = useDictStore();
await dictStore.loadKeys(dictKeys);
});
// 跳转到指定页
onBeforeMount(() => {
const key = route.query.key;
if (key) {
activeKey.value = Number(key);
}
});
// 卸载时清除 cache
onUnmounted(() => {
cacheStore.reset('users', 'roles', 'hosts', 'hostGroups', 'hostKeys', 'hostIdentities');
});
</script>
<style lang="less" scoped>
.view-container {
display: flex;
width: 100%;
height: 100%;
position: relative;
padding: 16px;
}
.tabs-container {
width: 100%;
height: 100%;
}
:deep(.arco-tabs-pane) {
width: 100%;
height: 100%;
position: relative;
}
:deep(.arco-tabs-nav-type-line .arco-tabs-tab) {
margin: 0 12px;
}
:deep(.arco-tabs-tab-title) {
user-select: none;
font-size: 15px;
padding: 0 4px;
}
:deep(.arco-tabs-content) {
position: relative;
}
</style>

View File

@@ -0,0 +1,81 @@
import HostGroupGrant from '../components/host-group-grant.vue';
import HostKeyGrant from '../components/host-key-grant.vue';
import HostIdentityGrant from '../components/host-identity-grant.vue';
// 路由
export const GrantRouteName = 'assetGrant';
// 授权 key
export const GrantKey = {
// 主机分组-角色
HOST_GROUP_ROLE: 1,
// 主机分组-用户
HOST_GROUP_USER: 2,
// 主机秘钥-角色
HOST_KEY_ROLE: 3,
// 主机秘钥-用户
HOST_KEY_USER: 4,
// 主机身份-角色
HOST_IDENTITY_ROLE: 5,
// 主机身份-用户
HOST_IDENTITY_USER: 6,
};
// 授权类型
export const GrantType = {
ROLE: 'role',
USER: 'user',
};
// 授权 tab 组件
export const GrantTabs = [
{
key: GrantKey.HOST_GROUP_ROLE,
permission: ['asset:host-group:grant'],
title: '主机分组授权 - 角色',
icon: 'icon-desktop',
type: GrantType.ROLE,
component: HostGroupGrant
}, {
key: GrantKey.HOST_GROUP_USER,
permission: ['asset:host-group:grant'],
title: '主机分组授权 - 用户',
icon: 'icon-desktop',
type: GrantType.USER,
component: HostGroupGrant
}, {
key: GrantKey.HOST_KEY_ROLE,
permission: ['asset:host-key:grant'],
title: '主机秘钥授权 - 角色',
icon: 'icon-lock',
type: GrantType.ROLE,
component: HostKeyGrant
}, {
key: GrantKey.HOST_KEY_USER,
permission: ['asset:host-key:grant'],
title: '主机秘钥授权 - 用户',
icon: 'icon-lock',
type: GrantType.USER,
component: HostKeyGrant
}, {
key: GrantKey.HOST_IDENTITY_ROLE,
permission: ['asset:host-identity:grant'],
title: '主机身份授权 - 角色',
icon: 'icon-idcard',
type: GrantType.ROLE,
component: HostIdentityGrant
}, {
key: GrantKey.HOST_IDENTITY_USER,
permission: ['asset:host-identity:grant'],
title: '主机身份授权 - 用户',
icon: 'icon-idcard',
type: GrantType.USER,
component: HostIdentityGrant
},
];
// 身份类型 字典项
export const identityTypeKey = 'hostIdentityType';
// 加载的字典值
export const dictKeys = [identityTypeKey];

View File

@@ -0,0 +1,80 @@
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface';
import { dateFormat } from '@/utils';
// 主机秘钥列
export const hostKeyColumns = [
{
title: 'id',
dataIndex: 'id',
slotName: 'id',
width: 100,
align: 'left',
fixed: 'left',
}, {
title: '名称',
dataIndex: 'name',
slotName: 'name',
ellipsis: true,
tooltip: true
}, {
title: '创建时间',
dataIndex: 'createTime',
slotName: 'createTime',
align: 'center',
width: 180,
render: ({ record }) => {
return dateFormat(new Date(record.createTime));
},
}, {
title: '修改时间',
dataIndex: 'updateTime',
slotName: 'updateTime',
align: 'center',
width: 180,
render: ({ record }) => {
return dateFormat(new Date(record.updateTime));
},
},
] as TableColumnData[];
// 主机身份列
export const hostIdentityColumns = [
{
title: 'id',
dataIndex: 'id',
slotName: 'id',
width: 70,
align: 'left',
fixed: 'left',
}, {
title: '名称',
dataIndex: 'name',
slotName: 'name',
ellipsis: true,
tooltip: true
}, {
title: '类型',
dataIndex: 'type',
slotName: 'type',
width: 98,
}, {
title: '用户名',
dataIndex: 'username',
slotName: 'username',
ellipsis: true,
tooltip: true
}, {
title: '主机秘钥',
dataIndex: 'keyId',
slotName: 'keyId',
}, {
title: '修改时间',
dataIndex: 'updateTime',
slotName: 'updateTime',
align: 'center',
width: 180,
render: ({ record }) => {
return dateFormat(new Date(record.updateTime));
},
},
] as TableColumnData[];

View File

@@ -0,0 +1,273 @@
<template>
<card-list v-model:searchValue="formModel.searchValue"
search-input-placeholder="输入 id / 名称 / 用户名"
:loading="loading"
:fieldConfig="fieldConfig"
:list="list"
:pagination="pagination"
:card-layout-cols="cardColLayout"
:filter-count="filterCount"
:add-permission="['asset:host-identity:create']"
@add="emits('openAdd')"
@reset="reset"
@search="fetchCardData"
@page-change="fetchCardData">
<!-- 左侧操作 -->
<template #leftHandle>
<!-- 角色授权 -->
<a-button v-permission="['asset:host-identity:grant']"
class="card-header-icon-wrapper"
@click="$router.push({ name: GrantRouteName, query: { key: GrantKey.HOST_IDENTITY_ROLE }})">
角色授权
<template #icon>
<icon-user-group />
</template>
</a-button>
<!-- 用户授权 -->
<a-button v-permission="['asset:host-identity:grant']"
class="card-header-icon-wrapper"
@click="$router.push({ name: GrantRouteName, query: { key: GrantKey.HOST_IDENTITY_USER }})">
用户授权
<template #icon>
<icon-user />
</template>
</a-button>
</template>
<!-- 过滤条件 -->
<template #filterContent>
<a-form :model="formModel"
class="card-filter-form"
size="small"
ref="formRef"
label-align="right"
:auto-label-width="true"
@keyup.enter="() => fetchCardData()">
<!-- id -->
<a-form-item field="id" label="id">
<a-input-number v-model="formModel.id"
placeholder="请输入id"
allow-clear
hide-button />
</a-form-item>
<!-- 名称 -->
<a-form-item field="name" label="名称">
<a-input v-model="formModel.name" placeholder="请输入名称" allow-clear />
</a-form-item>
<!-- 类型 -->
<a-form-item field="type" label="类型">
<a-select v-model="formModel.type"
placeholder="请选择类型"
:options="toOptions(identityTypeKey)"
allow-clear />
</a-form-item>
<!-- 用户名 -->
<a-form-item field="username" label="用户名">
<a-input v-model="formModel.username" placeholder="请输入用户名" allow-clear />
</a-form-item>
<!-- 秘钥 -->
<a-form-item field="keyId" label="秘钥">
<host-key-selector v-model="formModel.keyId" allow-clear />
</a-form-item>
</a-form>
</template>
<!-- 标题 -->
<template #title="{ record }">
{{ record.name }}
</template>
<!-- 类型 -->
<template #type="{ record }">
<a-tag :color="getDictValue(identityTypeKey, record.type, 'color')">
{{ getDictValue(identityTypeKey, record.type) }}
</a-tag>
</template>
<!-- 用户名 -->
<template #username="{ record }">
<span class="span-blue text-copy" @click="copy(record.username)">
{{ record.username }}
</span>
</template>
<!-- 秘钥名称 -->
<template #keyId="{ record }">
<!-- 有秘钥 -->
<template v-if="record.keyId && record.type === IdentityType.KEY">
<!-- 可查看详情 -->
<a-tooltip v-if="hasAnyPermission(['asset:host-key:detail', 'asset:host-key:update'])"
content="点击查看详情">
<a-tag :checked="true"
checkable
@click="emits('openKeyView', { id: record.keyId })">
{{ record.keyName }}
</a-tag>
</a-tooltip>
<!-- 不可查看详情 -->
<a-tag v-else>
{{ record.keyName }}
</a-tag>
</template>
<!-- 无秘钥 -->
<template v-else>
<span>-</span>
</template>
</template>
<!-- 拓展操作 -->
<template #extra="{ record }">
<a-space>
<!-- 更多操作 -->
<a-dropdown trigger="hover">
<icon-more class="card-extra-icon" />
<template #content>
<!-- 修改 -->
<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>
</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>
<script lang="ts">
export default {
name: 'hostIdentityCardList'
};
</script>
<script lang="ts" setup>
import type { HostIdentityQueryRequest, HostIdentityQueryResponse } from '@/api/asset/host-identity';
import { usePagination, useColLayout } from '@/types/card';
import { computed, reactive, ref, onMounted } from 'vue';
import useLoading from '@/hooks/loading';
import { objectTruthKeyCount, resetObject } from '@/utils';
import fieldConfig from '../types/card.fields';
import { deleteHostIdentity, getHostIdentityPage } from '@/api/asset/host-identity';
import { Message, Modal } from '@arco-design/web-vue';
import usePermission from '@/hooks/permission';
import { useDictStore } from '@/store';
import { copy } from '@/hooks/copy';
import { GrantKey, GrantRouteName } from '@/views/asset/grant/types/const';
import { IdentityType, identityTypeKey } from '../types/const';
import HostKeySelector from '@/components/asset/host-key/selector/index.vue';
const emits = defineEmits(['openAdd', 'openUpdate', 'openKeyView']);
const list = ref<HostIdentityQueryResponse[]>([]);
const cardColLayout = useColLayout();
const pagination = usePagination();
const { toOptions, getDictValue } = useDictStore();
const { loading, setLoading } = useLoading();
const { hasAnyPermission } = usePermission();
const formRef = ref();
const formModel = reactive<HostIdentityQueryRequest>({
searchValue: undefined,
id: undefined,
type: undefined,
name: undefined,
username: undefined,
keyId: undefined,
});
// 条件数量
const filterCount = computed(() => {
return objectTruthKeyCount(formModel, ['searchValue']);
});
// 删除当前行
const deleteRow = (id: number) => {
Modal.confirm({
title: '删除前确认!',
titleAlign: 'start',
content: '确定要删除这条记录吗?',
okText: '删除',
onOk: async () => {
try {
setLoading(true);
// 调用删除接口
await deleteHostIdentity(id);
Message.success('删除成功');
// 重新加载数据
fetchCardData();
} catch (e) {
} finally {
setLoading(false);
}
}
});
};
// 添加后回调
const addedCallback = () => {
fetchCardData();
};
// 更新后回调
const updatedCallback = () => {
fetchCardData();
};
defineExpose({
addedCallback, updatedCallback
});
// 重置条件
const reset = () => {
resetObject(formModel);
fetchCardData();
};
// 加载数据
const doFetchCardData = async (request: HostIdentityQueryRequest) => {
try {
setLoading(true);
const { data } = await getHostIdentityPage(request);
list.value = data.rows;
pagination.total = data.total;
pagination.current = request.page;
pagination.pageSize = request.limit;
} catch (e) {
} finally {
setLoading(false);
}
};
// 切换页码
const fetchCardData = (page = 1, limit = pagination.pageSize, form = formModel) => {
doFetchCardData({ page, limit, ...form });
};
onMounted(() => {
fetchCardData();
});
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,199 @@
<template>
<a-modal v-model:visible="visible"
body-class="modal-form-large"
title-align="start"
:title="title"
:top="80"
:align-center="false"
:draggable="true"
:mask-closable="false"
:unmount-on-close="true"
:ok-button-props="{ disabled: loading }"
:cancel-button-props="{ disabled: loading }"
:on-before-ok="handlerOk"
@close="handleClose">
<a-spin class="full" :loading="loading">
<a-form :model="formModel"
ref="formRef"
label-align="right"
:auto-label-width="true"
:rules="formRules">
<!-- 名称 -->
<a-form-item field="name" label="名称">
<a-input v-model="formModel.name" placeholder="请输入名称" />
</a-form-item>
<!-- 类型 -->
<a-form-item field="type" label="类型">
<a-radio-group v-model="formModel.type"
type="button"
class="usn"
:options="toRadioOptions(identityTypeKey)" />
</a-form-item>
<!-- 用户名 -->
<a-form-item field="username" label="用户名">
<a-input v-model="formModel.username" placeholder="请输入用户名" />
</a-form-item>
<!-- 用户密码 -->
<a-form-item v-if="formModel.type === IdentityType.PASSWORD"
field="password"
label="用户密码"
:rules="passwordRules">
<a-input-password v-model="formModel.password"
:disabled="!isAddHandle && !formModel.useNewPassword"
:class="[isAddHandle ? 'password-input-full' : 'password-input']"
placeholder="请输入用户密码" />
<a-switch v-model="formModel.useNewPassword"
v-if="!isAddHandle"
class="password-switch"
type="round"
checked-text="使用新密码"
unchecked-text="使用原密码" />
</a-form-item>
<!-- 主机秘钥 -->
<a-form-item v-if="formModel.type === IdentityType.KEY"
field="keyId"
label="主机秘钥">
<host-key-selector v-model="formModel.keyId" />
</a-form-item>
</a-form>
</a-spin>
</a-modal>
</template>
<script lang="ts">
export default {
name: 'hostIdentityFormModal'
};
</script>
<script lang="ts" setup>
import type { HostIdentityUpdateRequest } from '@/api/asset/host-identity';
import type { FieldRule } from '@arco-design/web-vue';
import { ref } from 'vue';
import useLoading from '@/hooks/loading';
import useVisible from '@/hooks/visible';
import formRules from '../types/form.rules';
import { createHostIdentity, updateHostIdentity } from '@/api/asset/host-identity';
import { Message } from '@arco-design/web-vue';
import { IdentityType, identityTypeKey } from '../types/const';
import { useDictStore } from '@/store';
import HostKeySelector from '@/components/asset/host-key/selector/index.vue';
const { toRadioOptions, getDictValue } = useDictStore();
const { visible, setVisible } = useVisible();
const { loading, setLoading } = useLoading();
const title = ref<string>();
const isAddHandle = ref<boolean>(true);
const defaultForm = (): HostIdentityUpdateRequest => {
return {
id: undefined,
type: IdentityType.PASSWORD,
name: undefined,
username: undefined,
password: undefined,
useNewPassword: false,
keyId: undefined,
};
};
const formRef = ref();
const formModel = ref<HostIdentityUpdateRequest>({});
const emits = defineEmits(['added', 'updated']);
// 打开新增
const openAdd = () => {
title.value = '添加主机身份';
isAddHandle.value = true;
renderForm({ ...defaultForm() });
setVisible(true);
};
// 打开修改
const openUpdate = (record: any) => {
title.value = '修改主机身份';
isAddHandle.value = false;
renderForm({ ...defaultForm(), ...record });
setVisible(true);
};
// 渲染表单
const renderForm = (record: any) => {
formModel.value = Object.assign({}, record);
};
defineExpose({ openAdd, openUpdate });
// 密码验证
const passwordRules = [{
validator: (value, cb) => {
if (value && value.length > 512) {
cb('密码长度不能大于512位');
return;
}
if (formModel.value.useNewPassword && !value) {
cb('请输入密码');
return;
}
}
}] as FieldRule[];
// 确定
const handlerOk = async () => {
setLoading(true);
try {
// 验证参数
const error = await formRef.value.validate();
if (error) {
return false;
}
if (isAddHandle.value) {
// 新增
await createHostIdentity(formModel.value);
Message.success('创建成功');
emits('added');
} else {
// 修改
await updateHostIdentity(formModel.value);
Message.success('修改成功');
emits('updated');
}
// 清空
handlerClear();
} catch (e) {
return false;
} finally {
setLoading(false);
}
};
// 关闭
const handleClose = () => {
handlerClear();
};
// 清空
const handlerClear = () => {
setLoading(false);
};
</script>
<style lang="less" scoped>
@switch-width: 94px;
.password-input {
width: calc(100% - @switch-width);
}
.password-input-full {
width: 100%;
}
.password-switch {
width: @switch-width;
margin-left: 16px;
}
</style>

View File

@@ -0,0 +1,249 @@
<template>
<!-- 搜索 -->
<a-card class="general-card table-search-card">
<query-header :model="formModel"
label-align="left"
@submit="fetchTableData"
@reset="fetchTableData"
@keyup.enter="() => fetchTableData()">
<!-- id -->
<a-form-item field="id" label="id">
<a-input-number v-model="formModel.id"
placeholder="请输入id"
allow-clear
hide-button />
</a-form-item>
<!-- 名称 -->
<a-form-item field="name" label="名称">
<a-input v-model="formModel.name" placeholder="请输入名称" allow-clear />
</a-form-item>
<!-- 类型 -->
<a-form-item field="type" label="类型">
<a-select v-model="formModel.type"
placeholder="请选择类型"
:options="toOptions(identityTypeKey)"
allow-clear />
</a-form-item>
<!-- 用户名 -->
<a-form-item field="username" label="用户名">
<a-input v-model="formModel.username" placeholder="请输入用户名" allow-clear />
</a-form-item>
<!-- 主机秘钥 -->
<a-form-item field="keyId" label="主机秘钥">
<host-key-selector v-model="formModel.keyId" allow-clear />
</a-form-item>
</query-header>
</a-card>
<!-- 表格 -->
<a-card class="general-card table-card">
<template #title>
<!-- 左侧操作 -->
<div class="table-left-bar-handle">
<!-- 标题 -->
<div class="table-title">
身份列表
</div>
</div>
<!-- 右侧操作 -->
<div class="table-right-bar-handle">
<a-space>
<!-- 角色授权 -->
<a-button type="primary"
v-permission="['asset:host-identity:grant']"
@click="$router.push({ name: GrantRouteName, query: { key: GrantKey.HOST_IDENTITY_ROLE }})">
角色授权
<template #icon>
<icon-user-group />
</template>
</a-button>
<!-- 用户授权 -->
<a-button type="primary"
v-permission="['asset:host-identity:grant']"
@click="$router.push({ name: GrantRouteName, query: { key: GrantKey.HOST_IDENTITY_USER }})">
用户授权
<template #icon>
<icon-user />
</template>
</a-button>
<!-- 新增 -->
<a-button type="primary"
v-permission="['asset:host-identity:create']"
@click="emits('openAdd')">
新增
<template #icon>
<icon-plus />
</template>
</a-button>
</a-space>
</div>
</template>
<!-- table -->
<a-table row-key="id"
ref="tableRef"
:loading="loading"
:columns="columns"
:data="tableRenderData"
:pagination="pagination"
@page-change="(page) => fetchTableData(page, pagination.pageSize)"
@page-size-change="(size) => fetchTableData(1, size)"
:bordered="false">
<!-- 类型 -->
<template #type="{ record }">
<a-tag :color="getDictValue(identityTypeKey, record.type, 'color')">
{{ getDictValue(identityTypeKey, record.type) }}
</a-tag>
</template>
<!-- 用户名 -->
<template #username="{ record }">
<span class="span-blue text-copy" @click="copy(record.username)">
{{ record.username }}
</span>
</template>
<!-- 秘钥名称 -->
<template #keyId="{ record }">
<!-- 有秘钥 -->
<template v-if="record.keyId && record.type === IdentityType.KEY">
<!-- 可查看详情 -->
<a-tooltip v-if="hasAnyPermission(['asset:host-key:detail', 'asset:host-key:update'])"
content="点击查看详情">
<a-tag :checked="true"
checkable
@click="emits('openKeyView', { id: record.keyId })">
{{ record.keyName }}
</a-tag>
</a-tooltip>
<!-- 不可查看详情 -->
<a-tag v-else>
{{ record.keyName }}
</a-tag>
</template>
<!-- 无秘钥 -->
<template v-else>
<span>-</span>
</template>
</template>
<!-- 操作 -->
<template #handle="{ record }">
<div class="table-handle-wrapper">
<!-- 修改 -->
<a-button type="text"
size="mini"
v-permission="['asset:host-identity:update']"
@click="emits('openUpdate', record)">
修改
</a-button>
<!-- 删除 -->
<a-popconfirm content="确认删除这条记录吗?"
position="left"
type="warning"
@ok="deleteRow(record)">
<a-button v-permission="['asset:host-identity:delete']"
type="text"
size="mini"
status="danger">
删除
</a-button>
</a-popconfirm>
</div>
</template>
</a-table>
</a-card>
</template>
<script lang="ts">
export default {
name: 'hostIdentityTable'
};
</script>
<script lang="ts" setup>
import type { HostIdentityQueryRequest, HostIdentityQueryResponse } from '@/api/asset/host-identity';
import { reactive, ref, onMounted } from 'vue';
import { deleteHostIdentity, getHostIdentityPage } from '@/api/asset/host-identity';
import { Message } from '@arco-design/web-vue';
import columns from '../types/table.columns';
import useLoading from '@/hooks/loading';
import usePermission from '@/hooks/permission';
import { copy } from '@/hooks/copy';
import { useDictStore } from '@/store';
import { usePagination } from '@/types/table';
import { GrantKey, GrantRouteName } from '@/views/asset/grant/types/const';
import { IdentityType, identityTypeKey } from '../types/const';
import HostKeySelector from '@/components/asset/host-key/selector/index.vue';
const emits = defineEmits(['openAdd', 'openUpdate', 'openKeyView']);
const pagination = usePagination();
const { toOptions, getDictValue } = useDictStore();
const { loading, setLoading } = useLoading();
const { hasAnyPermission } = usePermission();
const tableRenderData = ref<HostIdentityQueryResponse[]>([]);
const formModel = reactive<HostIdentityQueryRequest>({
id: undefined,
name: undefined,
type: undefined,
username: undefined,
keyId: undefined,
});
// 删除当前行
const deleteRow = async ({ id }: {
id: number
}) => {
try {
setLoading(true);
// 调用删除接口
await deleteHostIdentity(id);
Message.success('删除成功');
// 重新加载数据
fetchTableData();
} catch (e) {
} finally {
setLoading(false);
}
};
// 添加后回调
const addedCallback = () => {
fetchTableData();
};
// 更新后回调
const updatedCallback = () => {
fetchTableData();
};
defineExpose({
addedCallback, updatedCallback
});
// 加载数据
const doFetchTableData = async (request: HostIdentityQueryRequest) => {
try {
setLoading(true);
const { data } = await getHostIdentityPage(request);
tableRenderData.value = data.rows;
pagination.total = data.total;
pagination.current = request.page;
pagination.pageSize = request.limit;
} catch (e) {
} finally {
setLoading(false);
}
};
// 切换页码
const fetchTableData = (page = 1, limit = pagination.pageSize, form = formModel) => {
doFetchTableData({ page, limit, ...form });
};
onMounted(() => {
fetchTableData();
});
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div class="layout-container">
<!-- 列表-表格 -->
<host-identity-table v-if="renderTable"
ref="table"
@open-add="() => modal.openAdd()"
@open-update="(e) => modal.openUpdate(e)"
@open-key-view="(e) => keyDrawer.openView(e) " />
<!-- 列表-卡片 -->
<host-identity-card-list v-else
ref="card"
@open-add="() => modal.openAdd()"
@open-update="(e) => modal.openUpdate(e)"
@open-key-view="(e) => keyDrawer.openView(e) " />
<!-- 添加修改模态框 -->
<host-identity-form-modal ref="modal"
@added="modalAddCallback"
@updated="modalUpdateCallback" />
<!-- 主机秘钥抽屉 -->
<host-key-form-drawer ref="keyDrawer" />
</div>
</template>
<script lang="ts">
export default {
name: 'hostIdentity'
};
</script>
<script lang="ts" setup>
import { ref, computed, onUnmounted, onBeforeMount } from 'vue';
import { useAppStore, useCacheStore, useDictStore } from '@/store';
import { dictKeys } from './types/const';
import HostIdentityCardList from './components/host-identity-card-list.vue';
import HostIdentityTable from './components/host-identity-table.vue';
import HostIdentityFormModal from './components/host-identity-form-modal.vue';
import HostKeyFormDrawer from '../host-key/components/host-key-form-drawer.vue';
const table = ref();
const card = ref();
const modal = ref();
const keyDrawer = ref();
const appStore = useAppStore();
const cacheStore = useCacheStore();
const renderTable = computed(() => appStore.hostIdentityView === 'table');
// 添加回调
const modalAddCallback = () => {
if (renderTable.value) {
table.value.addedCallback();
} else {
card.value.addedCallback();
}
};
// 修改回调
const modalUpdateCallback = () => {
if (renderTable.value) {
table.value.updatedCallback();
} else {
card.value.updatedCallback();
}
};
// 加载字典值
onBeforeMount(async () => {
const dictStore = useDictStore();
await dictStore.loadKeys(dictKeys);
});
// 卸载时清除 cache
onUnmounted(() => {
const cacheStore = useCacheStore();
cacheStore.reset('hostKeys');
});
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,37 @@
import type { CardField, CardFieldConfig } from '@/types/card';
import { dateFormat } from '@/utils';
const fieldConfig = {
rowGap: '12px',
labelSpan: 8,
fields: [
{
label: 'id',
dataIndex: 'id',
slotName: 'id',
}, {
label: '类型',
dataIndex: 'type',
slotName: 'type',
}, {
label: '用户名',
dataIndex: 'username',
slotName: 'username',
ellipsis: true,
}, {
label: '主机秘钥',
dataIndex: 'keyId',
slotName: 'keyId',
height: '24px',
}, {
label: '修改时间',
dataIndex: 'updateTime',
slotName: 'updateTime',
render: ({ record }) => {
return dateFormat(new Date(record.updateTime));
},
}
] as CardField[]
} as CardFieldConfig;
export default fieldConfig;

View File

@@ -0,0 +1,11 @@
// 身份类型
export const IdentityType = {
PASSWORD: 'PASSWORD',
KEY: 'KEY',
};
// 身份类型 字典项
export const identityTypeKey = 'hostIdentityType';
// 加载的字典值
export const dictKeys = [identityTypeKey];

View File

@@ -0,0 +1,34 @@
import type { FieldRule } from '@arco-design/web-vue';
export const name = [{
required: true,
message: '请输入名称'
}, {
maxLength: 64,
message: '名称长度不能大于64位'
}] as FieldRule[];
export const type = [{
required: true,
message: '请选择类型'
}] as FieldRule[];
export const keyId = [{
required: true,
message: '请选择秘钥'
}] as FieldRule[];
export const username = [{
required: true,
message: '请输入用户名'
}, {
maxLength: 128,
message: '用户名长度不能大于128位'
}] as FieldRule[];
export default {
name,
type,
keyId,
username,
} as Record<string, FieldRule | FieldRule[]>;

View File

@@ -0,0 +1,49 @@
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface';
import { dateFormat } from '@/utils';
const columns = [
{
title: 'id',
dataIndex: 'id',
slotName: 'id',
width: 70,
align: 'left',
fixed: 'left',
}, {
title: '名称',
dataIndex: 'name',
slotName: 'name',
ellipsis: true,
tooltip: true
}, {
title: '类型',
dataIndex: 'type',
slotName: 'type',
width: 138,
}, {
title: '用户名',
dataIndex: 'username',
slotName: 'username',
}, {
title: '主机秘钥',
dataIndex: 'keyId',
slotName: 'keyId',
}, {
title: '修改时间',
dataIndex: 'updateTime',
slotName: 'updateTime',
align: 'center',
width: 180,
render: ({ record }) => {
return dateFormat(new Date(record.updateTime));
},
}, {
title: '操作',
slotName: 'handle',
width: 130,
align: 'center',
fixed: 'right',
},
] as TableColumnData[];
export default columns;

View File

@@ -0,0 +1,195 @@
<template>
<card-list v-model:searchValue="formModel.searchValue"
search-input-placeholder="输入 id / 名称"
:loading="loading"
:fieldConfig="fieldConfig"
:list="list"
:pagination="pagination"
:card-layout-cols="cardColLayout"
:handle-visible="{disableFilter: true}"
:add-permission="['asset:host-key:create']"
@add="emits('openAdd')"
@reset="reset"
@search="fetchCardData"
@page-change="fetchCardData">
<!-- 左侧操作 -->
<template #leftHandle>
<!-- 角色授权 -->
<a-button v-permission="['asset:host-key:grant']"
class="card-header-icon-wrapper"
@click="$router.push({ name: GrantRouteName, query: { key: GrantKey.HOST_KEY_ROLE }})">
角色授权
<template #icon>
<icon-user-group />
</template>
</a-button>
<!-- 用户授权 -->
<a-button v-permission="['asset:host-key:grant']"
class="card-header-icon-wrapper"
@click="$router.push({ name: GrantRouteName, query: { key: GrantKey.HOST_KEY_USER }})">
用户授权
<template #icon>
<icon-user />
</template>
</a-button>
</template>
<!-- 标题 -->
<template #title="{ record }">
{{ record.name }}
</template>
<!-- 拓展操作 -->
<template #extra="{ record }">
<a-space>
<!-- 更多操作 -->
<a-dropdown trigger="hover">
<icon-more class="card-extra-icon" />
<template #content>
<!-- 详情 -->
<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>
</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>
<script lang="ts">
export default {
name: 'hostKeyCardList'
};
</script>
<script lang="ts" setup>
import type { HostKeyQueryRequest, HostKeyQueryResponse } from '@/api/asset/host-key';
import { usePagination, useColLayout } from '@/types/card';
import { reactive, ref, onMounted } from 'vue';
import useLoading from '@/hooks/loading';
import { resetObject } from '@/utils';
import fieldConfig from '../types/card.fields';
import { deleteHostKey, getHostKeyPage } from '@/api/asset/host-key';
import { Message, Modal } from '@arco-design/web-vue';
import { GrantKey, GrantRouteName } from '@/views/asset/grant/types/const';
const emits = defineEmits(['openAdd', 'openUpdate', 'openView']);
const list = ref<HostKeyQueryResponse[]>([]);
const cardColLayout = useColLayout();
const pagination = usePagination();
const { loading, setLoading } = useLoading();
const formModel = reactive<HostKeyQueryRequest>({
searchValue: undefined,
});
// 删除当前行
const deleteRow = (id: number) => {
Modal.confirm({
title: '删除前确认!',
titleAlign: 'start',
content: '确定要删除这条记录吗?',
okText: '删除',
onOk: async () => {
try {
setLoading(true);
// 调用删除接口
await deleteHostKey(id);
Message.success('删除成功');
// 重新加载数据
fetchCardData();
} catch (e) {
} finally {
setLoading(false);
}
}
});
};
// 添加后回调
const addedCallback = () => {
fetchCardData();
};
// 更新后回调
const updatedCallback = () => {
fetchCardData();
};
defineExpose({
addedCallback, updatedCallback
});
// 重置条件
const reset = () => {
resetObject(formModel);
fetchCardData();
};
// 加载数据
const doFetchCardData = async (request: HostKeyQueryRequest) => {
try {
setLoading(true);
const { data } = await getHostKeyPage(request);
list.value = data.rows;
pagination.total = data.total;
pagination.current = request.page;
pagination.pageSize = request.limit;
} catch (e) {
} finally {
setLoading(false);
}
};
// 切换页码
const fetchCardData = (page = 1, limit = pagination.pageSize, form = formModel) => {
doFetchCardData({ page, limit, ...form });
};
onMounted(() => {
fetchCardData();
});
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,258 @@
<template>
<a-drawer v-model:visible="visible"
:title="title"
:width="520"
:mask-closable="false"
:unmount-on-close="true"
:ok-button-props="{ disabled: loading || isViewHandler }"
:cancel-button-props="{ disabled: loading }"
:on-before-ok="handlerOk"
@cancel="handleClose">
<a-spin class="full modal-form-small" :loading="loading">
<a-alert class="keygen-alert">
请使用 ssh-keygen -m PEM -t rsa 生成秘钥
</a-alert>
<a-form :model="formModel"
ref="formRef"
label-align="right"
:auto-label-width="true"
:rules="formRules">
<!-- 名称 -->
<a-form-item field="name" label="名称">
<a-input v-model="formModel.name"
:disabled="isViewHandler"
placeholder="请输入名称" />
</a-form-item>
<!-- 公钥文本 -->
<a-form-item field="publicKey" label="公钥">
<a-upload :auto-upload="false"
:show-file-list="false"
:draggable="true"
:disabled="isViewHandler"
@change="selectPublicFile"
@click.prevent="() => {}">
<template #upload-button>
<a-textarea v-model="formModel.publicKey"
:disabled="isViewHandler"
placeholder="请输入公钥文本或将文件拖拽到此处"
:auto-size="{ minRows: 8, maxRows: 8}" />
</template>
</a-upload>
</a-form-item>
<!-- 私钥文本 -->
<a-form-item field="privateKey" label="私钥">
<a-upload :auto-upload="false"
:show-file-list="false"
:draggable="true"
:disabled="isViewHandler"
@change="selectPrivateFile"
@click.prevent="() => {}">
<template #upload-button>
<a-textarea v-model="formModel.privateKey"
:disabled="isViewHandler"
placeholder="请输入私钥文本或将文件拖拽到此处"
:auto-size="{ minRows: 8, maxRows: 8}" />
</template>
</a-upload>
</a-form-item>
<!-- 密码 -->
<a-form-item v-if="!isViewHandler"
field="password"
label="密码"
:rules="passwordRules">
<a-input-password v-model="formModel.password"
:disabled="!isAddHandle && !formModel.useNewPassword"
:class="[isAddHandle ? 'password-input-full' : 'password-input']"
class="password-input"
placeholder="请输入私钥密码" />
<a-switch v-model="formModel.useNewPassword"
v-if="!isAddHandle"
class="password-switch"
type="round"
checked-text="使用新密码"
unchecked-text="使用原密码" />
</a-form-item>
</a-form>
</a-spin>
</a-drawer>
</template>
<script lang="ts">
export default {
name: 'hostKeyFormDrawer'
};
</script>
<script lang="ts" setup>
import type { HostKeyUpdateRequest } from '@/api/asset/host-key';
import type { FieldRule, FileItem } from '@arco-design/web-vue';
import { ref } from 'vue';
import useLoading from '@/hooks/loading';
import useVisible from '@/hooks/visible';
import formRules from '../types/form.rules';
import { createHostKey, updateHostKey, getHostKey } from '@/api/asset/host-key';
import { Message } from '@arco-design/web-vue';
import { readFileText } from '@/utils/file';
const { visible, setVisible } = useVisible();
const { loading, setLoading } = useLoading();
const title = ref<string>();
const isAddHandle = ref<boolean>(true);
const isViewHandler = ref<boolean>(false);
const defaultForm = (): HostKeyUpdateRequest => {
return {
id: undefined,
name: undefined,
publicKey: undefined,
privateKey: undefined,
password: undefined,
useNewPassword: false
};
};
const formRef = ref();
const formModel = ref<HostKeyUpdateRequest>({});
const emits = defineEmits(['added', 'updated']);
// 打开新增
const openAdd = () => {
title.value = '添加主机秘钥';
isAddHandle.value = true;
isViewHandler.value = false;
renderForm({ ...defaultForm() });
setVisible(true);
};
// 打开修改
const openUpdate = async (record: any) => {
title.value = '修改主机秘钥';
isAddHandle.value = false;
isViewHandler.value = false;
await render(record.id);
};
// 打开查看
const openView = async (record: any) => {
title.value = '主机秘钥';
isAddHandle.value = false;
isViewHandler.value = true;
await render(record.id);
};
// 渲染数据
const render = async (id: number) => {
setVisible(true);
setLoading(true);
try {
const { data } = await getHostKey(id);
renderForm({ ...data });
} catch (e) {
setVisible(false);
} finally {
setLoading(false);
}
};
// 渲染表单
const renderForm = (record: any) => {
formModel.value = Object.assign({}, record);
};
defineExpose({ openAdd, openUpdate, openView });
// 密码验证
const passwordRules = [{
validator: (value, cb) => {
if (value && value.length > 512) {
cb('密码长度不能大于512位');
return;
}
if (formModel.value.useNewPassword && !value) {
cb('请输入密码');
return;
}
}
}] as FieldRule[];
// 选择公钥文件
const selectPublicFile = async (fileList: FileItem[]) => {
formModel.value.publicKey = await readFileText(fileList[0].file as File);
formRef.value.clearValidate('publicKey');
};
// 选择私钥文件
const selectPrivateFile = async (fileList: FileItem[]) => {
formModel.value.privateKey = await readFileText(fileList[0].file as File);
formRef.value.clearValidate('privateKey');
};
// 确定
const handlerOk = async () => {
setLoading(true);
try {
// 验证参数
const error = await formRef.value.validate();
if (error) {
return false;
}
if (isAddHandle.value) {
// 新增
await createHostKey(formModel.value);
Message.success('创建成功');
emits('added');
} else {
// 修改
await updateHostKey(formModel.value);
Message.success('修改成功');
emits('updated');
}
// 清空
handlerClear();
} catch (e) {
return false;
} finally {
setLoading(false);
}
};
// 关闭
const handleClose = () => {
handlerClear();
};
// 清空
const handlerClear = () => {
setLoading(false);
};
</script>
<style lang="less" scoped>
@switch-width: 94px;
.form-wrapper {
width: 100%;
padding: 12px 12px 0 12px;
}
.keygen-alert {
margin-bottom: 12px;
width: 100%;
}
.password-input {
width: calc(100% - @switch-width);
}
.password-input-full {
width: 100%;
}
.password-switch {
width: @switch-width;
margin-left: 16px;
}
</style>

View File

@@ -0,0 +1,199 @@
<template>
<!-- 搜索 -->
<a-card class="general-card table-search-card">
<query-header :model="formModel"
label-align="left"
@submit="fetchTableData"
@reset="fetchTableData"
@keyup.enter="() => fetchTableData()">
<!-- id -->
<a-form-item field="id" label="id">
<a-input-number v-model="formModel.id"
placeholder="请输入id"
allow-clear
hide-button />
</a-form-item>
<!-- 名称 -->
<a-form-item field="name" label="名称">
<a-input v-model="formModel.name" placeholder="请输入名称" allow-clear />
</a-form-item>
</query-header>
</a-card>
<!-- 表格 -->
<a-card class="general-card table-card">
<template #title>
<!-- 左侧操作 -->
<div class="table-left-bar-handle">
<!-- 标题 -->
<div class="table-title">
秘钥列表
</div>
</div>
<!-- 右侧操作 -->
<div class="table-right-bar-handle">
<a-space>
<!-- 角色授权 -->
<a-button type="primary"
v-permission="['asset:host-key:grant']"
@click="$router.push({ name: GrantRouteName, query: { key: GrantKey.HOST_KEY_ROLE }})">
角色授权
<template #icon>
<icon-user-group />
</template>
</a-button>
<!-- 用户授权 -->
<a-button type="primary"
v-permission="['asset:host-key:grant']"
@click="$router.push({ name: GrantRouteName, query: { key: GrantKey.HOST_KEY_USER }})">
用户授权
<template #icon>
<icon-user />
</template>
</a-button>
<!-- 新增 -->
<a-button type="primary"
v-permission="['asset:host-key:create']"
@click="emits('openAdd')">
新增
<template #icon>
<icon-plus />
</template>
</a-button>
</a-space>
</div>
</template>
<!-- table -->
<a-table row-key="id"
ref="tableRef"
:loading="loading"
:columns="columns"
:data="tableRenderData"
:pagination="pagination"
@page-change="(page) => fetchTableData(page, pagination.pageSize)"
@page-size-change="(size) => fetchTableData(1, size)"
:bordered="false">
<!-- 操作 -->
<template #handle="{ record }">
<div class="table-handle-wrapper">
<!-- 详情 -->
<a-button type="text"
size="mini"
v-permission="['asset:host-key:detail', 'asset:host-key:update']"
@click="emits('openView', record)">
详情
</a-button>
<!-- 修改 -->
<a-button type="text"
size="mini"
v-permission="['asset:host-key:update']"
@click="emits('openUpdate', record)">
修改
</a-button>
<!-- 删除 -->
<a-popconfirm content="确认删除这条记录吗?"
position="left"
type="warning"
@ok="deleteRow(record)">
<a-button v-permission="['asset:host-key:delete']"
type="text"
size="mini"
status="danger">
删除
</a-button>
</a-popconfirm>
</div>
</template>
</a-table>
</a-card>
</template>
<script lang="ts">
export default {
name: 'hostKeyTable'
};
</script>
<script lang="ts" setup>
import type { HostKeyQueryRequest, HostKeyQueryResponse } from '@/api/asset/host-key';
import { reactive, ref, onMounted } from 'vue';
import { deleteHostKey, getHostKeyPage } from '@/api/asset/host-key';
import { Message } from '@arco-design/web-vue';
import useLoading from '@/hooks/loading';
import columns from '../types/table.columns';
import { usePagination } from '@/types/table';
import { GrantKey, GrantRouteName } from '@/views/asset/grant/types/const';
const emits = defineEmits(['openAdd', 'openUpdate', 'openView']);
const tableRenderData = ref<HostKeyQueryResponse[]>([]);
const pagination = usePagination();
const { loading, setLoading } = useLoading();
const formModel = reactive<HostKeyQueryRequest>({
id: undefined,
name: undefined,
publicKey: undefined,
privateKey: undefined,
});
// 删除当前行
const deleteRow = async ({ id }: {
id: number
}) => {
try {
setLoading(true);
// 调用删除接口
await deleteHostKey(id);
Message.success('删除成功');
// 重新加载数据
fetchTableData();
} catch (e) {
} finally {
setLoading(false);
}
};
// 添加后回调
const addedCallback = () => {
fetchTableData();
};
// 更新后回调
const updatedCallback = () => {
fetchTableData();
};
defineExpose({
addedCallback, updatedCallback
});
// 加载数据
const doFetchTableData = async (request: HostKeyQueryRequest) => {
try {
setLoading(true);
const { data } = await getHostKeyPage(request);
tableRenderData.value = data.rows;
pagination.total = data.total;
pagination.current = request.page;
pagination.pageSize = request.limit;
} catch (e) {
} finally {
setLoading(false);
}
};
// 切换页码
const fetchTableData = (page = 1, limit = pagination.pageSize, form = formModel) => {
doFetchTableData({ page, limit, ...form });
};
onMounted(() => {
fetchTableData();
});
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,64 @@
<template>
<div class="layout-container">
<!-- 列表-表格 -->
<host-key-table v-if="renderTable"
ref="table"
@open-view="(e) => drawer.openView(e)"
@open-add="() => drawer.openAdd()"
@open-update="(e) => drawer.openUpdate(e)" />
<!-- 列表-卡片 -->
<host-key-card-list v-else
ref="card"
@open-view="(e) => drawer.openView(e)"
@open-add="() => drawer.openAdd()"
@open-update="(e) => drawer.openUpdate(e)" />
<!-- 添加修改模态框 -->
<host-key-form-drawer ref="drawer"
@added="modalAddCallback"
@updated="modalUpdateCallback" />
</div>
</template>
<script lang="ts">
export default {
name: 'hostKey'
};
</script>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useAppStore } from '@/store';
import HostKeyCardList from './components/host-key-card-list.vue';
import HostKeyTable from './components/host-key-table.vue';
import HostKeyFormDrawer from './components/host-key-form-drawer.vue';
const table = ref();
const card = ref();
const drawer = ref();
const appStore = useAppStore();
const renderTable = computed(() => appStore.hostKeyView === 'table');
// 添加回调
const modalAddCallback = () => {
if (renderTable.value) {
table.value.addedCallback();
} else {
card.value.addedCallback();
}
};
// 修改回调
const modalUpdateCallback = () => {
if (renderTable.value) {
table.value.updatedCallback();
} else {
card.value.updatedCallback();
}
};
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,30 @@
import type { CardField, CardFieldConfig } from '@/types/card';
import { dateFormat } from '@/utils';
const fieldConfig = {
rowGap: '12px',
labelSpan: 8,
fields: [
{
label: 'id',
dataIndex: 'id',
slotName: 'id',
}, {
label: '创建时间',
dataIndex: 'createTime',
slotName: 'createTime',
render: ({ record }) => {
return dateFormat(new Date(record.createTime));
},
}, {
label: '修改时间',
dataIndex: 'updateTime',
slotName: 'updateTime',
render: ({ record }) => {
return dateFormat(new Date(record.updateTime));
},
}
] as CardField[]
} as CardFieldConfig;
export default fieldConfig;

View File

@@ -0,0 +1,19 @@
import type { FieldRule } from '@arco-design/web-vue';
export const name = [{
required: true,
message: '请输入名称'
}, {
maxLength: 64,
message: '名称长度不能大于64位'
}] as FieldRule[];
export const privateKey = [{
required: true,
message: '请输入私钥文本'
}] as FieldRule[];
export default {
name,
privateKey,
} as Record<string, FieldRule | FieldRule[]>;

View File

@@ -0,0 +1,45 @@
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface';
import { dateFormat } from '@/utils';
const columns = [
{
title: 'id',
dataIndex: 'id',
slotName: 'id',
width: 100,
align: 'left',
fixed: 'left',
}, {
title: '名称',
dataIndex: 'name',
slotName: 'name',
ellipsis: true,
tooltip: true
}, {
title: '创建时间',
dataIndex: 'createTime',
slotName: 'createTime',
align: 'center',
width: 198,
render: ({ record }) => {
return dateFormat(new Date(record.createTime));
},
}, {
title: '修改时间',
dataIndex: 'updateTime',
slotName: 'updateTime',
align: 'center',
width: 198,
render: ({ record }) => {
return dateFormat(new Date(record.updateTime));
},
}, {
title: '操作',
slotName: 'handle',
width: 180,
align: 'center',
fixed: 'right',
},
] as TableColumnData[];
export default columns;

View File

@@ -0,0 +1,122 @@
<template>
<a-drawer v-model:visible="visible"
:width="460"
:esc-to-close="false"
:mask-closable="false"
:unmount-on-close="true"
:ok-button-props="{ disabled: loading }"
:cancel-button-props="{ disabled: loading }"
:footer="false"
@cancel="handleCancel">
<!-- 标题 -->
<template #title>
<span class="host-title-text">
主机配置 <span class="host-name-title-text">{{ record.name }}</span>
</span>
</template>
<a-spin :loading="loading" class="host-config-container">
<!-- SSH 配置 -->
<ssh-config-form :host-id="record.id"
:content="config.ssh"
@submitted="(e) => config.ssh = e" />
</a-spin>
</a-drawer>
</template>
<script lang="ts">
export default {
name: 'hostConfigDrawer'
};
</script>
<script lang="ts" setup>
import type { HostConfigWrapper } from '../../types/const';
import type { HostSshConfig } from './ssh/types/const';
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 './ssh/types/const';
import SshConfigForm from './ssh/ssh-config-form.vue';
const { visible, setVisible } = useVisible();
const { loading, setLoading } = useLoading();
const cacheStore = useCacheStore();
const record = ref({} as any);
const config = ref<HostConfigWrapper>({
ssh: {} as HostSshConfig
});
// 打开
const open = async (e: any) => {
record.value = { ...e };
try {
setLoading(true);
setVisible(true);
// 加载字典值
const dictStore = useDictStore();
await dictStore.loadKeys(dictKeys);
// 加载配置
const { data } = await getHostConfigList(record.value.id);
data.forEach(s => {
config.value[s.type] = s;
});
} catch ({ message }) {
Message.error(`配置加载失败 ${message}`);
setVisible(false);
} finally {
setLoading(false);
}
};
// 关闭
const handleCancel = () => {
setLoading(false);
setVisible(false);
};
defineExpose({ open });
</script>
<style lang="less" scoped>
.host-title-text {
width: 368px;
display: flex;
font-size: 16px;
line-height: 16px;
font-weight: 600;
.host-name-title-text {
max-width: 288px;
margin-left: 8px;
font-size: 14px;
display: inline-block;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
color: rgb(var(--arcoblue-6));
}
.host-name-title-text::before {
content: '(';
}
.host-name-title-text::after {
content: ')';
}
}
.host-config-container {
width: 100%;
height: 100%;
background-color: var(--color-fill-2);
}
.arco-card {
margin: 18px 18px 0 18px;
}
</style>

View File

@@ -0,0 +1,304 @@
<template>
<a-card class="general-card"
:body-style="{ padding: config.status === EnabledStatus.ENABLED ? '' : '0' }">
<!-- 标题 -->
<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)" />
</div>
</template>
<a-spin v-show="config.status" :loading="loading" class="config-form-wrapper">
<!-- 表单 -->
<a-form :model="formModel"
ref="formRef"
label-align="right"
:auto-label-width="true"
:rules="rules">
<!-- 系统类型 -->
<a-form-item field="osType"
label="系统类型"
:hide-asterisk="true">
<a-select v-model="formModel.osType"
:options="toOptions(sshOsTypeKey)"
placeholder="请选择系统类型" />
</a-form-item>
<!-- 用户名 -->
<a-form-item field="username"
label="用户名"
:rules="usernameRules"
:help="SshAuthType.IDENTITY === formModel.authType ? '将使用主机身份的用户名' : undefined">
<a-input v-model="formModel.username"
:disabled="SshAuthType.IDENTITY === formModel.authType"
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="authType"
label="验证方式"
:hide-asterisk="true">
<a-radio-group type="button"
class="auth-type-group usn"
v-model="formModel.authType"
:options="toRadioOptions(sshAuthTypeKey)" />
</a-form-item>
<!-- 主机密码 -->
<a-form-item v-if="SshAuthType.PASSWORD === formModel.authType"
field="password"
label="主机密码"
:rules="passwordRules">
<a-input-password v-model="formModel.password"
:disabled="!formModel.useNewPassword && formModel.hasPassword"
placeholder="主机密码" />
<a-switch v-if="formModel.hasPassword"
v-model="formModel.useNewPassword"
class="password-switch"
type="round"
checked-text="使用新密码"
unchecked-text="使用原密码" />
</a-form-item>
<!-- 主机秘钥 -->
<a-form-item v-if="SshAuthType.KEY === formModel.authType"
field="keyId"
label="主机秘钥"
:hide-asterisk="true">
<host-key-selector v-model="formModel.keyId" />
</a-form-item>
<!-- 主机身份 -->
<a-form-item v-if="SshAuthType.IDENTITY === formModel.authType"
field="identityId"
label="主机身份"
:hide-asterisk="true">
<host-identity-selector v-model="formModel.identityId" />
</a-form-item>
<!-- 用户名 -->
<a-form-item field="connectTimeout"
label="连接超时时间"
:hide-asterisk="true">
<a-input-number v-model="formModel.connectTimeout"
placeholder="请输入连接超时时间"
hide-button>
<template #suffix>
ms
</template>
</a-input-number>
</a-form-item>
<!-- SSH输出编码 -->
<a-form-item field="charset"
label="SSH输出编码"
:hide-asterisk="true">
<a-input v-model="formModel.charset" placeholder="请输入 SSH 输出编码" />
</a-form-item>
<!-- 文件名称编码 -->
<a-form-item field="fileNameCharset"
label="文件名称编码"
:hide-asterisk="true">
<a-input v-model="formModel.fileNameCharset" placeholder="请输入 SFTP 文件名称编码" />
</a-form-item>
<!-- 文件内容编码 -->
<a-form-item field="fileContentCharset"
label="文件内容编码"
:hide-asterisk="true">
<a-input v-model="formModel.fileContentCharset" placeholder="请输入 SFTP 文件内容编码" />
</a-form-item>
</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>
<script lang="ts">
export default {
name: 'sshConfigForm'
};
</script>
<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/form.rules';
import { Message } from '@arco-design/web-vue';
import useLoading from '@/hooks/loading';
import { useDictStore } from '@/store';
import { EnabledStatus } from '@/types/const';
import { HostConfigType } from '../../../types/const';
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;
}>();
const emits = defineEmits(['submitted']);
const config = reactive({
status: undefined,
version: undefined,
});
const formRef = ref();
const formModel = ref<HostSshConfig>({
osType: SshOsType.LINUX,
username: undefined,
port: undefined,
password: undefined,
authType: SshAuthType.PASSWORD,
keyId: undefined,
identityId: undefined,
connectTimeout: undefined,
charset: undefined,
fileNameCharset: undefined,
fileContentCharset: undefined,
useNewPassword: false,
hasPassword: false,
});
// 监听数据变化
watch(() => props.content, (v: any) => {
config.status = v?.status;
config.version = v?.version;
resetConfig();
});
// 用户名验证
const usernameRules = [{
validator: (value, cb) => {
if (value && value.length > 128) {
cb('用户名长度不能大于128位');
return;
}
if (formModel.value.authType !== SshAuthType.IDENTITY && !value) {
cb('请输入用户名');
return;
}
}
}] as FieldRule[];
// 密码验证
const passwordRules = [{
validator: (value, cb) => {
if (value && value.length > 256) {
cb('密码长度不能大于256位');
return;
}
if (formModel.value.useNewPassword && !value) {
cb('请输入密码');
return;
}
}
}] 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);
}
};
</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;
justify-content: space-between;
}
.password-switch {
width: 148px;
margin-left: 8px;
}
</style>

View File

@@ -0,0 +1,41 @@
// 主机 SSH 配置
export interface HostSshConfig {
osType?: string;
port?: number;
username?: string;
password?: string;
authType?: string;
identityId?: number;
keyId?: number;
connectTimeout?: number;
charset?: string;
fileNameCharset?: string;
fileContentCharset?: string;
useNewPassword?: boolean;
hasPassword?: boolean;
}
// 主机验证方式
export const SshAuthType = {
// 密码验证
PASSWORD: 'PASSWORD',
// 秘钥验证
KEY: 'KEY',
// 身份验证
IDENTITY: 'IDENTITY'
};
// 主机系统版本
export const SshOsType = {
LINUX: 'LINUX',
WINDOWS: 'WINDOWS',
};
// 主机验证方式 字典项
export const sshAuthTypeKey = 'hostSshAuthType';
// 主机系统类型 字典项
export const sshOsTypeKey = 'hostSshOsType';
// 加载的字典值
export const dictKeys = [sshAuthTypeKey, sshOsTypeKey];

View File

@@ -0,0 +1,77 @@
import type { FieldRule } from '@arco-design/web-vue';
export const osType = [{
required: true,
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: '请选择认证方式'
}] as FieldRule[];
export const keyId = [{
required: true,
message: '请选择主机秘钥'
}] as FieldRule[];
export const identityId = [{
required: true,
message: '请选择主机身份'
}] as FieldRule[];
export const connectTimeout = [{
required: true,
message: '请输入连接超时时间'
}, {
type: 'number',
min: 0,
max: 100000,
message: '连接超时时间需要在 0 - 100000 之间'
}] as FieldRule[];
export const charset = [{
required: true,
message: '请输入SSH输出编码'
}, {
maxLength: 12,
message: 'SSH输出编码长度不能超过12位'
}] as FieldRule[];
export const fileNameCharset = [{
required: true,
message: '请输入文件名称编码'
}, {
maxLength: 12,
message: '文件名称编码长度不能超过12位'
}] as FieldRule[];
export const fileContentCharset = [{
required: true,
message: '请输入SSH输出编码'
}, {
maxLength: 12,
message: '文件内容编码长度不能超过12位'
}] as FieldRule[];
export default {
osType,
port,
authType,
keyId,
identityId,
connectTimeout,
charset,
fileNameCharset,
fileContentCharset,
} as Record<string, FieldRule | FieldRule[]>;

View File

@@ -0,0 +1,219 @@
<template>
<a-drawer v-model:visible="visible"
class="host-group-drawer"
width="70%"
title="主机分组配置"
:esc-to-close="false"
:mask-closable="false"
:unmount-on-close="true"
ok-text="保存"
cancel-text="关闭"
:ok-button-props="{ disabled: loading }"
:cancel-button-props="{ disabled: loading }"
:on-before-ok="saveHost"
@cancel="handleCancel">
<a-spin :loading="loading"
class="host-group-container">
<div class="host-group-wrapper">
<!-- 左侧菜单 -->
<div class="simple-card tree-card">
<!-- 主机分组头部 -->
<div class="tree-card-header">
<!-- 标题 -->
<div class="tree-card-title">
主机分组
</div>
<!-- 操作 -->
<div class="tree-card-handler">
<div v-permission="['asset:host-group:create']"
class="click-icon-wrapper handler-icon-wrapper"
title="根节点添加"
@click="addRootNode">
<icon-plus />
</div>
<div class="click-icon-wrapper handler-icon-wrapper"
title="刷新"
@click="refreshTree">
<icon-refresh />
</div>
</div>
</div>
<!-- 主机分组树 -->
<host-group-tree outer-class="tree-card-main"
ref="tree"
:loading="loading"
:editable="true"
@set-loading="setLoading"
@selected-node="selectGroup" />
</div>
<!-- 身体部分 -->
<a-spin class="simple-card transfer-body"
:loading="hostLoading">
<host-transfer v-model="currentGroupHost"
:group="currentGroup"
@loading="setHostLoading" />
</a-spin>
</div>
</a-spin>
</a-drawer>
</template>
<script lang="ts">
export default {
name: 'hostGroupDrawer'
};
</script>
<script lang="ts" setup>
import type { TreeNodeData } from '@arco-design/web-vue';
import { ref } from 'vue';
import useVisible from '@/hooks/visible';
import useLoading from '@/hooks/loading';
import { useCacheStore } from '@/store';
import { Message } from '@arco-design/web-vue';
import { updateHostGroupRel } from '@/api/asset/host-group';
import HostTransfer from './host-transfer.vue';
import HostGroupTree from '@/components/asset/host-group/tree/index.vue';
const { visible, setVisible } = useVisible();
const { loading, setLoading } = useLoading();
const { loading: hostLoading, setLoading: setHostLoading } = useLoading();
const cacheStore = useCacheStore();
const tree = ref();
const currentGroup = ref();
const currentGroupHost = ref<Array<string>>([]);
// 打开
const open = async () => {
setVisible(true);
};
defineExpose({ open });
// 添加根节点
const addRootNode = () => {
tree.value.addRootNode();
};
// 刷新树
const refreshTree = () => {
tree.value.fetchTreeData(true);
};
// 选中分组
const selectGroup = (group: TreeNodeData) => {
currentGroup.value = group;
};
// 保存主机
const saveHost = async () => {
setLoading(true);
try {
await updateHostGroupRel({
groupId: currentGroup.value?.key as number,
hostIdList: currentGroupHost.value
});
Message.success('保存成功');
} catch (e) {
} finally {
setLoading(false);
}
return false;
};
// 关闭
const handleCancel = () => {
setLoading(false);
setVisible(false);
};
</script>
<style lang="less" scoped>
@tree-width: 33%;
.host-group-container {
width: 100%;
height: 100%;
background-color: var(--color-fill-2);
padding: 16px;
.host-group-wrapper {
position: relative;
width: 100%;
height: 100%;
}
}
.tree-card {
width: @tree-width;
height: 100%;
position: absolute;
&-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 8px 0 16px;
position: relative;
width: 100%;
height: 44px;
border-bottom: 1px var(--color-border-2) solid;
user-select: none;
}
&-title {
color: rgba(var(--gray-10), .95);
font-size: 16px;
font-weight: 600;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&-handler {
display: flex;
.handler-icon-wrapper {
margin-left: 2px;
padding: 4px;
font-size: 16px;
background: unset;
&:hover {
background: var(--color-fill-3);
}
}
}
&-main {
padding: 8px;
position: relative;
width: 100%;
height: calc(100% - 44px);
}
}
.transfer-body {
display: flex;
height: 100%;
padding: 12px 16px 16px 16px;
width: calc(100% - @tree-width - 16px);
position: absolute;
left: calc(@tree-width + 16px);
}
</style>
<style lang="less">
.host-group-drawer {
.arco-drawer-footer {
padding: 8px;
}
.arco-drawer-body {
overflow: hidden;
}
}
</style>

View File

@@ -0,0 +1,181 @@
<template>
<div class="transfer-container">
<!-- 传输框 -->
<a-transfer v-model="value"
:data="data"
:source-input-search-props="{ placeholder: '请输入主机名称/编码/IP' }"
:target-input-search-props="{ placeholder: '请输入主机名称/编码/IP' }"
:disabled="!group.key"
show-search
one-way>
<!-- 主机列表 -->
<template #source-title="{ countTotal, countSelected, checked, indeterminate, onSelectAllChange }">
<!-- 左侧标题 -->
<div class="source-title-container">
<a-checkbox style="margin-right: 8px;"
:model-value="checked"
:indeterminate="indeterminate"
@change="onSelectAllChange" />
<span>
主机列表 {{ countSelected }} / {{ countTotal }}
</span>
</div>
</template>
<!-- 右侧标题 -->
<template #target-title="{ countTotal, countSelected, onClear }">
<div class="target-title-container">
<span>已选择 <span class="span-blue">{{ countTotal }}</span> </span>
<span class="pointer"
@click="onClear"
title="清空">
<icon-delete />
</span>
</div>
</template>
<!-- 内容 -->
<template #item="{ label }">
<a-tooltip position="top"
:mini="true"
:content="label">
<span v-html="renderLabel(label)" />
</a-tooltip>
</template>
</a-transfer>
</div>
</template>
<script lang="ts">
export default {
name: 'hostTransfer'
};
</script>
<script lang="ts" setup>
import type { TransferItem } from '@arco-design/web-vue/es/transfer/interface';
import type { TreeNodeData } from '@arco-design/web-vue';
import { onMounted, ref, watch, computed } from 'vue';
import { useCacheStore } from '@/store';
import { getHostGroupRelList } from '@/api/asset/host-group';
const props = withDefaults(defineProps<Partial<{
modelValue: Array<string>;
group: TreeNodeData;
}>>(), {
group: () => {
return {};
},
});
const emits = defineEmits(['loading', 'update:modelValue']);
const cacheStore = useCacheStore();
const data = ref<Array<TransferItem>>([]);
const value = computed<Array<string>>({
get() {
return props.modelValue as Array<string>;
},
set(e) {
if (e) {
emits('update:modelValue', e);
} else {
emits('update:modelValue', []);
}
}
});
// 渲染 label
const renderLabel = (label: string) => {
const last = label.lastIndexOf('-');
const prefix = label.substring(0, last - 1);
const address = label.substring(last + 2, label.length);
return `${prefix} - <span class="span-blue">${address}</span>`;
};
// 查询组内数据
watch(() => props.group?.key, async (groupId) => {
if (groupId) {
// 加载组内数据
try {
emits('loading', true);
const { data } = await getHostGroupRelList(groupId as number);
value.value = data.map(String);
} catch (e) {
} finally {
emits('loading', false);
}
} else {
// 重置
value.value = [];
}
});
onMounted(() => {
cacheStore.loadHosts().then(hosts => {
data.value = hosts.map(s => {
return {
value: String(s.id),
label: `${s.name} - ${s.address}`,
disabled: false
};
});
});
});
</script>
<style lang="less" scoped>
.transfer-container {
width: 100%;
height: 100%;
}
:deep(.arco-transfer) {
height: 100%;
.arco-transfer-view {
width: 100%;
height: 100%;
user-select: none;
}
.arco-transfer-view-source {
.arco-transfer-list-item .arco-checkbox {
width: calc(100% - 24px);
position: absolute;
}
}
.arco-transfer-view-target {
.arco-transfer-list-item-content {
margin-left: 4px;
position: absolute;
width: calc(100% - 52px);
display: inline-block;
}
.arco-transfer-list-item-remove-btn {
margin-right: 8px;
}
}
}
.source-title-container {
display: flex;
align-items: center;
}
.target-title-container {
display: flex;
justify-content: space-between;
align-items: center;
svg {
font-size: 16px;
}
}
</style>

View File

@@ -0,0 +1,276 @@
<template>
<card-list v-model:searchValue="formModel.searchValue"
search-input-placeholder="输入 id / 名称 / 编码 / 地址"
:loading="loading"
:fieldConfig="fieldConfig"
:list="list"
:pagination="pagination"
:card-layout-cols="cardColLayout"
:filter-count="filterCount"
:add-permission="['asset:host:create']"
@add="emits('openAdd')"
@reset="reset"
@search="fetchCardData"
@page-change="fetchCardData">
<!-- 左侧操作 -->
<template #leftHandle>
<!-- 主机分组 -->
<a-button v-permission="['asset:host-group:update']"
class="card-header-icon-wrapper"
@click="emits('openHostGroup')">
主机分组
<template #icon>
<icon-layers />
</template>
</a-button>
<!-- 角色授权 -->
<a-button v-permission="['asset:host-group:grant']"
class="card-header-icon-wrapper"
@click="$router.push({ name: GrantRouteName, query: { key: GrantKey.HOST_GROUP_ROLE }})">
角色授权
<template #icon>
<icon-user-group />
</template>
</a-button>
<!-- 用户授权 -->
<a-button v-permission="['asset:host-group:grant']"
class="card-header-icon-wrapper"
@click="$router.push({ name: GrantRouteName, query: { key: GrantKey.HOST_GROUP_USER }})">
用户授权
<template #icon>
<icon-user />
</template>
</a-button>
</template>
<!-- 过滤条件 -->
<template #filterContent>
<a-form :model="formModel"
class="card-filter-form"
size="small"
ref="formRef"
label-align="right"
:auto-label-width="true"
@keyup.enter="() => fetchCardData()">
<!-- id -->
<a-form-item field="id" label="主机id">
<a-input-number v-model="formModel.id"
placeholder="请输入主机id"
allow-clear
hide-button />
</a-form-item>
<!-- 主机名称 -->
<a-form-item field="name" label="主机名称">
<a-input v-model="formModel.name" placeholder="请输入主机名称" allow-clear />
</a-form-item>
<!-- 主机编码 -->
<a-form-item field="code" label="主机编码">
<a-input v-model="formModel.code" placeholder="请输入主机编码" allow-clear />
</a-form-item>
<!-- 主机地址 -->
<a-form-item field="address" label="主机地址">
<a-input v-model="formModel.address" placeholder="请输入主机地址" allow-clear />
</a-form-item>
<!-- 主机标签 -->
<a-form-item field="tags" label="主机标签">
<tag-multi-selector v-model="formModel.tags"
:limit="0"
type="HOST"
:tagColor="tagColor"
placeholder="请选择主机标签" />
</a-form-item>
</a-form>
</template>
<!-- 标题 -->
<template #title="{ record }">
{{ record.name }}
</template>
<!-- 编码 -->
<template #code="{ record }">
{{ record.code }}
</template>
<!-- 地址 -->
<template #address="{ record }">
<span class="span-blue text-copy" @click="copy(record.address)">
{{ record.address }}
</span>
</template>
<!-- 标签 -->
<template #tags="{ record }">
<a-space v-if="record.tags" wrap style="margin-bottom: -8px;">
<a-tag v-for="tag in record.tags"
:key="tag.id"
:color="dataColor(tag.name, tagColor)">
{{ tag.name }}
</a-tag>
</a-space>
</template>
<!-- 拓展操作 -->
<template #extra="{ record }">
<a-space>
<!-- 更多操作 -->
<a-dropdown trigger="hover">
<icon-more class="card-extra-icon" />
<template #content>
<!-- 修改 -->
<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>
</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>
<script lang="ts">
export default {
name: 'hostCardList'
};
</script>
<script lang="ts" setup>
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 { Message, Modal } from '@arco-design/web-vue';
import { tagColor } from '../types/const';
import { copy } from '@/hooks/copy';
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 list = ref<HostQueryResponse[]>([]);
const cardColLayout = useColLayout();
const pagination = usePagination();
const { loading, setLoading } = useLoading();
const formRef = ref();
const formModel = reactive<HostQueryRequest>({
searchValue: undefined,
id: undefined,
name: undefined,
code: undefined,
address: undefined,
tags: undefined,
queryTag: true
});
// 条件数量
const filterCount = computed(() => {
return objectTruthKeyCount(formModel, ['searchValue', 'queryTag']);
});
// 删除当前行
const deleteRow = (id: number) => {
Modal.confirm({
title: '删除前确认!',
titleAlign: 'start',
content: '确定要删除这条记录吗?',
okText: '删除',
onOk: async () => {
try {
setLoading(true);
// 调用删除接口
await deleteHost(id);
Message.success('删除成功');
// 重新加载数据
fetchCardData();
} catch (e) {
} finally {
setLoading(false);
}
}
});
};
// 添加后回调
const addedCallback = () => {
fetchCardData();
};
// 更新后回调
const updatedCallback = () => {
fetchCardData();
};
defineExpose({
addedCallback, updatedCallback
});
// 重置条件
const reset = () => {
resetObject(formModel, ['queryTag']);
fetchCardData();
};
// 加载数据
const doFetchCardData = async (request: HostQueryRequest) => {
try {
setLoading(true);
const { data } = await getHostPage(request);
list.value = data.rows;
pagination.total = data.total;
pagination.current = request.page;
pagination.pageSize = request.limit;
} catch (e) {
} finally {
setLoading(false);
}
};
// 切换页码
const fetchCardData = (page = 1, limit = pagination.pageSize, form = formModel) => {
doFetchCardData({ page, limit, ...form });
};
onMounted(() => {
fetchCardData();
});
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,192 @@
<template>
<a-modal v-model:visible="visible"
body-class="modal-form-large"
title-align="start"
:title="title"
:top="80"
:align-center="false"
:draggable="true"
:mask-closable="false"
:unmount-on-close="true"
:ok-button-props="{ disabled: loading }"
:cancel-button-props="{ disabled: loading }"
:on-before-ok="handlerOk"
@close="handleClose">
<a-spin class="full" :loading="loading">
<a-form :model="formModel"
ref="formRef"
label-align="right"
:auto-label-width="true"
:rules="formRules">
<!-- 主机名称 -->
<a-form-item field="name" label="主机名称">
<a-input v-model="formModel.name" placeholder="请输入主机名称" />
</a-form-item>
<!-- 主机编码 -->
<a-form-item field="code" label="主机编码">
<a-input v-model="formModel.code" placeholder="请输入主机编码" />
</a-form-item>
<!-- 主机地址 -->
<a-form-item field="address" label="主机地址">
<a-input v-model="formModel.address" placeholder="请输入主机地址" />
</a-form-item>
<!-- 主机分组 -->
<a-form-item field="groupIdList" label="主机分组">
<host-group-tree-selector v-model="formModel.groupIdList"
placeholder="请选择主机分组" />
</a-form-item>
<!-- 主机标签 -->
<a-form-item field="tags" label="主机标签">
<tag-multi-selector v-model="formModel.tags"
:allowCreate="true"
:limit="5"
type="HOST"
:tagColor="tagColor"
placeholder="请选择主机标签"
@on-limited="onLimitedTag" />
</a-form-item>
</a-form>
</a-spin>
</a-modal>
</template>
<script lang="ts">
export default {
name: 'hostFormModal'
};
</script>
<script lang="ts" setup>
import type { HostUpdateRequest } from '@/api/asset/host';
import { ref } from 'vue';
import useLoading from '@/hooks/loading';
import useVisible from '@/hooks/visible';
import formRules from '../types/form.rules';
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 TagMultiSelector from '@/components/meta/tag/multi-selector/index.vue';
import HostGroupTreeSelector from '@/components/asset/host-group/tree-selector/index.vue';
const { visible, setVisible } = useVisible();
const { loading, setLoading } = useLoading();
const title = ref<string>();
const isAddHandle = ref<boolean>(true);
const defaultForm = (): HostUpdateRequest => {
return {
id: undefined,
name: undefined,
code: undefined,
address: undefined,
tags: undefined,
groupIdList: undefined,
};
};
const formRef = ref();
const formModel = ref<HostUpdateRequest>({});
const emits = defineEmits(['added', 'updated']);
// 打开新增
const openAdd = () => {
title.value = '添加主机';
isAddHandle.value = true;
renderForm({ ...defaultForm() });
setVisible(true);
};
// 打开修改
const openUpdate = async (id: number) => {
title.value = '修改主机';
isAddHandle.value = false;
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'));
// tag
const tags = (data.tags || []).map(s => s.id);
// 渲染
renderForm({ ...detail, tags });
} catch (e) {
} finally {
setLoading(false);
}
};
// 渲染表单
const renderForm = (record: any) => {
formModel.value = Object.assign({}, record);
};
defineExpose({ openAdd, openUpdate });
// tag 超出所选限制
const onLimitedTag = (count: number, message: string) => {
formRef.value.setFields({
tags: {
status: 'error',
message
}
});
// 因为输入框已经限制数量 这里只做提示
setTimeout(() => {
formRef.value.clearValidate('tags');
}, 3000);
};
// 确定
const handlerOk = async () => {
setLoading(true);
try {
// 验证参数
const error = await formRef.value.validate();
if (error) {
return false;
}
if (isAddHandle.value) {
// 新增
await createHost(formModel.value);
Message.success('创建成功');
emits('added');
} else {
// 修改
await updateHost(formModel.value);
Message.success('修改成功');
emits('updated');
}
// 清空
handlerClear();
} catch (e) {
return false;
} finally {
setLoading(false);
}
};
// 关闭
const handleClose = () => {
handlerClear();
};
// 清空
const handlerClear = () => {
setLoading(false);
};
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,259 @@
<template>
<!-- 搜索 -->
<a-card class="general-card table-search-card">
<query-header :model="formModel"
label-align="left"
@submit="fetchTableData"
@reset="fetchTableData"
@keyup.enter="() => fetchTableData()">
<!-- id -->
<a-form-item field="id" label="主机id">
<a-input-number v-model="formModel.id"
placeholder="请输入主机id"
allow-clear
hide-button />
</a-form-item>
<!-- 主机名称 -->
<a-form-item field="name" label="主机名称">
<a-input v-model="formModel.name" placeholder="请输入主机名称" allow-clear />
</a-form-item>
<!-- 主机编码 -->
<a-form-item field="code" label="主机编码">
<a-input v-model="formModel.code" placeholder="请输入主机编码" allow-clear />
</a-form-item>
<!-- 主机地址 -->
<a-form-item field="address" label="主机地址">
<a-input v-model="formModel.address" placeholder="请输入主机地址" allow-clear />
</a-form-item>
<!-- 主机标签 -->
<a-form-item field="tags" label="主机标签">
<tag-multi-selector v-model="formModel.tags"
ref="tagSelector"
:limit="0"
type="HOST"
:tagColor="tagColor"
placeholder="请选择主机标签" />
</a-form-item>
</query-header>
</a-card>
<!-- 表格 -->
<a-card class="general-card table-card">
<template #title>
<!-- 左侧操作 -->
<div class="table-left-bar-handle">
<!-- 标题 -->
<div class="table-title">
主机列表
</div>
</div>
<!-- 右侧操作 -->
<div class="table-right-bar-handle">
<a-space>
<!-- 主机分组 -->
<a-button type="primary"
v-permission="['asset:host-group:update']"
@click="emits('openHostGroup')">
主机分组
<template #icon>
<icon-layers />
</template>
</a-button>
<!-- 角色授权 -->
<a-button type="primary"
v-permission="['asset:host-group:grant']"
@click="$router.push({ name: GrantRouteName, query: { key: GrantKey.HOST_GROUP_ROLE }})">
角色授权
<template #icon>
<icon-user-group />
</template>
</a-button>
<!-- 用户授权 -->
<a-button type="primary"
v-permission="['asset:host-group:grant']"
@click="$router.push({ name: GrantRouteName, query: { key: GrantKey.HOST_GROUP_USER }})">
用户授权
<template #icon>
<icon-user />
</template>
</a-button>
<!-- 新增 -->
<a-button type="primary"
v-permission="['asset:host:create']"
@click="emits('openAdd')">
新增
<template #icon>
<icon-plus />
</template>
</a-button>
</a-space>
</div>
</template>
<!-- table -->
<a-table row-key="id"
ref="tableRef"
:loading="loading"
:columns="columns"
:data="tableRenderData"
:pagination="pagination"
@page-change="(page) => fetchTableData(page, pagination.pageSize)"
@page-size-change="(size) => fetchTableData(1, size)"
:bordered="false">
<!-- 编码 -->
<template #code="{ record }">
<a-tag>{{ record.code }}</a-tag>
</template>
<!-- 地址 -->
<template #address="{ record }">
<span class="span-blue text-copy"
title="复制"
@click="copy(record.address)">
{{ record.address }}
</span>
</template>
<!-- 标签 -->
<template #tags="{ record }">
<a-space v-if="record.tags"
style="margin-bottom: -8px;"
:wrap="true">
<a-tag v-for="tag in record.tags"
:key="tag.id"
:color="dataColor(tag.name, tagColor)">
{{ tag.name }}
</a-tag>
</a-space>
</template>
<!-- 操作 -->
<template #handle="{ record }">
<div class="table-handle-wrapper row-handle-wrapper">
<!-- 修改 -->
<a-button type="text"
size="mini"
v-permission="['asset:host:update']"
@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"
type="warning"
@ok="deleteRow(record)">
<a-button v-permission="['asset:host:delete']"
type="text"
size="mini"
status="danger">
删除
</a-button>
</a-popconfirm>
</div>
</template>
</a-table>
</a-card>
</template>
<script lang="ts">
export default {
name: 'hostTable'
};
</script>
<script lang="ts" setup>
import type { HostQueryRequest, HostQueryResponse } from '@/api/asset/host';
import { reactive, ref, onMounted } from 'vue';
import { deleteHost, getHostPage } from '@/api/asset/host';
import { Message } from '@arco-design/web-vue';
import { tagColor } from '../types/const';
import { usePagination } from '@/types/table';
import useLoading from '@/hooks/loading';
import { copy } from '@/hooks/copy';
import columns from '../types/table.columns';
import { dataColor } from '@/utils';
import { GrantKey, GrantRouteName } from '@/views/asset/grant/types/const';
import TagMultiSelector from '@/components/meta/tag/multi-selector/index.vue';
const tagSelector = ref();
const tableRenderData = ref<HostQueryResponse[]>([]);
const { loading, setLoading } = useLoading();
const emits = defineEmits(['openAdd', 'openUpdate', 'openUpdateConfig', 'openHostGroup']);
const pagination = usePagination();
const formModel = reactive<HostQueryRequest>({
id: undefined,
name: undefined,
code: undefined,
address: undefined,
tags: undefined,
queryTag: true
});
// 删除当前行
const deleteRow = async ({ id }: {
id: number
}) => {
try {
setLoading(true);
// 调用删除接口
await deleteHost(id);
Message.success('删除成功');
// 重新加载数据
fetchTableData();
} catch (e) {
} finally {
setLoading(false);
}
};
// 添加后回调
const addedCallback = () => {
fetchTableData();
};
// 更新后回调
const updatedCallback = () => {
fetchTableData();
};
defineExpose({
addedCallback, updatedCallback
});
// 加载数据
const doFetchTableData = async (request: HostQueryRequest) => {
try {
setLoading(true);
const { data } = await getHostPage(request);
tableRenderData.value = data.rows;
pagination.total = data.total;
pagination.current = request.page;
pagination.pageSize = request.limit;
} catch (e) {
} finally {
setLoading(false);
}
};
// 切换页码
const fetchTableData = (page = 1, limit = pagination.pageSize, form = formModel) => {
doFetchTableData({ page, limit, ...form });
};
onMounted(() => {
fetchTableData();
});
</script>
<style lang="less" scoped>
.row-handle-wrapper {
display: flex;
align-items: center;
}
</style>

View File

@@ -0,0 +1,81 @@
<template>
<div class="layout-container">
<!-- 列表-表格 -->
<host-table v-if="renderTable"
ref="table"
@open-host-group="() => hostGroup.open()"
@open-add="() => modal.openAdd()"
@open-update="(e) => modal.openUpdate(e.id)"
@open-update-config="(e) => hostConfig.open(e)" />
<!-- 列表-卡片 -->
<host-card-list v-else
ref="card"
@open-host-group="() => hostGroup.open()"
@open-add="() => modal.openAdd()"
@open-update="(e) => modal.openUpdate(e.id)"
@open-update-config="(e) => hostConfig.open(e)" />
<!-- 添加修改模态框 -->
<host-form-modal ref="modal"
@added="modalAddCallback"
@updated="modalUpdateCallback" />
<!-- 配置面板 -->
<host-config-drawer ref="hostConfig" />
<!-- 分组配置 -->
<host-group-drawer ref="hostGroup" />
</div>
</template>
<script lang="ts">
export default {
name: 'hostList'
};
</script>
<script lang="ts" setup>
import { computed, ref, onUnmounted } from 'vue';
import { useAppStore, useCacheStore } from '@/store';
import HostTable from './components/host-table.vue';
import HostCardList from './components/host-card-list.vue';
import HostFormModal from './components/host-form-modal.vue';
import HostConfigDrawer from './components/config/host-config-drawer.vue';
import HostGroupDrawer from './components/group/host-group-drawer.vue';
const table = ref();
const card = ref();
const modal = ref();
const hostConfig = ref();
const hostGroup = ref();
const appStore = useAppStore();
const cacheStore = useCacheStore();
const renderTable = computed(() => appStore.hostView === 'table');
// 添加回调
const modalAddCallback = () => {
if (renderTable.value) {
table.value.addedCallback();
} else {
card.value.addedCallback();
}
};
// 修改回调
const modalUpdateCallback = () => {
if (renderTable.value) {
table.value.updatedCallback();
} else {
card.value.updatedCallback();
}
};
// 卸载时清除 cache
onUnmounted(() => {
cacheStore.reset('hosts', 'hostKeys', 'hostIdentities', 'hostGroups', 'HOST_Tags');
});
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,29 @@
import type { CardField, CardFieldConfig } from '@/types/card';
const fieldConfig = {
rowGap: '10px',
labelSpan: 8,
minHeight: '22px',
fields: [
{
label: 'id',
dataIndex: 'id',
slotName: 'id',
}, {
label: '主机编码',
dataIndex: 'code',
slotName: 'code',
}, {
label: '主机地址',
dataIndex: 'address',
slotName: 'address',
tooltip: true,
}, {
label: '主机标签',
dataIndex: 'tags',
slotName: 'tags',
rowAlign: 'start',
},
] as CardField[]
} as CardFieldConfig;
export default fieldConfig;

View File

@@ -0,0 +1,22 @@
import type { HostSshConfig } from '../components/config/ssh/types/const';
// 主机所有配置
export interface HostConfigWrapper {
ssh: HostSshConfig;
[key: string]: unknown;
}
// 主机配置类型
export const HostConfigType = {
SSH: 'ssh'
};
// tag 颜色
export const tagColor = [
'arcoblue',
'green',
'purple',
'pinkpurple',
'magenta'
];

View File

@@ -0,0 +1,37 @@
import type { FieldRule } from '@arco-design/web-vue';
export const name = [{
required: true,
message: '请输入主机名称'
}, {
maxLength: 64,
message: '主机名称长度不能大于64位'
}] as FieldRule[];
export const code = [{
required: true,
message: '请输入主机编码'
}, {
maxLength: 64,
message: '主机编码长度不能大于64位'
}] as FieldRule[];
export const address = [{
required: true,
message: '请输入主机地址'
}, {
maxLength: 128,
message: '主机地址长度不能大于128位'
}] as FieldRule[];
export const tags = [{
maxLength: 5,
message: '最多选择5个标签'
}] as FieldRule[];
export default {
name,
code,
address,
tags,
} as Record<string, FieldRule | FieldRule[]>;

View File

@@ -0,0 +1,39 @@
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface';
const columns = [
{
title: 'id',
dataIndex: 'id',
slotName: 'id',
width: 70,
align: 'left',
fixed: 'left',
}, {
title: '主机名称',
dataIndex: 'name',
slotName: 'name',
ellipsis: true,
tooltip: true
}, {
title: '主机编码',
dataIndex: 'code',
slotName: 'code',
}, {
title: '主机地址',
dataIndex: 'address',
slotName: 'address',
}, {
title: '主机标签',
dataIndex: 'tags',
slotName: 'tags',
align: 'left',
}, {
title: '操作',
slotName: 'handle',
width: 162,
align: 'center',
fixed: 'right',
},
] as TableColumnData[];
export default columns;