📝 工作台前端.

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 { computed } from 'vue';
import { useAppStore } from '@/store';
import useThemes from '@/hooks/themes';
// for code hints
// import { SeriesOption } from 'echarts';
// Because there are so many configuration items, this provides a relatively convenient code hint.
// 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;
// 配置生成
interface OptionsFn {
(isDark: boolean, themeTextColor: string, themeLineColor: string): EChartsOption;
}
export default function useChartOption(sourceOption: optionsFn) {
const appStore = useAppStore();
const isDark = computed(() => {
return appStore.theme === 'dark';
});
// echarts support https://echarts.apache.org/zh/theme-builder.html
// It's not used here
// 亮色文本色
const lightTextColor = '#4E5969';
// 暗色文本色
const darkTextColor = 'rgba(255, 255, 255, 0.7)';
// 亮色线色
const lightLineColor = '#F2F3F5';
// 暗色线色
const darkLineColor = '#2E2E30';
export default function useChartOption(sourceOption: OptionsFn) {
const { isDark } = useThemes();
// 配置
const chartOption = computed<EChartsOption>(() => {
return sourceOption(isDark.value);
return sourceOption(isDark.value,
isDark.value ? darkTextColor : lightTextColor,
isDark.value ? darkLineColor : lightLineColor);
});
return {
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 interface LineSingleChartData {
x: string[];
data: Array<number>;
}

View File

@@ -211,11 +211,12 @@
import { deleteTerminalConnectLog, getTerminalConnectLogPage, hostForceOffline } from '@/api/asset/terminal-connect-log';
import { connectStatusKey, connectTypeKey, TerminalConnectStatus } from '../types/const';
import { useTablePagination, useRowSelection } from '@/hooks/table';
import { useDictStore } from '@/store';
import { useDictStore, useUserStore } from '@/store';
import { Message } from '@arco-design/web-vue';
import columns from '../types/table.columns';
import useLoading from '@/hooks/loading';
import { copy } from '@/hooks/copy';
import { useRoute } from 'vue-router';
import { dateFormat } from '@/utils';
import { openNewRoute } from '@/router';
import UserSelector from '@/components/user/user/selector/index.vue';
@@ -223,6 +224,7 @@
const emits = defineEmits(['openClear', 'openDetail']);
const route = useRoute();
const pagination = useTablePagination();
const rowSelection = useRowSelection();
const { loading, setLoading } = useLoading();
@@ -316,6 +318,16 @@
};
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();
});

View File

@@ -198,7 +198,7 @@
</a-doption>
<!-- SSH -->
<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' } })">
<span class="more-doption normal">
<icon-thunderbolt /> SSH
@@ -206,7 +206,7 @@
</a-doption>
<!-- SFTP -->
<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' } })">
<span class="more-doption normal">
<icon-folder /> SFTP

View File

@@ -234,7 +234,7 @@
</a-doption>
<!-- SSH -->
<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' } })">
<span class="more-doption normal">
SSH
@@ -242,7 +242,7 @@
</a-doption>
<!-- SFTP -->
<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' } })">
<span class="more-doption normal">
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>
<a-card class="general-card"
title="快捷操作"
:body-style="{ padding: '0 20px' }">
<a-row :gutter="8">
<a-col v-for="link in links"
:key="link.meta.locale as string"
:span="8"
class="wrapper"
@click="openRoute($event, link)">
<div class="icon">
<component v-if="link.meta.icon" :is="link.meta.icon" />
</div>
<a-typography-paragraph class="text">
{{ link.meta.locale }}
</a-typography-paragraph>
</a-col>
</a-row>
</a-card>
<a-col :span="4">
<div class="card full">
<div class="card-title">
<p class="card-title-left">快捷操作</p>
</div>
<a-row :gutter="[12, 18]" class="pb12">
<a-col v-for="{ locale, icon, name, newWindow} in links"
:key="locale as string"
:span="8"
:title="locale"
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>
</a-col>
</a-row>
</div>
</a-col>
</template>
<script lang="ts" setup>
@@ -34,24 +38,80 @@
.filter(s => s.meta)
.map(s => s as RouteRecordNormalized)
.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({
name: route.name as string,
name: name as string,
});
return;
}
// 触发跳转
router.push({
name: route.name as string,
name: name as string,
});
};
</script>
<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>

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>
<div class="layout-container">
<!-- -->
<div class="top-side">
<!-- 提示 -->
<div class="panel">
<banner />
</div>
</div>
<div class="row-wrapper">
<div class="left-side">
<!-- 操作日志 -->
<a-card class="general-card"
title="操作日志"
:body-style="{ padding: '0 20px 8px 20px' }">
<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 />
</a-grid-item>
<!-- 文档 -->
<a-grid-item class="panel" :span="24">
<docs />
</a-grid-item>
</a-grid>
</div>
<div class="layout-container" v-if="render">
<!-- -->
<workplace-header class="mb16"
:data="statisticsData" />
<!-- 统计信息 -->
<workplace-statistics class="mb16"
:data="statisticsData" />
<a-row :gutter="16" class="mb16" align="stretch">
<!-- 最近终端连接表格 -->
<terminal-connect-table :loading="assetLoading"
:data="statisticsData" />
<!-- 最近批量执行表格 -->
<batch-exec-table :loading="assetLoading"
:data="statisticsData" />
<!-- 快捷操作 -->
<quick-operation />
</a-row>
<a-row :gutter="16" align="stretch">
<!-- 每日操作数量图表 -->
<operator-log-chart :data="statisticsData" />
<!-- 用户登录日志 -->
<user-login-table :loading="infraLoading"
:data="statisticsData" />
</a-row>
</div>
</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">
export default {
name: 'workplace',
};
</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>
.top-side {
flex: 1;
overflow: auto;
}
.row-wrapper {
margin-top: 16px;
width: 100%;
display: flex;
.left-side {
width: calc(100% - 296px);
margin-right: 16px;
}
.right-side {
width: 280px;
}
}
.panel {
background-color: var(--color-bg-2);
border-radius: 4px;
overflow: auto;
}
:deep(.panel-border) {
margin-bottom: 0;
border-bottom: 1px solid rgb(var(--gray-2));
}
.card-wrapper {
:deep(.card) {
padding: 16px 20px;
border-radius: 4px;
background-color: var(--color-bg-2);
:deep(.text) {
font-size: 12px;
text-align: center;
color: rgb(var(--gray-8));
}
&-title {
display: flex;
justify-content: space-between;
margin-bottom: 16px;
user-select: none;
:deep(.wrapper) {
margin-bottom: 8px;
text-align: center;
cursor: pointer;
&:last-child {
.text {
margin-bottom: 0;
}
}
&:hover {
.icon {
color: rgb(var(--arcoblue-6));
background-color: #E8F3FF;
}
.text {
color: rgb(var(--arcoblue-6));
}
&-left {
margin: 0;
color: var(--color-text-1);
font-weight: 600;
}
}
: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;
&-body {
height: calc(100% - 36px);
}
}
:deep(.arco-table-empty) {
.arco-table-td {
border-bottom: none;
}
}
</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
} from '@/api/exec/exec-command-log';
import { Message } from '@arco-design/web-vue';
import { useRoute } from 'vue-router';
import useLoading from '@/hooks/loading';
import { tableColumns } from '../types/table.columns';
import { ExecStatus, execStatusKey } from '@/components/exec/log/const';
import { useExpandable, useTablePagination, useRowSelection } from '@/hooks/table';
import { useDictStore } from '@/store';
import { useDictStore, useUserStore } from '@/store';
import { dateFormat, formatDuration } from '@/utils';
import { reExecCommand } from '@/api/exec/exec-command';
import { interruptExecCommand } from '@/api/exec/exec-command-log';
@@ -230,6 +231,7 @@
const emits = defineEmits(['viewCommand', 'viewParams', 'viewLog', 'openClear']);
const route = useRoute();
const pagination = useTablePagination();
const rowSelection = useRowSelection();
const expandable = useExpandable();
@@ -406,6 +408,11 @@
});
onMounted(() => {
// 当前用户
const action = route.query.action as string;
if (action === 'self') {
formModel.userId = useUserStore().id;
}
// 加载数据
fetchTableData();
// 注册状态轮询

View File

@@ -32,7 +32,6 @@
import { ref, onBeforeMount } from 'vue';
import { useDictStore } from '@/store';
import { dictKeys } from '@/components/exec/log/const';
import { useRouter } from 'vue-router';
import { openNewRoute } from '@/router';
import ExecCommandLogTable from './components/exec-command-log-table.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 ExecLogPanelModal from '@/components/exec/log/panel-modal/index.vue';
const router = useRouter();
const render = ref(false);
const tableRef = ref();
const logModal = ref();

View File

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