📝 工作台前端.

This commit is contained in:
lijiahang
2024-12-27 11:19:52 +08:00
parent 95759adf91
commit cb5657c685
23 changed files with 1156 additions and 225 deletions

View File

@@ -0,0 +1,26 @@
import type { LineSingleChartData } from '@/types/global';
import type { TerminalConnectLogQueryResponse } from '@/api/asset/terminal-connect-log';
import type { ExecLogQueryResponse } from '@/api/exec/exec-log';
import axios from 'axios';
/**
* 资产模块工作台响应
*/
export interface AssetWorkplaceStatisticsResponse {
execJobCount: number;
todayTerminalConnectCount: number;
todayExecCommandCount: number;
weekTerminalConnectCount: number;
weekExecCommandCount: number;
execCommandChart: LineSingleChartData;
terminalConnectChart: LineSingleChartData;
terminalConnectList: Array<TerminalConnectLogQueryResponse>;
execLogList: Array<ExecLogQueryResponse>;
}
/**
* 查询资产模块工作台统计信息
*/
export function getAssetWorkplaceStatisticsData() {
return axios.get<AssetWorkplaceStatisticsResponse>('/asset/statistics/get-workplace');
}

View File

@@ -0,0 +1,24 @@
import type { LineSingleChartData } from '@/types/global';
import type { LoginHistoryQueryResponse } from '@/api/user/user';
import axios from 'axios';
/**
* 基建模块工作台响应
*/
export interface InfraWorkplaceStatisticsResponse {
userId: number;
username: string;
nickname: string;
unreadMessageCount: number;
lastLoginTime: number;
userSessionCount: number;
operatorChart: LineSingleChartData;
loginHistoryList: Array<LoginHistoryQueryResponse>;
}
/**
* 查询基建模块工作台统计信息
*/
export function getInfraWorkplaceStatisticsData() {
return axios.get<InfraWorkplaceStatisticsResponse>('/infra/statistics/get-workplace');
}

View File

@@ -1,25 +1,34 @@
import type { EChartsOption } from 'echarts'; import type { EChartsOption } from 'echarts';
import { computed } from 'vue'; import { computed } from 'vue';
import { useAppStore } from '@/store'; import useThemes from '@/hooks/themes';
// for code hints // 配置生成
// import { SeriesOption } from 'echarts'; interface OptionsFn {
// Because there are so many configuration items, this provides a relatively convenient code hint. (isDark: boolean, themeTextColor: string, themeLineColor: string): EChartsOption;
// When using vue, pay attention to the reactive issues. It is necessary to ensure that corresponding functions can be triggered, TypeScript does not report errors, and code writing is convenient.
interface optionsFn {
(isDark: boolean): EChartsOption;
} }
export default function useChartOption(sourceOption: optionsFn) { // 亮色文本色
const appStore = useAppStore(); const lightTextColor = '#4E5969';
const isDark = computed(() => {
return appStore.theme === 'dark'; // 暗色文本色
}); const darkTextColor = 'rgba(255, 255, 255, 0.7)';
// echarts support https://echarts.apache.org/zh/theme-builder.html
// It's not used here // 亮色线色
const lightLineColor = '#F2F3F5';
// 暗色线色
const darkLineColor = '#2E2E30';
export default function useChartOption(sourceOption: OptionsFn) {
const { isDark } = useThemes();
// 配置
const chartOption = computed<EChartsOption>(() => { const chartOption = computed<EChartsOption>(() => {
return sourceOption(isDark.value); return sourceOption(isDark.value,
isDark.value ? darkTextColor : lightTextColor,
isDark.value ? darkLineColor : lightLineColor);
}); });
return { return {
chartOption, chartOption,
}; };

View File

@@ -0,0 +1,88 @@
import type { LineSeriesOption } from 'echarts';
/**
* 折线图系列定义
*/
export interface LineSeriesColor {
lineColor: string;
itemBorderColor: string;
}
/**
* 折线图系列常量
*/
export const LineSeriesColors: Record<string, LineSeriesColor> = {
BLUE: {
lineColor: '#4263EB',
itemBorderColor: '#DBE4FF',
},
CYAN: {
lineColor: '#1098AD',
itemBorderColor: '#C5F6FA',
},
GREEN: {
lineColor: '#37B24D',
itemBorderColor: '#D3F9D8',
},
PURPLE: {
lineColor: '#AE3EC9',
itemBorderColor: '#F3D9FA',
},
ORANGE: {
lineColor: '#F76707',
itemBorderColor: '#FFF3BF',
},
VIOLET: {
lineColor: '#7048E8',
itemBorderColor: '#E5DBFF',
},
YELLOW: {
lineColor: '#F59F00',
itemBorderColor: '#FFF3BF',
},
TEAL: {
lineColor: '#0CA678',
itemBorderColor: '#C3FAE8',
},
RED: {
lineColor: '#F03E3E',
itemBorderColor: '#FFE3E3',
}
};
/**
* 生成折线图系列
*/
export const createLineSeries = (name: string,
lineColor: string,
itemBorderColor: string,
data: number[]): LineSeriesOption => {
return {
name,
data,
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 10,
itemStyle: {
color: lineColor,
},
emphasis: {
focus: 'series',
itemStyle: {
color: lineColor,
borderWidth: 2,
borderColor: itemBorderColor,
},
},
lineStyle: {
width: 2,
color: lineColor,
},
showSymbol: data.length === 1,
areaStyle: {
opacity: 0.1,
color: lineColor,
},
};
};

View File

@@ -46,3 +46,8 @@ export interface DataGrid<T> {
} }
export type TimeRanger = [string, string]; export type TimeRanger = [string, string];
export interface LineSingleChartData {
x: string[];
data: Array<number>;
}

View File

@@ -211,11 +211,12 @@
import { deleteTerminalConnectLog, getTerminalConnectLogPage, hostForceOffline } from '@/api/asset/terminal-connect-log'; import { deleteTerminalConnectLog, getTerminalConnectLogPage, hostForceOffline } from '@/api/asset/terminal-connect-log';
import { connectStatusKey, connectTypeKey, TerminalConnectStatus } from '../types/const'; import { connectStatusKey, connectTypeKey, TerminalConnectStatus } from '../types/const';
import { useTablePagination, useRowSelection } from '@/hooks/table'; import { useTablePagination, useRowSelection } from '@/hooks/table';
import { useDictStore } from '@/store'; import { useDictStore, useUserStore } from '@/store';
import { Message } from '@arco-design/web-vue'; import { Message } from '@arco-design/web-vue';
import columns from '../types/table.columns'; import columns from '../types/table.columns';
import useLoading from '@/hooks/loading'; import useLoading from '@/hooks/loading';
import { copy } from '@/hooks/copy'; import { copy } from '@/hooks/copy';
import { useRoute } from 'vue-router';
import { dateFormat } from '@/utils'; import { dateFormat } from '@/utils';
import { openNewRoute } from '@/router'; import { openNewRoute } from '@/router';
import UserSelector from '@/components/user/user/selector/index.vue'; import UserSelector from '@/components/user/user/selector/index.vue';
@@ -223,6 +224,7 @@
const emits = defineEmits(['openClear', 'openDetail']); const emits = defineEmits(['openClear', 'openDetail']);
const route = useRoute();
const pagination = useTablePagination(); const pagination = useTablePagination();
const rowSelection = useRowSelection(); const rowSelection = useRowSelection();
const { loading, setLoading } = useLoading(); const { loading, setLoading } = useLoading();
@@ -316,6 +318,16 @@
}; };
onMounted(() => { onMounted(() => {
// 当前用户
const action = route.query.action as string;
if (action === 'self') {
formModel.userId = useUserStore().id;
}
// id
const id = route.query.id as string;
if (id) {
formModel.id = Number.parseInt(id);
}
fetchTableData(); fetchTableData();
}); });

View File

@@ -198,7 +198,7 @@
</a-doption> </a-doption>
<!-- SSH --> <!-- SSH -->
<a-doption v-if="record.type === HostType.SSH.value" <a-doption v-if="record.type === HostType.SSH.value"
v-permission="['terminal:terminal:access']" v-permission="['asset:terminal:access']"
@click="openNewRoute({ name: 'terminal', query: { connect: record.id, type: 'SSH' } })"> @click="openNewRoute({ name: 'terminal', query: { connect: record.id, type: 'SSH' } })">
<span class="more-doption normal"> <span class="more-doption normal">
<icon-thunderbolt /> SSH <icon-thunderbolt /> SSH
@@ -206,7 +206,7 @@
</a-doption> </a-doption>
<!-- SFTP --> <!-- SFTP -->
<a-doption v-if="record.type === HostType.SSH.value" <a-doption v-if="record.type === HostType.SSH.value"
v-permission="['terminal:terminal:access']" v-permission="['asset:terminal:access']"
@click="openNewRoute({ name: 'terminal', query: { connect: record.id, type: 'SFTP' } })"> @click="openNewRoute({ name: 'terminal', query: { connect: record.id, type: 'SFTP' } })">
<span class="more-doption normal"> <span class="more-doption normal">
<icon-folder /> SFTP <icon-folder /> SFTP

View File

@@ -234,7 +234,7 @@
</a-doption> </a-doption>
<!-- SSH --> <!-- SSH -->
<a-doption v-if="record.type === HostType.SSH.value" <a-doption v-if="record.type === HostType.SSH.value"
v-permission="['terminal:terminal:access']" v-permission="['asset:terminal:access']"
@click="openNewRoute({ name: 'terminal', query: { connect: record.id, type: 'SSH' } })"> @click="openNewRoute({ name: 'terminal', query: { connect: record.id, type: 'SSH' } })">
<span class="more-doption normal"> <span class="more-doption normal">
SSH SSH
@@ -242,7 +242,7 @@
</a-doption> </a-doption>
<!-- SFTP --> <!-- SFTP -->
<a-doption v-if="record.type === HostType.SSH.value" <a-doption v-if="record.type === HostType.SSH.value"
v-permission="['terminal:terminal:access']" v-permission="['asset:terminal:access']"
@click="openNewRoute({ name: 'terminal', query: { connect: record.id, type: 'SFTP' } })"> @click="openNewRoute({ name: 'terminal', query: { connect: record.id, type: 'SFTP' } })">
<span class="more-doption normal"> <span class="more-doption normal">
SFTP SFTP

View File

@@ -1,35 +0,0 @@
<template>
<a-col class="banner">
<a-col :span="8">
<a-typography-title :heading="5" style="margin-top: 0">
欢迎回来! {{ userInfo.name }}
</a-typography-title>
</a-col>
<a-divider class="panel-border" />
</a-col>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useUserStore } from '@/store';
const userStore = useUserStore();
const userInfo = computed(() => {
return {
name: userStore.nickname,
};
});
</script>
<style lang="less" scoped>
.banner {
width: 100%;
padding: 20px 20px 0 20px;
background-color: var(--color-bg-2);
border-radius: 4px 4px 0 0;
}
:deep(.arco-icon-home) {
margin-right: 6px;
}
</style>

View File

@@ -0,0 +1,68 @@
<template>
<a-col :span="8">
<div class="card full">
<div class="card-title">
<p class="card-title-left">最近批量执行记录</p>
<!-- 跳转 -->
<span class="pointer span-blue"
title="详情"
@click="$router.push({ name: 'execCommandLog', query: { action: 'self' } })">
详情
</span>
</div>
<div class="card-body">
<a-table row-key="id"
:loading="loading"
:columns="batchExecColumns"
:data="data.asset?.execLogList || []"
:pagination="false"
:bordered="false"
:scroll="{ y: 258 }">
<!-- 执行状态 -->
<template #status="{ record }">
<a-tag :color="getDictValue(execHostStatusKey, record.status, 'color')">
{{ getDictValue(execHostStatusKey, record.status) }}
</a-tag>
</template>
<!-- 操作 -->
<template #handle="{ record }">
<div class="table-handle-wrapper">
<!-- 日志 -->
<a-button v-permission="['asset:exec-command:exec']"
type="text"
size="mini"
@click="() => $router.push({ name: 'execCommand', query: { id: record.id } })">
日志
</a-button>
</div>
</template>
</a-table>
</div>
</div>
</a-col>
</template>
<script lang="ts">
export default {
name: 'batchExecTable'
};
</script>
<script lang="ts" setup>
import type { WorkplaceStatisticsData } from '@/views/dashboard/workplace/types/const';
import { batchExecColumns } from '../types/table.columns';
import { useDictStore } from '@/store';
import { execHostStatusKey } from '@/components/exec/log/const';
const props = defineProps<{
loading: boolean;
data: WorkplaceStatisticsData;
}>();
const { getDictValue } = useDictStore();
</script>
<style lang="less" scoped>
</style>

View File

@@ -1,37 +0,0 @@
<template>
<a-card class="general-card"
title="帮助文档"
:header-style="{ paddingBottom: '4px' }"
:body-style="{ padding: '0px 20px 8px 20px' }">
<a-row>
<a-col :span="12">
<a-link target="_blank" href="https://github.com/dromara/orion-visor">github</a-link>
</a-col>
<a-col :span="12">
<a-link target="_blank" href="https://gitee.com/dromara/orion-visor">gitee</a-link>
</a-col>
<a-col :span="12">
<a-link target="_blank" href="https://github.com/dromara/orion-visor/blob/main/LICENSE">License</a-link>
</a-col>
<a-col :span="12">
<a-link target="_blank" href="https://github.com/dromara/orion-visor/issues">上报 bug</a-link>
</a-col>
<a-col :span="12">
<a-link target="_blank" href="https://visor.orionsec.cn/operator/asset.html">操作手册</a-link>
</a-col>
<a-col :span="12">
<a-link target="_blank" href="https://visor.orionsec.cn/update/change-log.html">更新日志</a-link>
</a-col>
</a-row>
</a-card>
</template>
<script setup lang="ts">
</script>
<style lang="less" scoped>
.arco-card-body .arco-link {
margin: 10px 0;
color: rgb(var(--gray-8));
}
</style>

View File

@@ -0,0 +1,99 @@
<template>
<a-col :span="16">
<div class="card full">
<div class="card-title">
<p class="card-title-left">系统操作数量 (7)</p>
<!-- 跳转 -->
<span class="pointer span-blue"
title="详情"
@click="$router.push({ name: 'userInfo', query: { tab: 'operatorLog' } })">
详情
</span>
</div>
<!-- 图表 -->
<div class="card-body">
<chart height="440px" :options="chartOption" />
</div>
</div>
</a-col>
</template>
<script lang="ts">
export default {
name: 'operator-log-chart'
};
</script>
<script lang="ts" setup>
import type { WorkplaceStatisticsData } from '@/views/dashboard/workplace/types/const';
import { createLineSeries, LineSeriesColors } from '@/types/chart';
import useChartOption from '@/hooks/chart-option';
const props = defineProps<{
data: WorkplaceStatisticsData;
}>();
// 数量图表配置
const { chartOption } = useChartOption((dark, themeTextColor, themeLineColor) => {
return {
grid: {
left: '50',
right: '36',
top: '12',
bottom: '32',
},
xAxis: {
type: 'category',
offset: 2,
data: props.data.infra?.operatorChart.x || [],
boundaryGap: false,
axisLabel: {
color: themeTextColor,
},
axisLine: {
show: false,
},
axisTick: {
show: false,
},
splitLine: {
show: false,
},
axisPointer: {
show: true,
lineStyle: {
color: '#23ADFF',
width: 2,
},
},
},
yAxis: {
type: 'value',
axisLine: {
show: false,
},
axisLabel: {
color: themeTextColor,
formatter: (value: number) => {
return Math.floor(value);
}
},
splitLine: {
lineStyle: {
color: themeLineColor,
},
},
},
tooltip: {
trigger: 'axis',
},
series: [
createLineSeries('操作数量', LineSeriesColors.BLUE.lineColor, LineSeriesColors.BLUE.itemBorderColor, props.data.infra?.operatorChart.data || []),
],
};
});
</script>
<style lang="less" scoped>
</style>

View File

@@ -1,22 +1,26 @@
<template> <template>
<a-card class="general-card" <a-col :span="4">
title="快捷操作" <div class="card full">
:body-style="{ padding: '0 20px' }"> <div class="card-title">
<a-row :gutter="8"> <p class="card-title-left">快捷操作</p>
<a-col v-for="link in links" </div>
:key="link.meta.locale as string" <a-row :gutter="[12, 18]" class="pb12">
:span="8" <a-col v-for="{ locale, icon, name, newWindow} in links"
class="wrapper" :key="locale as string"
@click="openRoute($event, link)"> :span="8"
<div class="icon"> :title="locale"
<component v-if="link.meta.icon" :is="link.meta.icon" /> class="wrapper"
@click="openRoute($event, name, newWindow)">
<div class="icon">
<component v-if="icon" :is="icon" />
</div>
<div class="text usn">
<span>{{ locale }}</span>
</div> </div>
<a-typography-paragraph class="text">
{{ link.meta.locale }}
</a-typography-paragraph>
</a-col> </a-col>
</a-row> </a-row>
</a-card> </div>
</a-col>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@@ -34,24 +38,80 @@
.filter(s => s.meta) .filter(s => s.meta)
.map(s => s as RouteRecordNormalized) .map(s => s as RouteRecordNormalized)
.filter(s => s.meta.hideInMenu !== true) .filter(s => s.meta.hideInMenu !== true)
.slice(0, 15); .map(s => {
return {
name: s.name,
locale: s.meta.locale,
icon: s.meta.icon,
newWindow: s.meta.newWindow,
};
})
.slice(0, 10);
links.unshift({
locale: '个人中心', icon: 'icon-user', name: 'userInfo', newWindow: false
}, {
locale: '修改密码', icon: 'icon-safe', name: 'updatePassword', newWindow: false
});
// 打开路由 // 打开路由
const openRoute = (e: any, route: RouteRecordNormalized) => { const openRoute = (e: any, name: any, newWindow: any) => {
// 新页面打开 // 新页面打开
if (route.meta.newWindow || e.ctrlKey) { if (newWindow || e.ctrlKey) {
openNewRoute({ openNewRoute({
name: route.name as string, name: name as string,
}); });
return; return;
} }
// 触发跳转 // 触发跳转
router.push({ router.push({
name: route.name as string, name: name as string,
}); });
}; };
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.text {
padding: 0 4px;
span {
height: 14px;
display: block;
font-size: 12px;
text-align: center;
color: rgb(var(--gray-8));
word-wrap: break-word;
overflow: hidden;
}
}
.icon {
display: inline-block;
width: 32px;
height: 32px;
margin-bottom: 4px;
color: rgb(var(--dark-gray-1));
line-height: 32px;
font-size: 16px;
text-align: center;
background-color: rgb(var(--gray-1));
border-radius: 4px;
}
.wrapper {
text-align: center;
cursor: pointer;
&:hover {
.icon {
color: rgb(var(--arcoblue-6));
background-color: #E8F3FF;
}
.text {
color: rgb(var(--arcoblue-6));
}
}
}
</style> </style>

View File

@@ -0,0 +1,82 @@
<template>
<a-col :span="12">
<div class="card full">
<div class="card-title">
<p class="card-title-left">最近终端连接记录</p>
<!-- 跳转 -->
<span class="pointer span-blue"
title="详情"
@click="$router.push({ name: 'connectLog', query: { action: 'self' } })">
详情
</span>
</div>
<div class="card-body">
<a-table row-key="id"
:loading="loading"
:columns="terminalLogColumns"
:data="data.asset?.terminalConnectList || []"
:pagination="false"
:bordered="false"
:scroll="{ y: 258 }">
<!-- 连接主机 -->
<template #hostName="{ record }">
<span class="table-cell-value" :title="record.hostName">
{{ record.hostName }}
</span>
<br>
<span class="table-cell-sub-value text-copy"
:title="record.hostAddress"
@click="copy(record.hostAddress)">
{{ record.hostAddress }}
</span>
</template>
<!-- 类型 -->
<template #type="{ record }">
<a-tag :color="getDictValue(terminalConnectTypeKey, record.type, 'color')">
{{ getDictValue(terminalConnectTypeKey, record.type) }}
</a-tag>
</template>
<!-- 操作 -->
<template #handle="{ record }">
<div class="table-handle-wrapper">
<!-- 连接 SSH -->
<a-button v-permission="['asset:terminal:access']"
type="text"
size="mini"
@click="openNewRoute({ name: 'terminal', query: { connect: record.hostId, type: record.type } })">
连接
</a-button>
</div>
</template>
</a-table>
</div>
</div>
</a-col>
</template>
<script lang="ts">
export default {
name: 'terminalConnectTable'
};
</script>
<script lang="ts" setup>
import type { WorkplaceStatisticsData } from '../types/const';
import { copy } from '@/hooks/copy';
import { terminalLogColumns } from '../types/table.columns';
import { terminalConnectTypeKey } from '../types/const';
import { useDictStore } from '@/store';
import { openNewRoute } from '@/router';
const props = defineProps<{
loading: boolean;
data: WorkplaceStatisticsData;
}>();
const { getDictValue } = useDictStore();
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,60 @@
<template>
<a-col :span="8">
<div class="card full">
<div class="card-title">
<p class="card-title-left">用户登录日志</p>
<!-- 跳转 -->
<span class="pointer span-blue"
title="详情"
@click="$router.push({ name: 'userInfo', query: { tab: 'loginHistory' } })">
详情
</span>
</div>
<div class="card-body">
<a-table row-key="id"
:loading="loading"
:columns="userLoginColumns"
:data="data.infra?.loginHistoryList || []"
:pagination="false"
:bordered="false"
:scroll="{ y: 388 }">
<!-- 登录设备 -->
<template #content="{ record }">
<span>{{ record.address }} - {{ record.location }} - {{ record.userAgent }}</span>
</template>
<!-- 登录结果 -->
<template #result="{ record }">
<a-tag :color="getDictValue(operatorLogResultKey, record.result, 'color')">
{{ getDictValue(operatorLogResultKey, record.result) }}
</a-tag>
</template>
</a-table>
</div>
</div>
</a-col>
</template>
<script lang="ts">
export default {
name: 'userLoginTable'
};
</script>
<script lang="ts" setup>
import type { WorkplaceStatisticsData } from '@/views/dashboard/workplace/types/const';
import { userLoginColumns } from '../types/table.columns';
import { operatorLogResultKey } from '../types/const';
import { useDictStore } from '@/store';
const props = defineProps<{
loading: boolean;
data: WorkplaceStatisticsData;
}>();
const { getDictValue } = useDictStore();
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,76 @@
<template>
<a-row class="card">
<!-- 登录用户 -->
<a-col :span="8">
<a-typography-title :heading="5" style="padding: 4px 0; margin: 0;">
欢迎回来! {{ data.infra?.nickname }}
</a-typography-title>
</a-col>
<a-col :span="16" class="header-right flex-center flex-content-end">
<!-- 未读消息数-->
<a-tag v-if="data.infra?.unreadMessageCount"
class="pointer"
title="查看消息"
color="arcoblue"
@click="setVisibleMessageBox">
<template #icon>
<icon-message />
</template>
您有 {{ data.infra.unreadMessageCount }} 条未读消息
</a-tag>
<a-divider v-if="data.infra?.unreadMessageCount"
direction="vertical"
:margin="14"
style="height: 20px;" />
<!-- 上次登录时间 -->
<div v-if="data.infra?.lastLoginTime"
class="last-login-wrapper">
<span class="last-login-label usn">上次登录时间</span>
<a-tag color="purple">
<template #icon>
<icon-schedule />
</template>
{{ dateFormat(new Date(data.infra.lastLoginTime)) }}
</a-tag>
</div>
</a-col>
</a-row>
</template>
<script lang="ts">
export default {
name: 'workplaceHeader'
};
</script>
<script lang="ts" setup>
import type { WorkplaceStatisticsData } from '@/views/dashboard/workplace/types/const';
import { dateFormat } from '@/utils';
import { openMessageBox } from '@/types/symbol';
import { inject } from 'vue';
const props = defineProps<{
data: WorkplaceStatisticsData;
}>();
// 注入打开消息盒子
const setVisibleMessageBox = inject(openMessageBox) as () => void;
</script>
<style lang="less" scoped>
.header-right {
height: 36px;
}
.last-login-wrapper {
display: flex;
align-items: center;
}
.last-login-label {
color: var(--color-text-2);
margin-right: 8px;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,279 @@
<template>
<a-row :gutter="16">
<!-- 统计信息 -->
<a-col v-for="item in summaryItems"
:span="4"
class="statistics-card">
<div class="card pointer"
title="双击查看详情"
@dblclick="item.go">
<a-statistic :title="item.title"
:value="item.value"
:value-from="0"
:animation-duration="1000"
animation
show-group-separator>
<template #prefix>
<span class="statistic-prefix"
:style="{ background: item.prefix.background }">
<component :is="item.prefix.icon"
:style="{ color: item.prefix.iconColor }" />
</span>
</template>
</a-statistic>
</div>
</a-col>
<!-- 连接终端次数图表 -->
<a-col :span="4" class="statistics-card">
<div class="card" :style="{
background: isDark
? 'linear-gradient(180deg, #284991 0%, #122B62 100%)'
: 'linear-gradient(180deg, #CAE6FA 0%, #CAE6FA 100%)',
}">
<div class="chart-container">
<div class="statistics-wrapper">
<a-statistic title="连接终端次数 (7日)"
:value="data.asset?.weekTerminalConnectCount || 0"
:value-from="0"
:animation-duration="1000"
animation
show-group-separator />
</div>
<div class="chart-wrapper">
<chart width="100%" height="64px" :option="terminalConnectChart" />
</div>
</div>
</div>
</a-col>
<!-- 批量执行次数图表 -->
<a-col :span="4" class="statistics-card">
<div class="card" :style="{
background: isDark
? 'linear-gradient(180deg, #424f32 0%, #424f32 100%)'
: 'linear-gradient(180deg, #BCF5CF 0%, #BCF5CF 100%)',
}">
<div class="chart-container">
<div class="statistics-wrapper">
<a-statistic title="批量执行次数 (7日)"
:value="data.asset?.weekExecCommandCount || 0"
:value-from="0"
:animation-duration="1000"
animation
show-group-separator />
</div>
<div class="chart-wrapper">
<chart width="100%" height="64px" :option="execCommandChart" />
</div>
</div>
</div>
</a-col>
</a-row>
</template>
<script lang="ts">
export default {
name: 'workplaceStatistics'
};
</script>
<script lang="ts" setup>
import type { WorkplaceStatisticsData } from '@/views/dashboard/workplace/types/const';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import useThemes from '@/hooks/themes';
import useChartOption from '@/hooks/chart-option';
const props = defineProps<{
data: WorkplaceStatisticsData;
}>();
const router = useRouter();
const { isDark } = useThemes();
const summaryItems = computed(() => [
{
title: '今日连接终端次数',
value: props.data.asset?.todayTerminalConnectCount || 0,
prefix: {
icon: 'icon-history',
background: isDark.value ? '#354276' : '#E8F3FF',
iconColor: isDark.value ? '#4A7FF7' : '#165DFF',
},
go: () => router.push({ name: 'connectLog', query: { action: 'self' } })
}, {
title: '今日批量执行次数',
value: props.data.asset?.todayExecCommandCount || 0,
prefix: {
icon: 'icon-code-block',
background: isDark.value ? '#3F385E' : '#F5E8FF',
iconColor: isDark.value ? '#8558D3' : '#722ED1',
},
go: () => router.push({ name: 'execCommandLog', query: { action: 'self' } })
}, {
title: '当前登录设备数量',
value: props.data.infra?.userSessionCount || 0,
prefix: {
icon: 'icon-desktop',
background: isDark.value ? '#3D5A62' : '#D6FFF8',
iconColor: isDark.value ? '#6ED1CE' : '#33D1C9',
},
go: () => router.push({ name: 'userInfo', query: { tab: 'userSession' } })
}, {
title: '管理的任务数量',
value: props.data.asset?.execJobCount || 0,
prefix: {
icon: 'icon-calendar-clock',
background: isDark.value ? '#3F385E' : '#F5E8FF',
iconColor: isDark.value ? '#8558D3' : '#722ED1',
},
go: () => router.push({ name: 'execJob', query: { action: 'self' } })
}
]);
const { chartOption: terminalConnectChart } = useChartOption(() => {
return {
grid: {
left: 0,
right: 0,
top: 10,
bottom: 0,
},
xAxis: {
type: 'category',
show: false,
},
yAxis: {
show: false,
},
tooltip: {
show: true,
trigger: 'axis',
formatter: (p: any) => {
return p[0].data.x + ' ' + p[0].data.value + ' 次';
}
},
series: {
name: '连接次数',
data: !props.data.asset?.terminalConnectChart
? []
: props.data.asset?.terminalConnectChart.data.map((s, index) => {
const x = props.data.asset?.terminalConnectChart.x;
return {
x: x[index],
value: s,
};
}),
type: 'line',
showSymbol: false,
smooth: true,
lineStyle: {
color: '#165DFF',
width: 3,
type: 'dashed',
},
},
};
});
const { chartOption: execCommandChart } = useChartOption(() => {
return {
grid: {
left: 0,
right: 0,
top: 10,
bottom: 0,
},
xAxis: {
type: 'category',
show: false,
},
yAxis: {
show: false,
},
tooltip: {
show: true,
trigger: 'axis',
formatter: (p: any) => {
return p[0].data.x + ' ' + p[0].data.value + '次';
}
},
series: {
name: '执行次数',
data: !props.data.asset?.execCommandChart
? []
: props.data.asset?.execCommandChart.data.map((s, index) => {
const x = props.data.asset?.execCommandChart.x;
return {
x: x[index],
value: s,
itemStyle: { color: '#2CAB40' },
};
}),
type: 'bar',
barWidth: 7,
itemStyle: {
borderRadius: 2,
},
},
};
});
</script>
<style lang="less" scoped>
@card-height: 104px;
.statistics-card {
height: @card-height;
:deep(.arco-statistic) {
width: 100%;
.arco-statistic-title {
color: rgb(var(--gray-10));
font-weight: bold;
user-select: none;
width: 100%;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.arco-statistic-value {
display: flex;
align-items: center;
}
}
.statistic-prefix {
display: inline-block;
width: 32px;
height: 32px;
margin-right: 8px;
color: var(--color-white);
font-size: 16px;
line-height: 32px;
text-align: center;
vertical-align: middle;
border-radius: 6px;
}
.chart-container {
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 4px;
.statistics-wrapper {
width: 134px;
}
.chart-wrapper {
width: calc(100% - 134px);
}
}
}
</style>

View File

@@ -1,127 +1,118 @@
<template> <template>
<div class="layout-container"> <div class="layout-container" v-if="render">
<!-- --> <!-- -->
<div class="top-side"> <workplace-header class="mb16"
<!-- 提示 --> :data="statisticsData" />
<div class="panel"> <!-- 统计信息 -->
<banner /> <workplace-statistics class="mb16"
</div> :data="statisticsData" />
</div> <a-row :gutter="16" class="mb16" align="stretch">
<div class="row-wrapper"> <!-- 最近终端连接表格 -->
<div class="left-side"> <terminal-connect-table :loading="assetLoading"
<!-- 操作日志 --> :data="statisticsData" />
<a-card class="general-card" <!-- 最近批量执行表格 -->
title="操作日志" <batch-exec-table :loading="assetLoading"
:body-style="{ padding: '0 20px 8px 20px' }"> :data="statisticsData" />
<operator-log-simple-table :current="true"
:handle-column="false" />
</a-card>
</div>
<a-grid class="right-side"
:cols="24"
:row-gap="16">
<!-- 快捷操作 --> <!-- 快捷操作 -->
<a-grid-item class="card-wrapper" :span="24">
<quick-operation /> <quick-operation />
</a-grid-item> </a-row>
<!-- 文档 --> <a-row :gutter="16" align="stretch">
<a-grid-item class="panel" :span="24"> <!-- 每日操作数量图表 -->
<docs /> <operator-log-chart :data="statisticsData" />
</a-grid-item> <!-- 用户登录日志 -->
</a-grid> <user-login-table :loading="infraLoading"
</div> :data="statisticsData" />
</a-row>
</div> </div>
</template> </template>
<script lang="ts" setup>
import Banner from './components/banner.vue';
import QuickOperation from './components/quick-operation.vue';
import Docs from './components/docs.vue';
import OperatorLogSimpleTable from '@/views/user/operator-log/components/operator-log-simple-table.vue';
</script>
<script lang="ts"> <script lang="ts">
export default { export default {
name: 'workplace', name: 'workplace',
}; };
</script> </script>
<script lang="ts" setup>
import type { WorkplaceStatisticsData } from './types/const';
import { onBeforeMount, onMounted, ref } from 'vue';
import { useDictStore } from '@/store';
import { dictKeys } from './types/const';
import useLoading from '@/hooks/loading';
import { getInfraWorkplaceStatisticsData } from '@/api/statistics/infra-statistics';
import { getAssetWorkplaceStatisticsData } from '@/api/statistics/asset-statistics';
import WorkplaceHeader from './components/workplace-header.vue';
import WorkplaceStatistics from './components/workplace-statistics.vue';
import TerminalConnectTable from './components/terminal-connect-table.vue';
import BatchExecTable from './components/batch-exec-table.vue';
import QuickOperation from './components/quick-operation.vue';
import UserLoginTable from './components/user-login-table.vue';
import OperatorLogChart from './components/operator-log-chart.vue';
const { loading: infraLoading, setLoading: setInfraLoading } = useLoading();
const { loading: assetLoading, setLoading: setAssetLoading } = useLoading();
const render = ref(false);
const statisticsData = ref({} as WorkplaceStatisticsData);
const getWorkplaceData = () => {
// 基建模块
setInfraLoading(true);
getInfraWorkplaceStatisticsData().then(({ data }) => {
setInfraLoading(false);
statisticsData.value.infra = data;
}).catch(() => {
setInfraLoading(false);
});
// 资产模块
setAssetLoading(true);
getAssetWorkplaceStatisticsData().then(({ data }) => {
setAssetLoading(false);
statisticsData.value.asset = data;
}).catch(() => {
setAssetLoading(false);
});
};
onMounted(getWorkplaceData);
// 加载字典值
onBeforeMount(async () => {
const dictStore = useDictStore();
await dictStore.loadKeys(dictKeys);
render.value = true;
});
</script>
<style lang="less" scoped> <style lang="less" scoped>
.top-side {
flex: 1;
overflow: auto;
}
.row-wrapper { :deep(.card) {
margin-top: 16px; padding: 16px 20px;
width: 100%; border-radius: 4px;
background-color: var(--color-bg-2);
&-title {
display: flex; display: flex;
justify-content: space-between;
margin-bottom: 16px;
user-select: none;
.left-side { &-left {
width: calc(100% - 296px); margin: 0;
margin-right: 16px; color: var(--color-text-1);
} font-weight: 600;
.right-side {
width: 280px;
} }
} }
.panel { &-body {
background-color: var(--color-bg-2); height: calc(100% - 36px);
border-radius: 4px;
overflow: auto;
}
:deep(.panel-border) {
margin-bottom: 0;
border-bottom: 1px solid rgb(var(--gray-2));
}
.card-wrapper {
border-radius: 4px;
background-color: var(--color-bg-2);
:deep(.text) {
font-size: 12px;
text-align: center;
color: rgb(var(--gray-8));
}
:deep(.wrapper) {
margin-bottom: 8px;
text-align: center;
cursor: pointer;
&:last-child {
.text {
margin-bottom: 0;
} }
} }
&:hover { :deep(.arco-table-empty) {
.icon { .arco-table-td {
color: rgb(var(--arcoblue-6)); border-bottom: none;
background-color: #E8F3FF;
}
.text {
color: rgb(var(--arcoblue-6));
}
} }
} }
:deep(.icon) {
display: inline-block;
width: 32px;
height: 32px;
margin-bottom: 4px;
color: rgb(var(--dark-gray-1));
line-height: 32px;
font-size: 16px;
text-align: center;
background-color: rgb(var(--gray-1));
border-radius: 4px;
}
}
</style> </style>

View File

@@ -0,0 +1,18 @@
import type { InfraWorkplaceStatisticsResponse } from '@/api/statistics/infra-statistics';
import type { AssetWorkplaceStatisticsResponse } from '@/api/statistics/asset-statistics';
import { execHostStatusKey } from '@/components/exec/log/const';
// 工作台统计数据
export interface WorkplaceStatisticsData {
infra: InfraWorkplaceStatisticsResponse;
asset: AssetWorkplaceStatisticsResponse;
}
// 终端连接类型 字典项
export const terminalConnectTypeKey = 'terminalConnectType';
// 操作日志结果 字典项
export const operatorLogResultKey = 'operatorLogResult';
// 加载的字典值
export const dictKeys = [terminalConnectTypeKey, execHostStatusKey, operatorLogResultKey];

View File

@@ -0,0 +1,93 @@
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface';
import { dateFormat } from '@/utils';
// 终端日志列
export const terminalLogColumns = [
{
title: '连接主机',
dataIndex: 'hostName',
slotName: 'hostName',
align: 'left',
ellipsis: true,
}, {
title: '类型',
dataIndex: 'type',
slotName: 'type',
width: 88,
align: 'left',
}, {
title: '连接时间',
dataIndex: 'startTime',
slotName: 'startTime',
align: 'center',
width: 180,
render: ({ record }) => {
return dateFormat(new Date(record.startTime));
},
}, {
title: '操作',
slotName: 'handle',
width: 92,
align: 'center',
fixed: 'right',
},
] as TableColumnData[];
// 批量执行列
export const batchExecColumns = [
{
title: '执行描述',
dataIndex: 'description',
slotName: 'description',
align: 'left',
ellipsis: true,
tooltip: true,
}, {
title: '执行状态',
dataIndex: 'status',
slotName: 'status',
align: 'left',
width: 108,
}, {
title: '执行时间',
dataIndex: 'startTime',
slotName: 'startTime',
align: 'center',
width: 180,
render: ({ record }) => {
return dateFormat(new Date(record.startTime));
},
}, {
title: '操作',
slotName: 'handle',
width: 92,
align: 'center',
fixed: 'right',
},
] as TableColumnData[];
// 用户登录日志列
export const userLoginColumns = [
{
title: '登录设备',
dataIndex: 'content',
slotName: 'content',
ellipsis: true,
tooltip: true,
}, {
title: '登录结果',
dataIndex: 'result',
slotName: 'result',
align: 'center',
width: 90,
}, {
title: '登录时间',
dataIndex: 'createTime',
slotName: 'createTime',
align: 'center',
width: 180,
render: ({ record }) => {
return dateFormat(new Date(record.createTime));
},
},
] as TableColumnData[];

View File

@@ -217,11 +217,12 @@
getExecCommandLogStatus getExecCommandLogStatus
} from '@/api/exec/exec-command-log'; } from '@/api/exec/exec-command-log';
import { Message } from '@arco-design/web-vue'; import { Message } from '@arco-design/web-vue';
import { useRoute } from 'vue-router';
import useLoading from '@/hooks/loading'; import useLoading from '@/hooks/loading';
import { tableColumns } from '../types/table.columns'; import { tableColumns } from '../types/table.columns';
import { ExecStatus, execStatusKey } from '@/components/exec/log/const'; import { ExecStatus, execStatusKey } from '@/components/exec/log/const';
import { useExpandable, useTablePagination, useRowSelection } from '@/hooks/table'; import { useExpandable, useTablePagination, useRowSelection } from '@/hooks/table';
import { useDictStore } from '@/store'; import { useDictStore, useUserStore } from '@/store';
import { dateFormat, formatDuration } from '@/utils'; import { dateFormat, formatDuration } from '@/utils';
import { reExecCommand } from '@/api/exec/exec-command'; import { reExecCommand } from '@/api/exec/exec-command';
import { interruptExecCommand } from '@/api/exec/exec-command-log'; import { interruptExecCommand } from '@/api/exec/exec-command-log';
@@ -230,6 +231,7 @@
const emits = defineEmits(['viewCommand', 'viewParams', 'viewLog', 'openClear']); const emits = defineEmits(['viewCommand', 'viewParams', 'viewLog', 'openClear']);
const route = useRoute();
const pagination = useTablePagination(); const pagination = useTablePagination();
const rowSelection = useRowSelection(); const rowSelection = useRowSelection();
const expandable = useExpandable(); const expandable = useExpandable();
@@ -406,6 +408,11 @@
}); });
onMounted(() => { onMounted(() => {
// 当前用户
const action = route.query.action as string;
if (action === 'self') {
formModel.userId = useUserStore().id;
}
// 加载数据 // 加载数据
fetchTableData(); fetchTableData();
// 注册状态轮询 // 注册状态轮询

View File

@@ -32,7 +32,6 @@
import { ref, onBeforeMount } from 'vue'; import { ref, onBeforeMount } from 'vue';
import { useDictStore } from '@/store'; import { useDictStore } from '@/store';
import { dictKeys } from '@/components/exec/log/const'; import { dictKeys } from '@/components/exec/log/const';
import { useRouter } from 'vue-router';
import { openNewRoute } from '@/router'; import { openNewRoute } from '@/router';
import ExecCommandLogTable from './components/exec-command-log-table.vue'; import ExecCommandLogTable from './components/exec-command-log-table.vue';
import ExecCommandLogClearModal from './components/exec-command-log-clear-modal.vue'; import ExecCommandLogClearModal from './components/exec-command-log-clear-modal.vue';
@@ -40,8 +39,6 @@
import ShellEditorModal from '@/components/view/shell-editor/modal/index.vue'; import ShellEditorModal from '@/components/view/shell-editor/modal/index.vue';
import ExecLogPanelModal from '@/components/exec/log/panel-modal/index.vue'; import ExecLogPanelModal from '@/components/exec/log/panel-modal/index.vue';
const router = useRouter();
const render = ref(false); const render = ref(false);
const tableRef = ref(); const tableRef = ref();
const logModal = ref(); const logModal = ref();

View File

@@ -1,6 +1,7 @@
<template> <template>
<div class="tabs-container" v-if="render"> <div class="tabs-container" v-if="render">
<a-tabs type="rounded" <a-tabs v-model:active-key="activeTab"
type="rounded"
size="medium" size="medium"
position="left" position="left"
:lazy-load="true" :lazy-load="true"
@@ -64,6 +65,7 @@
const userStore = useUserStore(); const userStore = useUserStore();
const { hasPermission } = usePermission(); const { hasPermission } = usePermission();
const activeTab = ref<string>('mineInfo');
const render = ref<boolean>(false); const render = ref<boolean>(false);
const userId = ref<number>(); const userId = ref<number>();
const user = ref<UserQueryResponse>(); const user = ref<UserQueryResponse>();
@@ -72,11 +74,18 @@
const clickTab = (key: string) => { const clickTab = (key: string) => {
if (key === 'back') { if (key === 'back') {
router.back(); router.back();
} else {
activeTab.value = key;
} }
}; };
// 初始化 // 初始化
const init = async () => { const init = async () => {
// 设置当前面板
const tab = route.query.tab as string;
if (tab) {
activeTab.value = tab;
}
// 获取 userId // 获取 userId
const queryUserId = route.query.id as string; const queryUserId = route.query.id as string;
if (!queryUserId) { if (!queryUserId) {