This commit is contained in:
2026-03-21 22:23:23 +08:00
parent 8ebb29174f
commit 1697a71693
23 changed files with 5279 additions and 15 deletions

View File

@@ -0,0 +1,705 @@
<template>
<div class="server-detail">
<div class="detail-header">
<div class="server-info">
<h2>{{ record.name || '办公PC详情' }}</h2>
<div class="info-tags">
<a-tag :color="getStatusColor(record.status)">{{ getStatusText(record.status) }}</a-tag>
<a-tag color="blue">{{ record.server_type || '未知类型' }}</a-tag>
<a-tag color="cyan">{{ record.os || '未知系统' }}</a-tag>
</div>
</div>
<div class="header-actions">
<a-button @click="handleRemoteControl">
<template #icon>
<icon-desktop />
</template>
远程登录
</a-button>
<a-button type="primary" @click="handleRestart">
<template #icon>
<icon-refresh />
</template>
重启
</a-button>
</div>
</div>
<a-tabs v-model:active-tab="activeTab" class="detail-tabs">
<a-tab-pane key="overview" title="实例详情">
<a-descriptions :column="2" bordered class="info-descriptions">
<a-descriptions-item label="唯一标识">{{ record.unique_id || '-' }}</a-descriptions-item>
<a-descriptions-item label="办公PC名称">{{ record.name || '-' }}</a-descriptions-item>
<a-descriptions-item label="操作系统">{{ record.os || '-' }}</a-descriptions-item>
<a-descriptions-item label="位置信息">{{ record.location || '-' }}</a-descriptions-item>
<a-descriptions-item label="标签">{{ record.tags || '-' }}</a-descriptions-item>
<a-descriptions-item label="IP地址">{{ record.ip || '-' }}</a-descriptions-item>
<a-descriptions-item label="远程端口">{{ record.remote_port || '-' }}</a-descriptions-item>
<a-descriptions-item label="Agent URL">{{ record.agent_url || '-' }}</a-descriptions-item>
<a-descriptions-item label="数据采集">
<a-tag :color="record.data_collection ? 'green' : 'gray'">
{{ record.data_collection ? '已开启' : '未开启' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="采集间隔">{{ record.collection_interval ? `${record.collection_interval}分钟` : '-' }}</a-descriptions-item>
<a-descriptions-item label="备注信息" :span="2">{{ record.remark || '-' }}</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
<a-tab-pane key="monitor" title="监控">
<div class="monitor-section">
<div class="time-selector">
<a-radio-group v-model="timeRange" type="button">
<a-radio value="1h">1小时</a-radio>
<a-radio value="3h">3小时</a-radio>
<a-radio value="6h">6小时</a-radio>
<a-radio value="12h">12小时</a-radio>
<a-radio value="1d">1</a-radio>
<a-radio value="3d">3</a-radio>
<a-radio value="7d">7</a-radio>
<a-radio value="14d">14</a-radio>
<a-radio value="custom">自定义</a-radio>
</a-radio-group>
</div>
<a-row :gutter="20" class="charts-row">
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">CPU使用率_宿主机视角 (%)</span>
<div class="chart-actions">
<a-select v-model="cpuMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
<a-option value="min">最小值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="cpuOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot green"></span>(ECS)CPU使用率_平均值</span>
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)CPU使用率_最小值</span>
<span class="legend-item"><span class="legend-dot purple"></span>(ECS)CPU使用率_最大值</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">总带宽 (bit/s)</span>
<div class="chart-actions">
<a-select v-model="bandwidthMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="bandwidthOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot green"></span>内网流入带宽</span>
<span class="legend-item"><span class="legend-dot blue"></span>内网流出带宽</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">公网带宽 (bit/s)</span>
<div class="chart-actions">
<a-select v-model="publicBandwidthMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="publicBandwidthOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>公网流入带宽</span>
<span class="legend-item"><span class="legend-dot green"></span>公网流出带宽</span>
</div>
</div>
</a-col>
</a-row>
<a-row :gutter="20" class="charts-row">
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">公网流出带宽使用率 (%)</span>
<div class="chart-actions">
<a-select v-model="bandwidthUsageMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="bandwidthUsageOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)公网流出带宽使用率</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">磁盘读取写入BPS (bytes/s)</span>
<div class="chart-actions">
<a-select v-model="diskMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="diskBpsOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)所有磁盘读取BPS</span>
<span class="legend-item"><span class="legend-dot green"></span>(ECS)所有磁盘写入BPS</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">磁盘读取写入IOPS (Count/Second)</span>
<div class="chart-actions">
<a-select v-model="diskIopsMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="diskIopsOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)所有磁盘每秒读取次数</span>
<span class="legend-item"><span class="legend-dot green"></span>(ECS)所有磁盘每秒写入次数</span>
</div>
</div>
</a-col>
</a-row>
<a-row :gutter="20" class="charts-row">
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">同时连接数 (Count)</span>
<div class="chart-actions">
<a-select v-model="connectionMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="connectionOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>同时连接数</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">RDMA流量使用信息 (bit/s)</span>
<div class="chart-actions">
<a-select v-model="rdmaMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="rdmaOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"></span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">RDMA资源使用信息 (Count)</span>
<div class="chart-actions">
<a-select v-model="rdmaResourceMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="rdmaResourceOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"></span>
</div>
</div>
</a-col>
</a-row>
</div>
</a-tab-pane>
<a-tab-pane key="operation" title="操作记录">
<div class="operation-records">
<a-empty description="暂无操作记录" />
</div>
</a-tab-pane>
</a-tabs>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconDesktop, IconRefresh, IconSettings } from '@arco-design/web-vue/es/icon'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
} from 'echarts/components'
use([
CanvasRenderer,
LineChart,
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
])
interface Props {
record: any
}
const props = defineProps<Props>()
const emit = defineEmits(['remote-control', 'restart', 'close'])
const activeTab = ref('overview')
const timeRange = ref('1h')
const cpuMetric = ref('average')
const bandwidthMetric = ref('average')
const publicBandwidthMetric = ref('average')
const bandwidthUsageMetric = ref('average')
const diskMetric = ref('average')
const diskIopsMetric = ref('average')
const connectionMetric = ref('average')
const rdmaMetric = ref('average')
const rdmaResourceMetric = ref('average')
const generateTimeData = (count: number) => {
const now = Date.now()
const data = []
for (let i = count - 1; i >= 0; i--) {
data.push(new Date(now - i * 60000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }))
}
return data
}
const generateRandomData = (count: number, min: number, max: number) => {
return Array.from({ length: count }, () => Math.floor(Math.random() * (max - min + 1)) + min)
}
const timeData = generateTimeData(60)
const baseOption = {
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255,255,255,0.95)',
borderColor: '#e5e6eb',
textStyle: {
color: '#1d2129',
},
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: timeData,
axisLabel: {
fontSize: 10,
},
},
yAxis: {
type: 'value',
axisLabel: {
fontSize: 10,
},
},
dataZoom: [
{
type: 'inside',
start: 0,
end: 100,
},
],
}
const cpuOption = computed(() => ({
...baseOption,
series: [
{
name: '平均值',
type: 'line',
smooth: true,
data: generateRandomData(60, 20, 80),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(0, 180, 42, 0.3)' },
{ offset: 1, color: 'rgba(0, 180, 42, 0.05)' },
],
},
},
},
{
name: '最小值',
type: 'line',
smooth: true,
data: generateRandomData(60, 10, 40),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '最大值',
type: 'line',
smooth: true,
data: generateRandomData(60, 50, 95),
lineStyle: { color: '#722ed1' },
itemStyle: { color: '#722ed1' },
},
],
}))
const bandwidthOption = computed(() => ({
...baseOption,
series: [
{
name: '内网流入带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 100000, 500000),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
{
name: '内网流出带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 50000, 300000),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
],
}))
const publicBandwidthOption = computed(() => ({
...baseOption,
series: [
{
name: '公网流入带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 50000, 200000),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '公网流出带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 30000, 150000),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
],
}))
const bandwidthUsageOption = computed(() => ({
...baseOption,
series: [
{
name: '公网流出带宽使用率',
type: 'line',
smooth: true,
data: generateRandomData(60, 5, 30),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(22, 93, 255, 0.3)' },
{ offset: 1, color: 'rgba(22, 93, 255, 0.05)' },
],
},
},
},
],
}))
const diskBpsOption = computed(() => ({
...baseOption,
series: [
{
name: '磁盘读取BPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 10000, 100000),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '磁盘写入BPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 20000, 150000),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
],
}))
const diskIopsOption = computed(() => ({
...baseOption,
series: [
{
name: '磁盘读取IOPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 50, 500),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '磁盘写入IOPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 100, 800),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
],
}))
const connectionOption = computed(() => ({
...baseOption,
series: [
{
name: '同时连接数',
type: 'line',
smooth: true,
data: generateRandomData(60, 100, 500),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
],
}))
const rdmaOption = computed(() => ({
...baseOption,
series: [],
}))
const rdmaResourceOption = computed(() => ({
...baseOption,
series: [],
}))
const getStatusColor = (status?: string) => {
const colorMap: Record<string, string> = {
online: 'green',
offline: 'red',
maintenance: 'orange',
retired: 'gray',
}
return colorMap[status || ''] || 'gray'
}
const getStatusText = (status?: string) => {
const textMap: Record<string, string> = {
online: '在线',
offline: '离线',
maintenance: '维护中',
retired: '已退役',
}
return textMap[status || ''] || '-'
}
const handleRemoteControl = () => {
emit('remote-control')
}
const handleRestart = () => {
Message.info('正在发送重启指令...')
emit('restart')
}
</script>
<style scoped lang="less">
.server-detail {
padding: 20px;
background: #fff;
border-radius: 4px;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #e5e6eb;
.server-info {
h2 {
margin: 0 0 12px 0;
font-size: 20px;
font-weight: 600;
color: #1d2129;
}
.info-tags {
display: flex;
gap: 8px;
}
}
.header-actions {
display: flex;
gap: 12px;
}
}
.detail-tabs {
:deep(.arco-tabs-header) {
margin-bottom: 20px;
}
}
.info-descriptions {
:deep(.arco-descriptions-item-label) {
width: 140px;
background: #f7f8fa;
}
}
.monitor-section {
.time-selector {
margin-bottom: 20px;
}
.charts-row {
margin-bottom: 20px;
}
.chart-col {
margin-bottom: 20px;
}
.chart-card {
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 4px;
padding: 16px;
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.chart-title {
font-size: 14px;
font-weight: 500;
color: #1d2129;
}
.chart-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
.chart {
height: 200px;
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e5e6eb;
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #86909c;
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
&.green {
background: #00b42a;
}
&.blue {
background: #165dff;
}
&.purple {
background: #722ed1;
}
}
}
}
}
}
.operation-records {
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,211 @@
<template>
<a-modal
:visible="visible"
:title="isEdit ? '编辑办公PC' : '新增办公PC'"
@ok="handleOk"
@cancel="handleCancel"
@update:visible="handleUpdateVisible"
:confirm-loading="confirmLoading"
width="800px"
>
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="unique_id" label="唯一标识">
<a-input
v-model="formData.unique_id"
placeholder="输入为空系统自动生成UUID"
:disabled="isEdit"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="name" label="办公PC名称">
<a-input v-model="formData.name" placeholder="请输入办公PC名称" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="os" label="操作系统">
<a-select v-model="formData.os" placeholder="请选择操作系统">
<a-option value="windows">Windows</a-option>
<a-option value="linux">Linux</a-option>
<a-option value="other">其它</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="tags" label="标签">
<a-input v-model="formData.tags" placeholder="多个标签,逗号间隔" />
</a-form-item>
</a-col>
</a-row>
<a-form-item field="location" label="位置信息">
<a-input
v-model="formData.location"
placeholder="请输入位置信息"
/>
</a-form-item>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="ip" label="IP地址">
<a-input v-model="formData.ip" placeholder="可以输入多个IP逗号做分隔" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="remote_port" label="远程访问端口">
<a-input v-model="formData.remote_port" placeholder="为空则不可远程访问" />
</a-form-item>
</a-col>
</a-row>
<a-form-item field="agent_url" label="Agent URL配置">
<a-input v-model="formData.agent_url" placeholder="请输入Agent URL配置" />
</a-form-item>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="data_collection" label="数据采集">
<a-switch v-model="formData.data_collection" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item v-if="formData.data_collection" field="collection_interval" label="采集时间">
<a-select v-model="formData.collection_interval" placeholder="请选择采集时间">
<a-option :value="1">1分钟</a-option>
<a-option :value="5">5分钟</a-option>
<a-option :value="10">10分钟</a-option>
<a-option :value="30">30分钟</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item field="remark" label="备注信息">
<a-textarea
v-model="formData.remark"
placeholder="请输入备注信息"
:rows="4"
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { v4 as uuidv4 } from 'uuid'
import type { FormInstance } from '@arco-design/web-vue'
interface Props {
visible: boolean
record?: any
}
const props = withDefaults(defineProps<Props>(), {
record: () => ({}),
})
const emit = defineEmits(['update:visible', 'success'])
const formRef = ref<FormInstance>()
const confirmLoading = ref(false)
const selectedLocation = ref('')
const isEdit = computed(() => !!props.record?.id)
const formData = reactive({
unique_id: '',
name: '',
server_type: '',
os: '',
location: '',
tags: '',
ip: '',
remote_port: '',
agent_url: '',
data_collection: false,
collection_interval: 5,
remark: '',
})
const rules = {
name: [{ required: true, message: '请输入办公名称' }],
server_type: [{ required: true, message: '请选择办公类型' }],
os: [{ required: true, message: '请选择操作系统' }],
}
const locationOptions = ref([
{ label: 'A数据中心-3层-24机柜-5U位', value: 'A数据中心-3层-24机柜-5U位' },
{ label: 'A数据中心-3层-24机柜-6U位', value: 'A数据中心-3层-24机柜-6U位' },
{ label: 'B数据中心-1层-12机柜-1U位', value: 'B数据中心-1层-12机柜-1U位' },
{ label: 'B数据中心-1层-12机柜-2U位', value: 'B数据中心-1层-12机柜-2U位' },
{ label: 'C数据中心-2层-8机柜-3U位', value: 'C数据中心-2层-8机柜-3U位' },
])
watch(
() => props.visible,
(val) => {
if (val) {
if (isEdit.value && props.record) {
Object.assign(formData, props.record)
} else {
Object.assign(formData, {
unique_id: '',
name: '',
server_type: '',
os: '',
location: '',
tags: '',
ip: '',
remote_port: '',
agent_url: '',
data_collection: false,
collection_interval: 5,
remark: '',
})
}
}
}
)
const handleLocationSelect = (value: string) => {
formData.location = value
}
const handleOk = async () => {
try {
await formRef.value?.validate()
if (!formData.unique_id) {
formData.unique_id = uuidv4()
}
confirmLoading.value = true
await new Promise(resolve => setTimeout(resolve, 1000))
Message.success(isEdit.value ? '更新成功' : '创建成功')
emit('success')
handleCancel()
} catch (error) {
console.error('验证失败:', error)
} finally {
confirmLoading.value = false
}
}
const handleUpdateVisible = (value: boolean) => {
emit('update:visible', value)
}
const handleCancel = () => {
emit('update:visible', false)
formRef.value?.resetFields()
}
</script>

View File

@@ -0,0 +1,90 @@
<template>
<a-modal
:visible="visible"
title="快捷配置"
:mask-closable="false"
:ok-loading="loading"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form :model="form" layout="vertical">
<a-form-item label="远程访问端口">
<a-input-number
v-model="form.remote_port"
placeholder="请输入远程访问端口,为空则不可远程访问"
:min="1"
:max="65535"
style="width: 100%"
allow-clear
/>
<template #extra>
<span style="color: #86909c">为空则不可远程访问</span>
</template>
</a-form-item>
<a-form-item label="Agent URL配置">
<a-input
v-model="form.agent_url"
placeholder="请输入Agent URL"
allow-clear
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
interface Props {
visible: boolean
record: any
}
const props = defineProps<Props>()
const emit = defineEmits(['update:visible', 'success'])
const loading = ref(false)
const form = ref({
remote_port: undefined as number | undefined,
agent_url: '',
})
watch(
() => props.visible,
(val) => {
if (val && props.record) {
form.value.remote_port = props.record.remote_port
form.value.agent_url = props.record.agent_url || ''
}
}
)
const handleSubmit = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
// 更新记录
props.record.remote_port = form.value.remote_port
props.record.agent_url = form.value.agent_url
props.record.remote_access = !!form.value.remote_port
props.record.agent_config = !!form.value.agent_url
Message.success('配置成功')
emit('success')
emit('update:visible', false)
} catch (error) {
Message.error('配置失败')
} finally {
loading.value = false
}
}
const handleCancel = () => {
emit('update:visible', false)
}
</script>

View File

@@ -0,0 +1,310 @@
<template>
<div class="remote-control">
<!-- 顶部栏 -->
<div class="header">
<div class="header-left">
<span class="title">{{ record.name || '远程控制' }}</span>
<a-tag :color="getStatusColor(record.status)" size="small">{{ getStatusText(record.status) }}</a-tag>
</div>
<a-button size="small" @click="handleClose">
<template #icon><icon-close /></template>
关闭
</a-button>
</div>
<!-- 登录界面 -->
<div v-if="!isConnected" class="login-box">
<a-card title="SSH 登录" class="login-card">
<a-form :model="loginForm" layout="vertical">
<a-form-item label="连接协议">
<a-radio-group v-model="loginForm.protocol" type="button">
<a-radio value="ssh">SSH</a-radio>
<a-radio value="tat">免密连接</a-radio>
</a-radio-group>
</a-form-item>
<a-row :gutter="16">
<a-col :span="16">
<a-form-item label="主机地址">
<a-input v-model="loginForm.host" placeholder="IP或域名" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="端口">
<a-input-number v-model="loginForm.port" :min="1" :max="65535" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="用户名">
<a-input v-model="loginForm.username" placeholder="请输入用户名" />
</a-form-item>
<a-form-item label="密码">
<a-input-password v-model="loginForm.password" placeholder="请输入密码" />
</a-form-item>
<a-space>
<a-button type="primary" @click="handleLogin" :loading="loginLoading">
连接
</a-button>
<a-button @click="handleClose">取消</a-button>
</a-space>
</a-form>
</a-card>
</div>
<!-- 终端界面 -->
<div v-else class="terminal-box">
<div class="terminal-toolbar">
<span class="terminal-info">已连接: {{ loginForm.username }}@{{ loginForm.host }}:{{ loginForm.port }}</span>
<a-space>
<a-button size="small" @click="handleDisconnect">断开</a-button>
<a-button size="small" @click="handleFullscreen">全屏</a-button>
</a-space>
</div>
<div class="terminal-content" ref="terminalRef">
<div v-for="(line, index) in terminalLines" :key="index" class="terminal-line">
<span v-if="line.type === 'input'" class="prompt">{{ line.prompt }}</span>
<span :class="line.type">{{ line.content }}</span>
</div>
<div class="terminal-input">
<span class="prompt">{{ prompt }}</span>
<input
ref="inputRef"
v-model="currentInput"
@keyup.enter="handleCommand"
class="command-input"
spellcheck="false"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, nextTick, onMounted, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconClose } from '@arco-design/web-vue/es/icon'
interface Props {
record: any
}
const props = defineProps<Props>()
const emit = defineEmits(['close'])
const isConnected = ref(false)
const loginLoading = ref(false)
const terminalRef = ref<HTMLElement>()
const inputRef = ref<HTMLInputElement>()
const loginForm = ref({
protocol: 'ssh',
host: props.record?.ip || '',
port: 22,
username: 'root',
password: '',
})
const terminalLines = ref<Array<{type: string, content: string, prompt?: string}>>([
{ type: 'output', content: `Welcome to ${props.record?.name || 'Server'}!` },
{ type: 'output', content: `Last login: ${new Date().toLocaleString()}` },
])
const currentInput = ref('')
const prompt = computed(() => `${loginForm.value.username}@${loginForm.value.host}:~# `)
const getStatusColor = (status?: string) => {
const map: Record<string, string> = { online: 'green', offline: 'red', maintenance: 'orange' }
return map[status || ''] || 'gray'
}
const getStatusText = (status?: string) => {
const map: Record<string, string> = { online: '在线', offline: '离线', maintenance: '维护中' }
return map[status || ''] || '-'
}
const handleLogin = async () => {
if (!loginForm.value.host || !loginForm.value.username) {
Message.warning('请填写完整信息')
return
}
loginLoading.value = true
await new Promise(r => setTimeout(r, 1000))
isConnected.value = true
loginLoading.value = false
Message.success('连接成功')
nextTick(() => inputRef.value?.focus())
}
const handleCommand = () => {
const cmd = currentInput.value.trim()
if (!cmd) return
terminalLines.value.push({ type: 'input', content: cmd, prompt: prompt.value })
const commands: Record<string, string> = {
help: '可用命令: help, ls, pwd, whoami, date, clear, exit',
ls: 'bin boot dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var',
pwd: '/root',
whoami: loginForm.value.username,
date: new Date().toString(),
}
if (cmd === 'clear') {
terminalLines.value = []
} else if (cmd === 'exit') {
handleDisconnect()
} else {
terminalLines.value.push({ type: 'output', content: commands[cmd] || `命令未找到: ${cmd}` })
}
currentInput.value = ''
nextTick(() => {
terminalRef.value?.scrollTo(0, terminalRef.value.scrollHeight)
})
}
const handleDisconnect = () => {
isConnected.value = false
terminalLines.value = [
{ type: 'output', content: `Welcome to ${props.record?.name || 'Server'}!` },
{ type: 'output', content: `Last login: ${new Date().toLocaleString()}` },
]
Message.info('已断开连接')
}
const handleFullscreen = () => {
if (!document.fullscreenElement) {
terminalRef.value?.requestFullscreen()
} else {
document.exitFullscreen()
}
}
const handleClose = () => {
emit('close')
}
onMounted(() => {
if (props.record?.ip) {
loginForm.value.host = props.record.ip
}
})
</script>
<style scoped lang="less">
.remote-control {
height: 100vh;
display: flex;
flex-direction: column;
background: #f0f2f5;
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
background: #fff;
border-bottom: 1px solid #e5e6eb;
.header-left {
display: flex;
align-items: center;
gap: 12px;
.title {
font-size: 16px;
font-weight: 500;
}
}
}
.login-box {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
.login-card {
width: 480px;
}
}
.terminal-box {
flex: 1;
display: flex;
flex-direction: column;
margin: 16px;
background: #1e1e1e;
border-radius: 4px;
overflow: hidden;
.terminal-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: #2d2d2d;
border-bottom: 1px solid #3a3a3a;
.terminal-info {
color: #c9cdd4;
font-size: 13px;
}
}
.terminal-content {
flex: 1;
padding: 16px;
overflow-y: auto;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 14px;
line-height: 1.6;
.terminal-line {
margin-bottom: 4px;
color: #fff;
.prompt {
color: #00ff00;
margin-right: 8px;
}
&.input {
color: #fff;
}
&.output {
color: #c9cdd4;
}
}
.terminal-input {
display: flex;
align-items: center;
.prompt {
color: #00ff00;
margin-right: 8px;
white-space: nowrap;
}
.command-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: #fff;
font-family: inherit;
font-size: inherit;
caret-color: #00ff00;
}
}
}
}
}
</style>

View File

@@ -163,7 +163,7 @@
</search-table>
<!-- 新增/编辑对话框 -->
<ServerFormDialog
<FormDialog
v-model:visible="formDialogVisible"
:record="currentRecord"
@success="handleFormSuccess"
@@ -195,7 +195,7 @@ import {
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import { searchFormConfig } from './config/search-form'
import ServerFormDialog from '../pc/components/ServerFormDialog.vue'
import FormDialog from '../pc/components/FormDialog.vue'
import QuickConfigDialog from '../pc/components/QuickConfigDialog.vue'
import { columns as columnsConfig } from './config/columns'
import {

View File

@@ -0,0 +1,705 @@
<template>
<div class="server-detail">
<div class="detail-header">
<div class="server-info">
<h2>{{ record.name || '办公PC详情' }}</h2>
<div class="info-tags">
<a-tag :color="getStatusColor(record.status)">{{ getStatusText(record.status) }}</a-tag>
<a-tag color="blue">{{ record.server_type || '未知类型' }}</a-tag>
<a-tag color="cyan">{{ record.os || '未知系统' }}</a-tag>
</div>
</div>
<div class="header-actions">
<a-button @click="handleRemoteControl">
<template #icon>
<icon-desktop />
</template>
远程登录
</a-button>
<a-button type="primary" @click="handleRestart">
<template #icon>
<icon-refresh />
</template>
重启
</a-button>
</div>
</div>
<a-tabs v-model:active-tab="activeTab" class="detail-tabs">
<a-tab-pane key="overview" title="实例详情">
<a-descriptions :column="2" bordered class="info-descriptions">
<a-descriptions-item label="唯一标识">{{ record.unique_id || '-' }}</a-descriptions-item>
<a-descriptions-item label="办公PC名称">{{ record.name || '-' }}</a-descriptions-item>
<a-descriptions-item label="操作系统">{{ record.os || '-' }}</a-descriptions-item>
<a-descriptions-item label="位置信息">{{ record.location || '-' }}</a-descriptions-item>
<a-descriptions-item label="标签">{{ record.tags || '-' }}</a-descriptions-item>
<a-descriptions-item label="IP地址">{{ record.ip || '-' }}</a-descriptions-item>
<a-descriptions-item label="远程端口">{{ record.remote_port || '-' }}</a-descriptions-item>
<a-descriptions-item label="Agent URL">{{ record.agent_url || '-' }}</a-descriptions-item>
<a-descriptions-item label="数据采集">
<a-tag :color="record.data_collection ? 'green' : 'gray'">
{{ record.data_collection ? '已开启' : '未开启' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="采集间隔">{{ record.collection_interval ? `${record.collection_interval}分钟` : '-' }}</a-descriptions-item>
<a-descriptions-item label="备注信息" :span="2">{{ record.remark || '-' }}</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
<a-tab-pane key="monitor" title="监控">
<div class="monitor-section">
<div class="time-selector">
<a-radio-group v-model="timeRange" type="button">
<a-radio value="1h">1小时</a-radio>
<a-radio value="3h">3小时</a-radio>
<a-radio value="6h">6小时</a-radio>
<a-radio value="12h">12小时</a-radio>
<a-radio value="1d">1</a-radio>
<a-radio value="3d">3</a-radio>
<a-radio value="7d">7</a-radio>
<a-radio value="14d">14</a-radio>
<a-radio value="custom">自定义</a-radio>
</a-radio-group>
</div>
<a-row :gutter="20" class="charts-row">
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">CPU使用率_宿主机视角 (%)</span>
<div class="chart-actions">
<a-select v-model="cpuMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
<a-option value="min">最小值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="cpuOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot green"></span>(ECS)CPU使用率_平均值</span>
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)CPU使用率_最小值</span>
<span class="legend-item"><span class="legend-dot purple"></span>(ECS)CPU使用率_最大值</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">总带宽 (bit/s)</span>
<div class="chart-actions">
<a-select v-model="bandwidthMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="bandwidthOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot green"></span>内网流入带宽</span>
<span class="legend-item"><span class="legend-dot blue"></span>内网流出带宽</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">公网带宽 (bit/s)</span>
<div class="chart-actions">
<a-select v-model="publicBandwidthMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="publicBandwidthOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>公网流入带宽</span>
<span class="legend-item"><span class="legend-dot green"></span>公网流出带宽</span>
</div>
</div>
</a-col>
</a-row>
<a-row :gutter="20" class="charts-row">
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">公网流出带宽使用率 (%)</span>
<div class="chart-actions">
<a-select v-model="bandwidthUsageMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="bandwidthUsageOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)公网流出带宽使用率</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">磁盘读取写入BPS (bytes/s)</span>
<div class="chart-actions">
<a-select v-model="diskMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="diskBpsOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)所有磁盘读取BPS</span>
<span class="legend-item"><span class="legend-dot green"></span>(ECS)所有磁盘写入BPS</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">磁盘读取写入IOPS (Count/Second)</span>
<div class="chart-actions">
<a-select v-model="diskIopsMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="diskIopsOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)所有磁盘每秒读取次数</span>
<span class="legend-item"><span class="legend-dot green"></span>(ECS)所有磁盘每秒写入次数</span>
</div>
</div>
</a-col>
</a-row>
<a-row :gutter="20" class="charts-row">
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">同时连接数 (Count)</span>
<div class="chart-actions">
<a-select v-model="connectionMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="connectionOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>同时连接数</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">RDMA流量使用信息 (bit/s)</span>
<div class="chart-actions">
<a-select v-model="rdmaMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="rdmaOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"></span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">RDMA资源使用信息 (Count)</span>
<div class="chart-actions">
<a-select v-model="rdmaResourceMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="rdmaResourceOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"></span>
</div>
</div>
</a-col>
</a-row>
</div>
</a-tab-pane>
<a-tab-pane key="operation" title="操作记录">
<div class="operation-records">
<a-empty description="暂无操作记录" />
</div>
</a-tab-pane>
</a-tabs>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconDesktop, IconRefresh, IconSettings } from '@arco-design/web-vue/es/icon'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
} from 'echarts/components'
use([
CanvasRenderer,
LineChart,
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
])
interface Props {
record: any
}
const props = defineProps<Props>()
const emit = defineEmits(['remote-control', 'restart', 'close'])
const activeTab = ref('overview')
const timeRange = ref('1h')
const cpuMetric = ref('average')
const bandwidthMetric = ref('average')
const publicBandwidthMetric = ref('average')
const bandwidthUsageMetric = ref('average')
const diskMetric = ref('average')
const diskIopsMetric = ref('average')
const connectionMetric = ref('average')
const rdmaMetric = ref('average')
const rdmaResourceMetric = ref('average')
const generateTimeData = (count: number) => {
const now = Date.now()
const data = []
for (let i = count - 1; i >= 0; i--) {
data.push(new Date(now - i * 60000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }))
}
return data
}
const generateRandomData = (count: number, min: number, max: number) => {
return Array.from({ length: count }, () => Math.floor(Math.random() * (max - min + 1)) + min)
}
const timeData = generateTimeData(60)
const baseOption = {
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255,255,255,0.95)',
borderColor: '#e5e6eb',
textStyle: {
color: '#1d2129',
},
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: timeData,
axisLabel: {
fontSize: 10,
},
},
yAxis: {
type: 'value',
axisLabel: {
fontSize: 10,
},
},
dataZoom: [
{
type: 'inside',
start: 0,
end: 100,
},
],
}
const cpuOption = computed(() => ({
...baseOption,
series: [
{
name: '平均值',
type: 'line',
smooth: true,
data: generateRandomData(60, 20, 80),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(0, 180, 42, 0.3)' },
{ offset: 1, color: 'rgba(0, 180, 42, 0.05)' },
],
},
},
},
{
name: '最小值',
type: 'line',
smooth: true,
data: generateRandomData(60, 10, 40),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '最大值',
type: 'line',
smooth: true,
data: generateRandomData(60, 50, 95),
lineStyle: { color: '#722ed1' },
itemStyle: { color: '#722ed1' },
},
],
}))
const bandwidthOption = computed(() => ({
...baseOption,
series: [
{
name: '内网流入带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 100000, 500000),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
{
name: '内网流出带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 50000, 300000),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
],
}))
const publicBandwidthOption = computed(() => ({
...baseOption,
series: [
{
name: '公网流入带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 50000, 200000),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '公网流出带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 30000, 150000),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
],
}))
const bandwidthUsageOption = computed(() => ({
...baseOption,
series: [
{
name: '公网流出带宽使用率',
type: 'line',
smooth: true,
data: generateRandomData(60, 5, 30),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(22, 93, 255, 0.3)' },
{ offset: 1, color: 'rgba(22, 93, 255, 0.05)' },
],
},
},
},
],
}))
const diskBpsOption = computed(() => ({
...baseOption,
series: [
{
name: '磁盘读取BPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 10000, 100000),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '磁盘写入BPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 20000, 150000),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
],
}))
const diskIopsOption = computed(() => ({
...baseOption,
series: [
{
name: '磁盘读取IOPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 50, 500),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '磁盘写入IOPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 100, 800),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
],
}))
const connectionOption = computed(() => ({
...baseOption,
series: [
{
name: '同时连接数',
type: 'line',
smooth: true,
data: generateRandomData(60, 100, 500),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
],
}))
const rdmaOption = computed(() => ({
...baseOption,
series: [],
}))
const rdmaResourceOption = computed(() => ({
...baseOption,
series: [],
}))
const getStatusColor = (status?: string) => {
const colorMap: Record<string, string> = {
online: 'green',
offline: 'red',
maintenance: 'orange',
retired: 'gray',
}
return colorMap[status || ''] || 'gray'
}
const getStatusText = (status?: string) => {
const textMap: Record<string, string> = {
online: '在线',
offline: '离线',
maintenance: '维护中',
retired: '已退役',
}
return textMap[status || ''] || '-'
}
const handleRemoteControl = () => {
emit('remote-control')
}
const handleRestart = () => {
Message.info('正在发送重启指令...')
emit('restart')
}
</script>
<style scoped lang="less">
.server-detail {
padding: 20px;
background: #fff;
border-radius: 4px;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #e5e6eb;
.server-info {
h2 {
margin: 0 0 12px 0;
font-size: 20px;
font-weight: 600;
color: #1d2129;
}
.info-tags {
display: flex;
gap: 8px;
}
}
.header-actions {
display: flex;
gap: 12px;
}
}
.detail-tabs {
:deep(.arco-tabs-header) {
margin-bottom: 20px;
}
}
.info-descriptions {
:deep(.arco-descriptions-item-label) {
width: 140px;
background: #f7f8fa;
}
}
.monitor-section {
.time-selector {
margin-bottom: 20px;
}
.charts-row {
margin-bottom: 20px;
}
.chart-col {
margin-bottom: 20px;
}
.chart-card {
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 4px;
padding: 16px;
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.chart-title {
font-size: 14px;
font-weight: 500;
color: #1d2129;
}
.chart-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
.chart {
height: 200px;
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e5e6eb;
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #86909c;
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
&.green {
background: #00b42a;
}
&.blue {
background: #165dff;
}
&.purple {
background: #722ed1;
}
}
}
}
}
}
.operation-records {
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,211 @@
<template>
<a-modal
:visible="visible"
:title="isEdit ? '编辑办公PC' : '新增办公PC'"
@ok="handleOk"
@cancel="handleCancel"
@update:visible="handleUpdateVisible"
:confirm-loading="confirmLoading"
width="800px"
>
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="unique_id" label="唯一标识">
<a-input
v-model="formData.unique_id"
placeholder="输入为空系统自动生成UUID"
:disabled="isEdit"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="name" label="办公PC名称">
<a-input v-model="formData.name" placeholder="请输入办公PC名称" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="os" label="操作系统">
<a-select v-model="formData.os" placeholder="请选择操作系统">
<a-option value="windows">Windows</a-option>
<a-option value="linux">Linux</a-option>
<a-option value="other">其它</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="tags" label="标签">
<a-input v-model="formData.tags" placeholder="多个标签,逗号间隔" />
</a-form-item>
</a-col>
</a-row>
<a-form-item field="location" label="位置信息">
<a-input
v-model="formData.location"
placeholder="请输入位置信息"
/>
</a-form-item>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="ip" label="IP地址">
<a-input v-model="formData.ip" placeholder="可以输入多个IP逗号做分隔" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="remote_port" label="远程访问端口">
<a-input v-model="formData.remote_port" placeholder="为空则不可远程访问" />
</a-form-item>
</a-col>
</a-row>
<a-form-item field="agent_url" label="Agent URL配置">
<a-input v-model="formData.agent_url" placeholder="请输入Agent URL配置" />
</a-form-item>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="data_collection" label="数据采集">
<a-switch v-model="formData.data_collection" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item v-if="formData.data_collection" field="collection_interval" label="采集时间">
<a-select v-model="formData.collection_interval" placeholder="请选择采集时间">
<a-option :value="1">1分钟</a-option>
<a-option :value="5">5分钟</a-option>
<a-option :value="10">10分钟</a-option>
<a-option :value="30">30分钟</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item field="remark" label="备注信息">
<a-textarea
v-model="formData.remark"
placeholder="请输入备注信息"
:rows="4"
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { v4 as uuidv4 } from 'uuid'
import type { FormInstance } from '@arco-design/web-vue'
interface Props {
visible: boolean
record?: any
}
const props = withDefaults(defineProps<Props>(), {
record: () => ({}),
})
const emit = defineEmits(['update:visible', 'success'])
const formRef = ref<FormInstance>()
const confirmLoading = ref(false)
const selectedLocation = ref('')
const isEdit = computed(() => !!props.record?.id)
const formData = reactive({
unique_id: '',
name: '',
server_type: '',
os: '',
location: '',
tags: '',
ip: '',
remote_port: '',
agent_url: '',
data_collection: false,
collection_interval: 5,
remark: '',
})
const rules = {
name: [{ required: true, message: '请输入办公名称' }],
server_type: [{ required: true, message: '请选择办公类型' }],
os: [{ required: true, message: '请选择操作系统' }],
}
const locationOptions = ref([
{ label: 'A数据中心-3层-24机柜-5U位', value: 'A数据中心-3层-24机柜-5U位' },
{ label: 'A数据中心-3层-24机柜-6U位', value: 'A数据中心-3层-24机柜-6U位' },
{ label: 'B数据中心-1层-12机柜-1U位', value: 'B数据中心-1层-12机柜-1U位' },
{ label: 'B数据中心-1层-12机柜-2U位', value: 'B数据中心-1层-12机柜-2U位' },
{ label: 'C数据中心-2层-8机柜-3U位', value: 'C数据中心-2层-8机柜-3U位' },
])
watch(
() => props.visible,
(val) => {
if (val) {
if (isEdit.value && props.record) {
Object.assign(formData, props.record)
} else {
Object.assign(formData, {
unique_id: '',
name: '',
server_type: '',
os: '',
location: '',
tags: '',
ip: '',
remote_port: '',
agent_url: '',
data_collection: false,
collection_interval: 5,
remark: '',
})
}
}
}
)
const handleLocationSelect = (value: string) => {
formData.location = value
}
const handleOk = async () => {
try {
await formRef.value?.validate()
if (!formData.unique_id) {
formData.unique_id = uuidv4()
}
confirmLoading.value = true
await new Promise(resolve => setTimeout(resolve, 1000))
Message.success(isEdit.value ? '更新成功' : '创建成功')
emit('success')
handleCancel()
} catch (error) {
console.error('验证失败:', error)
} finally {
confirmLoading.value = false
}
}
const handleUpdateVisible = (value: boolean) => {
emit('update:visible', value)
}
const handleCancel = () => {
emit('update:visible', false)
formRef.value?.resetFields()
}
</script>

View File

@@ -0,0 +1,90 @@
<template>
<a-modal
:visible="visible"
title="快捷配置"
:mask-closable="false"
:ok-loading="loading"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form :model="form" layout="vertical">
<a-form-item label="远程访问端口">
<a-input-number
v-model="form.remote_port"
placeholder="请输入远程访问端口,为空则不可远程访问"
:min="1"
:max="65535"
style="width: 100%"
allow-clear
/>
<template #extra>
<span style="color: #86909c">为空则不可远程访问</span>
</template>
</a-form-item>
<a-form-item label="Agent URL配置">
<a-input
v-model="form.agent_url"
placeholder="请输入Agent URL"
allow-clear
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
interface Props {
visible: boolean
record: any
}
const props = defineProps<Props>()
const emit = defineEmits(['update:visible', 'success'])
const loading = ref(false)
const form = ref({
remote_port: undefined as number | undefined,
agent_url: '',
})
watch(
() => props.visible,
(val) => {
if (val && props.record) {
form.value.remote_port = props.record.remote_port
form.value.agent_url = props.record.agent_url || ''
}
}
)
const handleSubmit = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
// 更新记录
props.record.remote_port = form.value.remote_port
props.record.agent_url = form.value.agent_url
props.record.remote_access = !!form.value.remote_port
props.record.agent_config = !!form.value.agent_url
Message.success('配置成功')
emit('success')
emit('update:visible', false)
} catch (error) {
Message.error('配置失败')
} finally {
loading.value = false
}
}
const handleCancel = () => {
emit('update:visible', false)
}
</script>

View File

@@ -0,0 +1,310 @@
<template>
<div class="remote-control">
<!-- 顶部栏 -->
<div class="header">
<div class="header-left">
<span class="title">{{ record.name || '远程控制' }}</span>
<a-tag :color="getStatusColor(record.status)" size="small">{{ getStatusText(record.status) }}</a-tag>
</div>
<a-button size="small" @click="handleClose">
<template #icon><icon-close /></template>
关闭
</a-button>
</div>
<!-- 登录界面 -->
<div v-if="!isConnected" class="login-box">
<a-card title="SSH 登录" class="login-card">
<a-form :model="loginForm" layout="vertical">
<a-form-item label="连接协议">
<a-radio-group v-model="loginForm.protocol" type="button">
<a-radio value="ssh">SSH</a-radio>
<a-radio value="tat">免密连接</a-radio>
</a-radio-group>
</a-form-item>
<a-row :gutter="16">
<a-col :span="16">
<a-form-item label="主机地址">
<a-input v-model="loginForm.host" placeholder="IP或域名" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="端口">
<a-input-number v-model="loginForm.port" :min="1" :max="65535" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="用户名">
<a-input v-model="loginForm.username" placeholder="请输入用户名" />
</a-form-item>
<a-form-item label="密码">
<a-input-password v-model="loginForm.password" placeholder="请输入密码" />
</a-form-item>
<a-space>
<a-button type="primary" @click="handleLogin" :loading="loginLoading">
连接
</a-button>
<a-button @click="handleClose">取消</a-button>
</a-space>
</a-form>
</a-card>
</div>
<!-- 终端界面 -->
<div v-else class="terminal-box">
<div class="terminal-toolbar">
<span class="terminal-info">已连接: {{ loginForm.username }}@{{ loginForm.host }}:{{ loginForm.port }}</span>
<a-space>
<a-button size="small" @click="handleDisconnect">断开</a-button>
<a-button size="small" @click="handleFullscreen">全屏</a-button>
</a-space>
</div>
<div class="terminal-content" ref="terminalRef">
<div v-for="(line, index) in terminalLines" :key="index" class="terminal-line">
<span v-if="line.type === 'input'" class="prompt">{{ line.prompt }}</span>
<span :class="line.type">{{ line.content }}</span>
</div>
<div class="terminal-input">
<span class="prompt">{{ prompt }}</span>
<input
ref="inputRef"
v-model="currentInput"
@keyup.enter="handleCommand"
class="command-input"
spellcheck="false"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, nextTick, onMounted, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconClose } from '@arco-design/web-vue/es/icon'
interface Props {
record: any
}
const props = defineProps<Props>()
const emit = defineEmits(['close'])
const isConnected = ref(false)
const loginLoading = ref(false)
const terminalRef = ref<HTMLElement>()
const inputRef = ref<HTMLInputElement>()
const loginForm = ref({
protocol: 'ssh',
host: props.record?.ip || '',
port: 22,
username: 'root',
password: '',
})
const terminalLines = ref<Array<{type: string, content: string, prompt?: string}>>([
{ type: 'output', content: `Welcome to ${props.record?.name || 'Server'}!` },
{ type: 'output', content: `Last login: ${new Date().toLocaleString()}` },
])
const currentInput = ref('')
const prompt = computed(() => `${loginForm.value.username}@${loginForm.value.host}:~# `)
const getStatusColor = (status?: string) => {
const map: Record<string, string> = { online: 'green', offline: 'red', maintenance: 'orange' }
return map[status || ''] || 'gray'
}
const getStatusText = (status?: string) => {
const map: Record<string, string> = { online: '在线', offline: '离线', maintenance: '维护中' }
return map[status || ''] || '-'
}
const handleLogin = async () => {
if (!loginForm.value.host || !loginForm.value.username) {
Message.warning('请填写完整信息')
return
}
loginLoading.value = true
await new Promise(r => setTimeout(r, 1000))
isConnected.value = true
loginLoading.value = false
Message.success('连接成功')
nextTick(() => inputRef.value?.focus())
}
const handleCommand = () => {
const cmd = currentInput.value.trim()
if (!cmd) return
terminalLines.value.push({ type: 'input', content: cmd, prompt: prompt.value })
const commands: Record<string, string> = {
help: '可用命令: help, ls, pwd, whoami, date, clear, exit',
ls: 'bin boot dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var',
pwd: '/root',
whoami: loginForm.value.username,
date: new Date().toString(),
}
if (cmd === 'clear') {
terminalLines.value = []
} else if (cmd === 'exit') {
handleDisconnect()
} else {
terminalLines.value.push({ type: 'output', content: commands[cmd] || `命令未找到: ${cmd}` })
}
currentInput.value = ''
nextTick(() => {
terminalRef.value?.scrollTo(0, terminalRef.value.scrollHeight)
})
}
const handleDisconnect = () => {
isConnected.value = false
terminalLines.value = [
{ type: 'output', content: `Welcome to ${props.record?.name || 'Server'}!` },
{ type: 'output', content: `Last login: ${new Date().toLocaleString()}` },
]
Message.info('已断开连接')
}
const handleFullscreen = () => {
if (!document.fullscreenElement) {
terminalRef.value?.requestFullscreen()
} else {
document.exitFullscreen()
}
}
const handleClose = () => {
emit('close')
}
onMounted(() => {
if (props.record?.ip) {
loginForm.value.host = props.record.ip
}
})
</script>
<style scoped lang="less">
.remote-control {
height: 100vh;
display: flex;
flex-direction: column;
background: #f0f2f5;
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
background: #fff;
border-bottom: 1px solid #e5e6eb;
.header-left {
display: flex;
align-items: center;
gap: 12px;
.title {
font-size: 16px;
font-weight: 500;
}
}
}
.login-box {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
.login-card {
width: 480px;
}
}
.terminal-box {
flex: 1;
display: flex;
flex-direction: column;
margin: 16px;
background: #1e1e1e;
border-radius: 4px;
overflow: hidden;
.terminal-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: #2d2d2d;
border-bottom: 1px solid #3a3a3a;
.terminal-info {
color: #c9cdd4;
font-size: 13px;
}
}
.terminal-content {
flex: 1;
padding: 16px;
overflow-y: auto;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 14px;
line-height: 1.6;
.terminal-line {
margin-bottom: 4px;
color: #fff;
.prompt {
color: #00ff00;
margin-right: 8px;
}
&.input {
color: #fff;
}
&.output {
color: #c9cdd4;
}
}
.terminal-input {
display: flex;
align-items: center;
.prompt {
color: #00ff00;
margin-right: 8px;
white-space: nowrap;
}
.command-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: #fff;
font-family: inherit;
font-size: inherit;
caret-color: #00ff00;
}
}
}
}
}
</style>

View File

@@ -163,7 +163,7 @@
</search-table>
<!-- 新增/编辑对话框 -->
<ServerFormDialog
<FormDialog
v-model:visible="formDialogVisible"
:record="currentRecord"
@success="handleFormSuccess"
@@ -195,7 +195,7 @@ import {
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import { searchFormConfig } from './config/search-form'
import ServerFormDialog from '../pc/components/ServerFormDialog.vue'
import FormDialog from '../pc/components/FormDialog.vue'
import QuickConfigDialog from '../pc/components/QuickConfigDialog.vue'
import { columns as columnsConfig } from './config/columns'
import {

View File

@@ -0,0 +1,705 @@
<template>
<div class="server-detail">
<div class="detail-header">
<div class="server-info">
<h2>{{ record.name || '办公PC详情' }}</h2>
<div class="info-tags">
<a-tag :color="getStatusColor(record.status)">{{ getStatusText(record.status) }}</a-tag>
<a-tag color="blue">{{ record.server_type || '未知类型' }}</a-tag>
<a-tag color="cyan">{{ record.os || '未知系统' }}</a-tag>
</div>
</div>
<div class="header-actions">
<a-button @click="handleRemoteControl">
<template #icon>
<icon-desktop />
</template>
远程登录
</a-button>
<a-button type="primary" @click="handleRestart">
<template #icon>
<icon-refresh />
</template>
重启
</a-button>
</div>
</div>
<a-tabs v-model:active-tab="activeTab" class="detail-tabs">
<a-tab-pane key="overview" title="实例详情">
<a-descriptions :column="2" bordered class="info-descriptions">
<a-descriptions-item label="唯一标识">{{ record.unique_id || '-' }}</a-descriptions-item>
<a-descriptions-item label="办公PC名称">{{ record.name || '-' }}</a-descriptions-item>
<a-descriptions-item label="操作系统">{{ record.os || '-' }}</a-descriptions-item>
<a-descriptions-item label="位置信息">{{ record.location || '-' }}</a-descriptions-item>
<a-descriptions-item label="标签">{{ record.tags || '-' }}</a-descriptions-item>
<a-descriptions-item label="IP地址">{{ record.ip || '-' }}</a-descriptions-item>
<a-descriptions-item label="远程端口">{{ record.remote_port || '-' }}</a-descriptions-item>
<a-descriptions-item label="Agent URL">{{ record.agent_url || '-' }}</a-descriptions-item>
<a-descriptions-item label="数据采集">
<a-tag :color="record.data_collection ? 'green' : 'gray'">
{{ record.data_collection ? '已开启' : '未开启' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="采集间隔">{{ record.collection_interval ? `${record.collection_interval}分钟` : '-' }}</a-descriptions-item>
<a-descriptions-item label="备注信息" :span="2">{{ record.remark || '-' }}</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
<a-tab-pane key="monitor" title="监控">
<div class="monitor-section">
<div class="time-selector">
<a-radio-group v-model="timeRange" type="button">
<a-radio value="1h">1小时</a-radio>
<a-radio value="3h">3小时</a-radio>
<a-radio value="6h">6小时</a-radio>
<a-radio value="12h">12小时</a-radio>
<a-radio value="1d">1</a-radio>
<a-radio value="3d">3</a-radio>
<a-radio value="7d">7</a-radio>
<a-radio value="14d">14</a-radio>
<a-radio value="custom">自定义</a-radio>
</a-radio-group>
</div>
<a-row :gutter="20" class="charts-row">
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">CPU使用率_宿主机视角 (%)</span>
<div class="chart-actions">
<a-select v-model="cpuMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
<a-option value="min">最小值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="cpuOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot green"></span>(ECS)CPU使用率_平均值</span>
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)CPU使用率_最小值</span>
<span class="legend-item"><span class="legend-dot purple"></span>(ECS)CPU使用率_最大值</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">总带宽 (bit/s)</span>
<div class="chart-actions">
<a-select v-model="bandwidthMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="bandwidthOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot green"></span>内网流入带宽</span>
<span class="legend-item"><span class="legend-dot blue"></span>内网流出带宽</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">公网带宽 (bit/s)</span>
<div class="chart-actions">
<a-select v-model="publicBandwidthMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="publicBandwidthOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>公网流入带宽</span>
<span class="legend-item"><span class="legend-dot green"></span>公网流出带宽</span>
</div>
</div>
</a-col>
</a-row>
<a-row :gutter="20" class="charts-row">
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">公网流出带宽使用率 (%)</span>
<div class="chart-actions">
<a-select v-model="bandwidthUsageMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="bandwidthUsageOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)公网流出带宽使用率</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">磁盘读取写入BPS (bytes/s)</span>
<div class="chart-actions">
<a-select v-model="diskMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="diskBpsOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)所有磁盘读取BPS</span>
<span class="legend-item"><span class="legend-dot green"></span>(ECS)所有磁盘写入BPS</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">磁盘读取写入IOPS (Count/Second)</span>
<div class="chart-actions">
<a-select v-model="diskIopsMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="diskIopsOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)所有磁盘每秒读取次数</span>
<span class="legend-item"><span class="legend-dot green"></span>(ECS)所有磁盘每秒写入次数</span>
</div>
</div>
</a-col>
</a-row>
<a-row :gutter="20" class="charts-row">
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">同时连接数 (Count)</span>
<div class="chart-actions">
<a-select v-model="connectionMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="connectionOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>同时连接数</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">RDMA流量使用信息 (bit/s)</span>
<div class="chart-actions">
<a-select v-model="rdmaMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="rdmaOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"></span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">RDMA资源使用信息 (Count)</span>
<div class="chart-actions">
<a-select v-model="rdmaResourceMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="rdmaResourceOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"></span>
</div>
</div>
</a-col>
</a-row>
</div>
</a-tab-pane>
<a-tab-pane key="operation" title="操作记录">
<div class="operation-records">
<a-empty description="暂无操作记录" />
</div>
</a-tab-pane>
</a-tabs>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconDesktop, IconRefresh, IconSettings } from '@arco-design/web-vue/es/icon'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
} from 'echarts/components'
use([
CanvasRenderer,
LineChart,
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
])
interface Props {
record: any
}
const props = defineProps<Props>()
const emit = defineEmits(['remote-control', 'restart', 'close'])
const activeTab = ref('overview')
const timeRange = ref('1h')
const cpuMetric = ref('average')
const bandwidthMetric = ref('average')
const publicBandwidthMetric = ref('average')
const bandwidthUsageMetric = ref('average')
const diskMetric = ref('average')
const diskIopsMetric = ref('average')
const connectionMetric = ref('average')
const rdmaMetric = ref('average')
const rdmaResourceMetric = ref('average')
const generateTimeData = (count: number) => {
const now = Date.now()
const data = []
for (let i = count - 1; i >= 0; i--) {
data.push(new Date(now - i * 60000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }))
}
return data
}
const generateRandomData = (count: number, min: number, max: number) => {
return Array.from({ length: count }, () => Math.floor(Math.random() * (max - min + 1)) + min)
}
const timeData = generateTimeData(60)
const baseOption = {
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255,255,255,0.95)',
borderColor: '#e5e6eb',
textStyle: {
color: '#1d2129',
},
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: timeData,
axisLabel: {
fontSize: 10,
},
},
yAxis: {
type: 'value',
axisLabel: {
fontSize: 10,
},
},
dataZoom: [
{
type: 'inside',
start: 0,
end: 100,
},
],
}
const cpuOption = computed(() => ({
...baseOption,
series: [
{
name: '平均值',
type: 'line',
smooth: true,
data: generateRandomData(60, 20, 80),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(0, 180, 42, 0.3)' },
{ offset: 1, color: 'rgba(0, 180, 42, 0.05)' },
],
},
},
},
{
name: '最小值',
type: 'line',
smooth: true,
data: generateRandomData(60, 10, 40),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '最大值',
type: 'line',
smooth: true,
data: generateRandomData(60, 50, 95),
lineStyle: { color: '#722ed1' },
itemStyle: { color: '#722ed1' },
},
],
}))
const bandwidthOption = computed(() => ({
...baseOption,
series: [
{
name: '内网流入带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 100000, 500000),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
{
name: '内网流出带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 50000, 300000),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
],
}))
const publicBandwidthOption = computed(() => ({
...baseOption,
series: [
{
name: '公网流入带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 50000, 200000),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '公网流出带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 30000, 150000),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
],
}))
const bandwidthUsageOption = computed(() => ({
...baseOption,
series: [
{
name: '公网流出带宽使用率',
type: 'line',
smooth: true,
data: generateRandomData(60, 5, 30),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(22, 93, 255, 0.3)' },
{ offset: 1, color: 'rgba(22, 93, 255, 0.05)' },
],
},
},
},
],
}))
const diskBpsOption = computed(() => ({
...baseOption,
series: [
{
name: '磁盘读取BPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 10000, 100000),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '磁盘写入BPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 20000, 150000),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
],
}))
const diskIopsOption = computed(() => ({
...baseOption,
series: [
{
name: '磁盘读取IOPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 50, 500),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '磁盘写入IOPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 100, 800),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
],
}))
const connectionOption = computed(() => ({
...baseOption,
series: [
{
name: '同时连接数',
type: 'line',
smooth: true,
data: generateRandomData(60, 100, 500),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
],
}))
const rdmaOption = computed(() => ({
...baseOption,
series: [],
}))
const rdmaResourceOption = computed(() => ({
...baseOption,
series: [],
}))
const getStatusColor = (status?: string) => {
const colorMap: Record<string, string> = {
online: 'green',
offline: 'red',
maintenance: 'orange',
retired: 'gray',
}
return colorMap[status || ''] || 'gray'
}
const getStatusText = (status?: string) => {
const textMap: Record<string, string> = {
online: '在线',
offline: '离线',
maintenance: '维护中',
retired: '已退役',
}
return textMap[status || ''] || '-'
}
const handleRemoteControl = () => {
emit('remote-control')
}
const handleRestart = () => {
Message.info('正在发送重启指令...')
emit('restart')
}
</script>
<style scoped lang="less">
.server-detail {
padding: 20px;
background: #fff;
border-radius: 4px;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #e5e6eb;
.server-info {
h2 {
margin: 0 0 12px 0;
font-size: 20px;
font-weight: 600;
color: #1d2129;
}
.info-tags {
display: flex;
gap: 8px;
}
}
.header-actions {
display: flex;
gap: 12px;
}
}
.detail-tabs {
:deep(.arco-tabs-header) {
margin-bottom: 20px;
}
}
.info-descriptions {
:deep(.arco-descriptions-item-label) {
width: 140px;
background: #f7f8fa;
}
}
.monitor-section {
.time-selector {
margin-bottom: 20px;
}
.charts-row {
margin-bottom: 20px;
}
.chart-col {
margin-bottom: 20px;
}
.chart-card {
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 4px;
padding: 16px;
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.chart-title {
font-size: 14px;
font-weight: 500;
color: #1d2129;
}
.chart-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
.chart {
height: 200px;
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e5e6eb;
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #86909c;
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
&.green {
background: #00b42a;
}
&.blue {
background: #165dff;
}
&.purple {
background: #722ed1;
}
}
}
}
}
}
.operation-records {
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,211 @@
<template>
<a-modal
:visible="visible"
:title="isEdit ? '编辑办公PC' : '新增办公PC'"
@ok="handleOk"
@cancel="handleCancel"
@update:visible="handleUpdateVisible"
:confirm-loading="confirmLoading"
width="800px"
>
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="unique_id" label="唯一标识">
<a-input
v-model="formData.unique_id"
placeholder="输入为空系统自动生成UUID"
:disabled="isEdit"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="name" label="办公PC名称">
<a-input v-model="formData.name" placeholder="请输入办公PC名称" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="os" label="操作系统">
<a-select v-model="formData.os" placeholder="请选择操作系统">
<a-option value="windows">Windows</a-option>
<a-option value="linux">Linux</a-option>
<a-option value="other">其它</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="tags" label="标签">
<a-input v-model="formData.tags" placeholder="多个标签,逗号间隔" />
</a-form-item>
</a-col>
</a-row>
<a-form-item field="location" label="位置信息">
<a-input
v-model="formData.location"
placeholder="请输入位置信息"
/>
</a-form-item>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="ip" label="IP地址">
<a-input v-model="formData.ip" placeholder="可以输入多个IP逗号做分隔" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="remote_port" label="远程访问端口">
<a-input v-model="formData.remote_port" placeholder="为空则不可远程访问" />
</a-form-item>
</a-col>
</a-row>
<a-form-item field="agent_url" label="Agent URL配置">
<a-input v-model="formData.agent_url" placeholder="请输入Agent URL配置" />
</a-form-item>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="data_collection" label="数据采集">
<a-switch v-model="formData.data_collection" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item v-if="formData.data_collection" field="collection_interval" label="采集时间">
<a-select v-model="formData.collection_interval" placeholder="请选择采集时间">
<a-option :value="1">1分钟</a-option>
<a-option :value="5">5分钟</a-option>
<a-option :value="10">10分钟</a-option>
<a-option :value="30">30分钟</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item field="remark" label="备注信息">
<a-textarea
v-model="formData.remark"
placeholder="请输入备注信息"
:rows="4"
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { v4 as uuidv4 } from 'uuid'
import type { FormInstance } from '@arco-design/web-vue'
interface Props {
visible: boolean
record?: any
}
const props = withDefaults(defineProps<Props>(), {
record: () => ({}),
})
const emit = defineEmits(['update:visible', 'success'])
const formRef = ref<FormInstance>()
const confirmLoading = ref(false)
const selectedLocation = ref('')
const isEdit = computed(() => !!props.record?.id)
const formData = reactive({
unique_id: '',
name: '',
server_type: '',
os: '',
location: '',
tags: '',
ip: '',
remote_port: '',
agent_url: '',
data_collection: false,
collection_interval: 5,
remark: '',
})
const rules = {
name: [{ required: true, message: '请输入办公名称' }],
server_type: [{ required: true, message: '请选择办公类型' }],
os: [{ required: true, message: '请选择操作系统' }],
}
const locationOptions = ref([
{ label: 'A数据中心-3层-24机柜-5U位', value: 'A数据中心-3层-24机柜-5U位' },
{ label: 'A数据中心-3层-24机柜-6U位', value: 'A数据中心-3层-24机柜-6U位' },
{ label: 'B数据中心-1层-12机柜-1U位', value: 'B数据中心-1层-12机柜-1U位' },
{ label: 'B数据中心-1层-12机柜-2U位', value: 'B数据中心-1层-12机柜-2U位' },
{ label: 'C数据中心-2层-8机柜-3U位', value: 'C数据中心-2层-8机柜-3U位' },
])
watch(
() => props.visible,
(val) => {
if (val) {
if (isEdit.value && props.record) {
Object.assign(formData, props.record)
} else {
Object.assign(formData, {
unique_id: '',
name: '',
server_type: '',
os: '',
location: '',
tags: '',
ip: '',
remote_port: '',
agent_url: '',
data_collection: false,
collection_interval: 5,
remark: '',
})
}
}
}
)
const handleLocationSelect = (value: string) => {
formData.location = value
}
const handleOk = async () => {
try {
await formRef.value?.validate()
if (!formData.unique_id) {
formData.unique_id = uuidv4()
}
confirmLoading.value = true
await new Promise(resolve => setTimeout(resolve, 1000))
Message.success(isEdit.value ? '更新成功' : '创建成功')
emit('success')
handleCancel()
} catch (error) {
console.error('验证失败:', error)
} finally {
confirmLoading.value = false
}
}
const handleUpdateVisible = (value: boolean) => {
emit('update:visible', value)
}
const handleCancel = () => {
emit('update:visible', false)
formRef.value?.resetFields()
}
</script>

View File

@@ -0,0 +1,90 @@
<template>
<a-modal
:visible="visible"
title="快捷配置"
:mask-closable="false"
:ok-loading="loading"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form :model="form" layout="vertical">
<a-form-item label="远程访问端口">
<a-input-number
v-model="form.remote_port"
placeholder="请输入远程访问端口,为空则不可远程访问"
:min="1"
:max="65535"
style="width: 100%"
allow-clear
/>
<template #extra>
<span style="color: #86909c">为空则不可远程访问</span>
</template>
</a-form-item>
<a-form-item label="Agent URL配置">
<a-input
v-model="form.agent_url"
placeholder="请输入Agent URL"
allow-clear
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
interface Props {
visible: boolean
record: any
}
const props = defineProps<Props>()
const emit = defineEmits(['update:visible', 'success'])
const loading = ref(false)
const form = ref({
remote_port: undefined as number | undefined,
agent_url: '',
})
watch(
() => props.visible,
(val) => {
if (val && props.record) {
form.value.remote_port = props.record.remote_port
form.value.agent_url = props.record.agent_url || ''
}
}
)
const handleSubmit = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
// 更新记录
props.record.remote_port = form.value.remote_port
props.record.agent_url = form.value.agent_url
props.record.remote_access = !!form.value.remote_port
props.record.agent_config = !!form.value.agent_url
Message.success('配置成功')
emit('success')
emit('update:visible', false)
} catch (error) {
Message.error('配置失败')
} finally {
loading.value = false
}
}
const handleCancel = () => {
emit('update:visible', false)
}
</script>

View File

@@ -0,0 +1,310 @@
<template>
<div class="remote-control">
<!-- 顶部栏 -->
<div class="header">
<div class="header-left">
<span class="title">{{ record.name || '远程控制' }}</span>
<a-tag :color="getStatusColor(record.status)" size="small">{{ getStatusText(record.status) }}</a-tag>
</div>
<a-button size="small" @click="handleClose">
<template #icon><icon-close /></template>
关闭
</a-button>
</div>
<!-- 登录界面 -->
<div v-if="!isConnected" class="login-box">
<a-card title="SSH 登录" class="login-card">
<a-form :model="loginForm" layout="vertical">
<a-form-item label="连接协议">
<a-radio-group v-model="loginForm.protocol" type="button">
<a-radio value="ssh">SSH</a-radio>
<a-radio value="tat">免密连接</a-radio>
</a-radio-group>
</a-form-item>
<a-row :gutter="16">
<a-col :span="16">
<a-form-item label="主机地址">
<a-input v-model="loginForm.host" placeholder="IP或域名" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="端口">
<a-input-number v-model="loginForm.port" :min="1" :max="65535" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="用户名">
<a-input v-model="loginForm.username" placeholder="请输入用户名" />
</a-form-item>
<a-form-item label="密码">
<a-input-password v-model="loginForm.password" placeholder="请输入密码" />
</a-form-item>
<a-space>
<a-button type="primary" @click="handleLogin" :loading="loginLoading">
连接
</a-button>
<a-button @click="handleClose">取消</a-button>
</a-space>
</a-form>
</a-card>
</div>
<!-- 终端界面 -->
<div v-else class="terminal-box">
<div class="terminal-toolbar">
<span class="terminal-info">已连接: {{ loginForm.username }}@{{ loginForm.host }}:{{ loginForm.port }}</span>
<a-space>
<a-button size="small" @click="handleDisconnect">断开</a-button>
<a-button size="small" @click="handleFullscreen">全屏</a-button>
</a-space>
</div>
<div class="terminal-content" ref="terminalRef">
<div v-for="(line, index) in terminalLines" :key="index" class="terminal-line">
<span v-if="line.type === 'input'" class="prompt">{{ line.prompt }}</span>
<span :class="line.type">{{ line.content }}</span>
</div>
<div class="terminal-input">
<span class="prompt">{{ prompt }}</span>
<input
ref="inputRef"
v-model="currentInput"
@keyup.enter="handleCommand"
class="command-input"
spellcheck="false"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, nextTick, onMounted, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconClose } from '@arco-design/web-vue/es/icon'
interface Props {
record: any
}
const props = defineProps<Props>()
const emit = defineEmits(['close'])
const isConnected = ref(false)
const loginLoading = ref(false)
const terminalRef = ref<HTMLElement>()
const inputRef = ref<HTMLInputElement>()
const loginForm = ref({
protocol: 'ssh',
host: props.record?.ip || '',
port: 22,
username: 'root',
password: '',
})
const terminalLines = ref<Array<{type: string, content: string, prompt?: string}>>([
{ type: 'output', content: `Welcome to ${props.record?.name || 'Server'}!` },
{ type: 'output', content: `Last login: ${new Date().toLocaleString()}` },
])
const currentInput = ref('')
const prompt = computed(() => `${loginForm.value.username}@${loginForm.value.host}:~# `)
const getStatusColor = (status?: string) => {
const map: Record<string, string> = { online: 'green', offline: 'red', maintenance: 'orange' }
return map[status || ''] || 'gray'
}
const getStatusText = (status?: string) => {
const map: Record<string, string> = { online: '在线', offline: '离线', maintenance: '维护中' }
return map[status || ''] || '-'
}
const handleLogin = async () => {
if (!loginForm.value.host || !loginForm.value.username) {
Message.warning('请填写完整信息')
return
}
loginLoading.value = true
await new Promise(r => setTimeout(r, 1000))
isConnected.value = true
loginLoading.value = false
Message.success('连接成功')
nextTick(() => inputRef.value?.focus())
}
const handleCommand = () => {
const cmd = currentInput.value.trim()
if (!cmd) return
terminalLines.value.push({ type: 'input', content: cmd, prompt: prompt.value })
const commands: Record<string, string> = {
help: '可用命令: help, ls, pwd, whoami, date, clear, exit',
ls: 'bin boot dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var',
pwd: '/root',
whoami: loginForm.value.username,
date: new Date().toString(),
}
if (cmd === 'clear') {
terminalLines.value = []
} else if (cmd === 'exit') {
handleDisconnect()
} else {
terminalLines.value.push({ type: 'output', content: commands[cmd] || `命令未找到: ${cmd}` })
}
currentInput.value = ''
nextTick(() => {
terminalRef.value?.scrollTo(0, terminalRef.value.scrollHeight)
})
}
const handleDisconnect = () => {
isConnected.value = false
terminalLines.value = [
{ type: 'output', content: `Welcome to ${props.record?.name || 'Server'}!` },
{ type: 'output', content: `Last login: ${new Date().toLocaleString()}` },
]
Message.info('已断开连接')
}
const handleFullscreen = () => {
if (!document.fullscreenElement) {
terminalRef.value?.requestFullscreen()
} else {
document.exitFullscreen()
}
}
const handleClose = () => {
emit('close')
}
onMounted(() => {
if (props.record?.ip) {
loginForm.value.host = props.record.ip
}
})
</script>
<style scoped lang="less">
.remote-control {
height: 100vh;
display: flex;
flex-direction: column;
background: #f0f2f5;
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
background: #fff;
border-bottom: 1px solid #e5e6eb;
.header-left {
display: flex;
align-items: center;
gap: 12px;
.title {
font-size: 16px;
font-weight: 500;
}
}
}
.login-box {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
.login-card {
width: 480px;
}
}
.terminal-box {
flex: 1;
display: flex;
flex-direction: column;
margin: 16px;
background: #1e1e1e;
border-radius: 4px;
overflow: hidden;
.terminal-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: #2d2d2d;
border-bottom: 1px solid #3a3a3a;
.terminal-info {
color: #c9cdd4;
font-size: 13px;
}
}
.terminal-content {
flex: 1;
padding: 16px;
overflow-y: auto;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 14px;
line-height: 1.6;
.terminal-line {
margin-bottom: 4px;
color: #fff;
.prompt {
color: #00ff00;
margin-right: 8px;
}
&.input {
color: #fff;
}
&.output {
color: #c9cdd4;
}
}
.terminal-input {
display: flex;
align-items: center;
.prompt {
color: #00ff00;
margin-right: 8px;
white-space: nowrap;
}
.command-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: #fff;
font-family: inherit;
font-size: inherit;
caret-color: #00ff00;
}
}
}
}
}
</style>

View File

@@ -163,7 +163,7 @@
</search-table>
<!-- 新增/编辑对话框 -->
<ServerFormDialog
<FormDialog
v-model:visible="formDialogVisible"
:record="currentRecord"
@success="handleFormSuccess"
@@ -195,7 +195,7 @@ import {
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import { searchFormConfig } from './config/search-form'
import ServerFormDialog from '../pc/components/ServerFormDialog.vue'
import FormDialog from '../pc/components/FormDialog.vue'
import QuickConfigDialog from '../pc/components/QuickConfigDialog.vue'
import { columns as columnsConfig } from './config/columns'
import {

View File

@@ -0,0 +1,705 @@
<template>
<div class="server-detail">
<div class="detail-header">
<div class="server-info">
<h2>{{ record.name || '办公PC详情' }}</h2>
<div class="info-tags">
<a-tag :color="getStatusColor(record.status)">{{ getStatusText(record.status) }}</a-tag>
<a-tag color="blue">{{ record.server_type || '未知类型' }}</a-tag>
<a-tag color="cyan">{{ record.os || '未知系统' }}</a-tag>
</div>
</div>
<div class="header-actions">
<a-button @click="handleRemoteControl">
<template #icon>
<icon-desktop />
</template>
远程登录
</a-button>
<a-button type="primary" @click="handleRestart">
<template #icon>
<icon-refresh />
</template>
重启
</a-button>
</div>
</div>
<a-tabs v-model:active-tab="activeTab" class="detail-tabs">
<a-tab-pane key="overview" title="实例详情">
<a-descriptions :column="2" bordered class="info-descriptions">
<a-descriptions-item label="唯一标识">{{ record.unique_id || '-' }}</a-descriptions-item>
<a-descriptions-item label="办公PC名称">{{ record.name || '-' }}</a-descriptions-item>
<a-descriptions-item label="操作系统">{{ record.os || '-' }}</a-descriptions-item>
<a-descriptions-item label="位置信息">{{ record.location || '-' }}</a-descriptions-item>
<a-descriptions-item label="标签">{{ record.tags || '-' }}</a-descriptions-item>
<a-descriptions-item label="IP地址">{{ record.ip || '-' }}</a-descriptions-item>
<a-descriptions-item label="远程端口">{{ record.remote_port || '-' }}</a-descriptions-item>
<a-descriptions-item label="Agent URL">{{ record.agent_url || '-' }}</a-descriptions-item>
<a-descriptions-item label="数据采集">
<a-tag :color="record.data_collection ? 'green' : 'gray'">
{{ record.data_collection ? '已开启' : '未开启' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="采集间隔">{{ record.collection_interval ? `${record.collection_interval}分钟` : '-' }}</a-descriptions-item>
<a-descriptions-item label="备注信息" :span="2">{{ record.remark || '-' }}</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
<a-tab-pane key="monitor" title="监控">
<div class="monitor-section">
<div class="time-selector">
<a-radio-group v-model="timeRange" type="button">
<a-radio value="1h">1小时</a-radio>
<a-radio value="3h">3小时</a-radio>
<a-radio value="6h">6小时</a-radio>
<a-radio value="12h">12小时</a-radio>
<a-radio value="1d">1</a-radio>
<a-radio value="3d">3</a-radio>
<a-radio value="7d">7</a-radio>
<a-radio value="14d">14</a-radio>
<a-radio value="custom">自定义</a-radio>
</a-radio-group>
</div>
<a-row :gutter="20" class="charts-row">
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">CPU使用率_宿主机视角 (%)</span>
<div class="chart-actions">
<a-select v-model="cpuMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
<a-option value="min">最小值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="cpuOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot green"></span>(ECS)CPU使用率_平均值</span>
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)CPU使用率_最小值</span>
<span class="legend-item"><span class="legend-dot purple"></span>(ECS)CPU使用率_最大值</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">总带宽 (bit/s)</span>
<div class="chart-actions">
<a-select v-model="bandwidthMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="bandwidthOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot green"></span>内网流入带宽</span>
<span class="legend-item"><span class="legend-dot blue"></span>内网流出带宽</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">公网带宽 (bit/s)</span>
<div class="chart-actions">
<a-select v-model="publicBandwidthMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="publicBandwidthOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>公网流入带宽</span>
<span class="legend-item"><span class="legend-dot green"></span>公网流出带宽</span>
</div>
</div>
</a-col>
</a-row>
<a-row :gutter="20" class="charts-row">
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">公网流出带宽使用率 (%)</span>
<div class="chart-actions">
<a-select v-model="bandwidthUsageMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="bandwidthUsageOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)公网流出带宽使用率</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">磁盘读取写入BPS (bytes/s)</span>
<div class="chart-actions">
<a-select v-model="diskMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="diskBpsOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)所有磁盘读取BPS</span>
<span class="legend-item"><span class="legend-dot green"></span>(ECS)所有磁盘写入BPS</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">磁盘读取写入IOPS (Count/Second)</span>
<div class="chart-actions">
<a-select v-model="diskIopsMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="diskIopsOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)所有磁盘每秒读取次数</span>
<span class="legend-item"><span class="legend-dot green"></span>(ECS)所有磁盘每秒写入次数</span>
</div>
</div>
</a-col>
</a-row>
<a-row :gutter="20" class="charts-row">
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">同时连接数 (Count)</span>
<div class="chart-actions">
<a-select v-model="connectionMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="connectionOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>同时连接数</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">RDMA流量使用信息 (bit/s)</span>
<div class="chart-actions">
<a-select v-model="rdmaMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="rdmaOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"></span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">RDMA资源使用信息 (Count)</span>
<div class="chart-actions">
<a-select v-model="rdmaResourceMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="rdmaResourceOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"></span>
</div>
</div>
</a-col>
</a-row>
</div>
</a-tab-pane>
<a-tab-pane key="operation" title="操作记录">
<div class="operation-records">
<a-empty description="暂无操作记录" />
</div>
</a-tab-pane>
</a-tabs>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconDesktop, IconRefresh, IconSettings } from '@arco-design/web-vue/es/icon'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
} from 'echarts/components'
use([
CanvasRenderer,
LineChart,
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
])
interface Props {
record: any
}
const props = defineProps<Props>()
const emit = defineEmits(['remote-control', 'restart', 'close'])
const activeTab = ref('overview')
const timeRange = ref('1h')
const cpuMetric = ref('average')
const bandwidthMetric = ref('average')
const publicBandwidthMetric = ref('average')
const bandwidthUsageMetric = ref('average')
const diskMetric = ref('average')
const diskIopsMetric = ref('average')
const connectionMetric = ref('average')
const rdmaMetric = ref('average')
const rdmaResourceMetric = ref('average')
const generateTimeData = (count: number) => {
const now = Date.now()
const data = []
for (let i = count - 1; i >= 0; i--) {
data.push(new Date(now - i * 60000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }))
}
return data
}
const generateRandomData = (count: number, min: number, max: number) => {
return Array.from({ length: count }, () => Math.floor(Math.random() * (max - min + 1)) + min)
}
const timeData = generateTimeData(60)
const baseOption = {
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255,255,255,0.95)',
borderColor: '#e5e6eb',
textStyle: {
color: '#1d2129',
},
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: timeData,
axisLabel: {
fontSize: 10,
},
},
yAxis: {
type: 'value',
axisLabel: {
fontSize: 10,
},
},
dataZoom: [
{
type: 'inside',
start: 0,
end: 100,
},
],
}
const cpuOption = computed(() => ({
...baseOption,
series: [
{
name: '平均值',
type: 'line',
smooth: true,
data: generateRandomData(60, 20, 80),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(0, 180, 42, 0.3)' },
{ offset: 1, color: 'rgba(0, 180, 42, 0.05)' },
],
},
},
},
{
name: '最小值',
type: 'line',
smooth: true,
data: generateRandomData(60, 10, 40),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '最大值',
type: 'line',
smooth: true,
data: generateRandomData(60, 50, 95),
lineStyle: { color: '#722ed1' },
itemStyle: { color: '#722ed1' },
},
],
}))
const bandwidthOption = computed(() => ({
...baseOption,
series: [
{
name: '内网流入带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 100000, 500000),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
{
name: '内网流出带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 50000, 300000),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
],
}))
const publicBandwidthOption = computed(() => ({
...baseOption,
series: [
{
name: '公网流入带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 50000, 200000),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '公网流出带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 30000, 150000),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
],
}))
const bandwidthUsageOption = computed(() => ({
...baseOption,
series: [
{
name: '公网流出带宽使用率',
type: 'line',
smooth: true,
data: generateRandomData(60, 5, 30),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(22, 93, 255, 0.3)' },
{ offset: 1, color: 'rgba(22, 93, 255, 0.05)' },
],
},
},
},
],
}))
const diskBpsOption = computed(() => ({
...baseOption,
series: [
{
name: '磁盘读取BPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 10000, 100000),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '磁盘写入BPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 20000, 150000),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
],
}))
const diskIopsOption = computed(() => ({
...baseOption,
series: [
{
name: '磁盘读取IOPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 50, 500),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '磁盘写入IOPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 100, 800),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
],
}))
const connectionOption = computed(() => ({
...baseOption,
series: [
{
name: '同时连接数',
type: 'line',
smooth: true,
data: generateRandomData(60, 100, 500),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
],
}))
const rdmaOption = computed(() => ({
...baseOption,
series: [],
}))
const rdmaResourceOption = computed(() => ({
...baseOption,
series: [],
}))
const getStatusColor = (status?: string) => {
const colorMap: Record<string, string> = {
online: 'green',
offline: 'red',
maintenance: 'orange',
retired: 'gray',
}
return colorMap[status || ''] || 'gray'
}
const getStatusText = (status?: string) => {
const textMap: Record<string, string> = {
online: '在线',
offline: '离线',
maintenance: '维护中',
retired: '已退役',
}
return textMap[status || ''] || '-'
}
const handleRemoteControl = () => {
emit('remote-control')
}
const handleRestart = () => {
Message.info('正在发送重启指令...')
emit('restart')
}
</script>
<style scoped lang="less">
.server-detail {
padding: 20px;
background: #fff;
border-radius: 4px;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #e5e6eb;
.server-info {
h2 {
margin: 0 0 12px 0;
font-size: 20px;
font-weight: 600;
color: #1d2129;
}
.info-tags {
display: flex;
gap: 8px;
}
}
.header-actions {
display: flex;
gap: 12px;
}
}
.detail-tabs {
:deep(.arco-tabs-header) {
margin-bottom: 20px;
}
}
.info-descriptions {
:deep(.arco-descriptions-item-label) {
width: 140px;
background: #f7f8fa;
}
}
.monitor-section {
.time-selector {
margin-bottom: 20px;
}
.charts-row {
margin-bottom: 20px;
}
.chart-col {
margin-bottom: 20px;
}
.chart-card {
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 4px;
padding: 16px;
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.chart-title {
font-size: 14px;
font-weight: 500;
color: #1d2129;
}
.chart-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
.chart {
height: 200px;
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e5e6eb;
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #86909c;
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
&.green {
background: #00b42a;
}
&.blue {
background: #165dff;
}
&.purple {
background: #722ed1;
}
}
}
}
}
}
.operation-records {
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,211 @@
<template>
<a-modal
:visible="visible"
:title="isEdit ? '编辑办公PC' : '新增办公PC'"
@ok="handleOk"
@cancel="handleCancel"
@update:visible="handleUpdateVisible"
:confirm-loading="confirmLoading"
width="800px"
>
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="unique_id" label="唯一标识">
<a-input
v-model="formData.unique_id"
placeholder="输入为空系统自动生成UUID"
:disabled="isEdit"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="name" label="办公PC名称">
<a-input v-model="formData.name" placeholder="请输入办公PC名称" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="os" label="操作系统">
<a-select v-model="formData.os" placeholder="请选择操作系统">
<a-option value="windows">Windows</a-option>
<a-option value="linux">Linux</a-option>
<a-option value="other">其它</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="tags" label="标签">
<a-input v-model="formData.tags" placeholder="多个标签,逗号间隔" />
</a-form-item>
</a-col>
</a-row>
<a-form-item field="location" label="位置信息">
<a-input
v-model="formData.location"
placeholder="请输入位置信息"
/>
</a-form-item>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="ip" label="IP地址">
<a-input v-model="formData.ip" placeholder="可以输入多个IP逗号做分隔" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="remote_port" label="远程访问端口">
<a-input v-model="formData.remote_port" placeholder="为空则不可远程访问" />
</a-form-item>
</a-col>
</a-row>
<a-form-item field="agent_url" label="Agent URL配置">
<a-input v-model="formData.agent_url" placeholder="请输入Agent URL配置" />
</a-form-item>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="data_collection" label="数据采集">
<a-switch v-model="formData.data_collection" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item v-if="formData.data_collection" field="collection_interval" label="采集时间">
<a-select v-model="formData.collection_interval" placeholder="请选择采集时间">
<a-option :value="1">1分钟</a-option>
<a-option :value="5">5分钟</a-option>
<a-option :value="10">10分钟</a-option>
<a-option :value="30">30分钟</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item field="remark" label="备注信息">
<a-textarea
v-model="formData.remark"
placeholder="请输入备注信息"
:rows="4"
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { v4 as uuidv4 } from 'uuid'
import type { FormInstance } from '@arco-design/web-vue'
interface Props {
visible: boolean
record?: any
}
const props = withDefaults(defineProps<Props>(), {
record: () => ({}),
})
const emit = defineEmits(['update:visible', 'success'])
const formRef = ref<FormInstance>()
const confirmLoading = ref(false)
const selectedLocation = ref('')
const isEdit = computed(() => !!props.record?.id)
const formData = reactive({
unique_id: '',
name: '',
server_type: '',
os: '',
location: '',
tags: '',
ip: '',
remote_port: '',
agent_url: '',
data_collection: false,
collection_interval: 5,
remark: '',
})
const rules = {
name: [{ required: true, message: '请输入办公名称' }],
server_type: [{ required: true, message: '请选择办公类型' }],
os: [{ required: true, message: '请选择操作系统' }],
}
const locationOptions = ref([
{ label: 'A数据中心-3层-24机柜-5U位', value: 'A数据中心-3层-24机柜-5U位' },
{ label: 'A数据中心-3层-24机柜-6U位', value: 'A数据中心-3层-24机柜-6U位' },
{ label: 'B数据中心-1层-12机柜-1U位', value: 'B数据中心-1层-12机柜-1U位' },
{ label: 'B数据中心-1层-12机柜-2U位', value: 'B数据中心-1层-12机柜-2U位' },
{ label: 'C数据中心-2层-8机柜-3U位', value: 'C数据中心-2层-8机柜-3U位' },
])
watch(
() => props.visible,
(val) => {
if (val) {
if (isEdit.value && props.record) {
Object.assign(formData, props.record)
} else {
Object.assign(formData, {
unique_id: '',
name: '',
server_type: '',
os: '',
location: '',
tags: '',
ip: '',
remote_port: '',
agent_url: '',
data_collection: false,
collection_interval: 5,
remark: '',
})
}
}
}
)
const handleLocationSelect = (value: string) => {
formData.location = value
}
const handleOk = async () => {
try {
await formRef.value?.validate()
if (!formData.unique_id) {
formData.unique_id = uuidv4()
}
confirmLoading.value = true
await new Promise(resolve => setTimeout(resolve, 1000))
Message.success(isEdit.value ? '更新成功' : '创建成功')
emit('success')
handleCancel()
} catch (error) {
console.error('验证失败:', error)
} finally {
confirmLoading.value = false
}
}
const handleUpdateVisible = (value: boolean) => {
emit('update:visible', value)
}
const handleCancel = () => {
emit('update:visible', false)
formRef.value?.resetFields()
}
</script>

View File

@@ -156,8 +156,8 @@
</search-table>
<!-- 新增/编辑对话框 -->
<ServerFormDialog
v-model:visible="formDialogVisible"
<FormDialog
v-model:visible="dialogVisible"
:record="currentRecord"
@success="handleFormSuccess"
/>
@@ -194,9 +194,9 @@ import {
fetchPCList,
deletePC,
} from '@/api/ops/pc'
import ServerFormDialog from './components/ServerFormDialog.vue'
import FormDialog from './components/FormDialog.vue'
import QuickConfigDialog from './components/QuickConfigDialog.vue'
import ServerDetail from './components/ServerDetail.vue'
import Detail from './components/Detail.vue'
const router = useRouter()
@@ -334,7 +334,7 @@ const formModel = ref({
status: undefined,
})
const formDialogVisible = ref(false)
const dialogVisible = ref(false)
const quickConfigVisible = ref(false)
const currentRecord = ref<any>(null)
@@ -460,7 +460,7 @@ const handleRefresh = () => {
// 新增PC
const handleAdd = () => {
currentRecord.value = null
formDialogVisible.value = true
dialogVisible.value = true
}
// 快捷配置
@@ -472,7 +472,7 @@ const handleQuickConfig = (record: any) => {
// 编辑PC
const handleEdit = (record: any) => {
currentRecord.value = record
formDialogVisible.value = true
dialogVisible.value = true
}
// 详情 - 在当前窗口打开

View File

@@ -0,0 +1,90 @@
<template>
<a-modal
:visible="visible"
title="快捷配置"
:mask-closable="false"
:ok-loading="loading"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form :model="form" layout="vertical">
<a-form-item label="远程访问端口">
<a-input-number
v-model="form.remote_port"
placeholder="请输入远程访问端口,为空则不可远程访问"
:min="1"
:max="65535"
style="width: 100%"
allow-clear
/>
<template #extra>
<span style="color: #86909c">为空则不可远程访问</span>
</template>
</a-form-item>
<a-form-item label="Agent URL配置">
<a-input
v-model="form.agent_url"
placeholder="请输入Agent URL"
allow-clear
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
interface Props {
visible: boolean
record: any
}
const props = defineProps<Props>()
const emit = defineEmits(['update:visible', 'success'])
const loading = ref(false)
const form = ref({
remote_port: undefined as number | undefined,
agent_url: '',
})
watch(
() => props.visible,
(val) => {
if (val && props.record) {
form.value.remote_port = props.record.remote_port
form.value.agent_url = props.record.agent_url || ''
}
}
)
const handleSubmit = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
// 更新记录
props.record.remote_port = form.value.remote_port
props.record.agent_url = form.value.agent_url
props.record.remote_access = !!form.value.remote_port
props.record.agent_config = !!form.value.agent_url
Message.success('配置成功')
emit('success')
emit('update:visible', false)
} catch (error) {
Message.error('配置失败')
} finally {
loading.value = false
}
}
const handleCancel = () => {
emit('update:visible', false)
}
</script>

View File

@@ -0,0 +1,310 @@
<template>
<div class="remote-control">
<!-- 顶部栏 -->
<div class="header">
<div class="header-left">
<span class="title">{{ record.name || '远程控制' }}</span>
<a-tag :color="getStatusColor(record.status)" size="small">{{ getStatusText(record.status) }}</a-tag>
</div>
<a-button size="small" @click="handleClose">
<template #icon><icon-close /></template>
关闭
</a-button>
</div>
<!-- 登录界面 -->
<div v-if="!isConnected" class="login-box">
<a-card title="SSH 登录" class="login-card">
<a-form :model="loginForm" layout="vertical">
<a-form-item label="连接协议">
<a-radio-group v-model="loginForm.protocol" type="button">
<a-radio value="ssh">SSH</a-radio>
<a-radio value="tat">免密连接</a-radio>
</a-radio-group>
</a-form-item>
<a-row :gutter="16">
<a-col :span="16">
<a-form-item label="主机地址">
<a-input v-model="loginForm.host" placeholder="IP或域名" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="端口">
<a-input-number v-model="loginForm.port" :min="1" :max="65535" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="用户名">
<a-input v-model="loginForm.username" placeholder="请输入用户名" />
</a-form-item>
<a-form-item label="密码">
<a-input-password v-model="loginForm.password" placeholder="请输入密码" />
</a-form-item>
<a-space>
<a-button type="primary" @click="handleLogin" :loading="loginLoading">
连接
</a-button>
<a-button @click="handleClose">取消</a-button>
</a-space>
</a-form>
</a-card>
</div>
<!-- 终端界面 -->
<div v-else class="terminal-box">
<div class="terminal-toolbar">
<span class="terminal-info">已连接: {{ loginForm.username }}@{{ loginForm.host }}:{{ loginForm.port }}</span>
<a-space>
<a-button size="small" @click="handleDisconnect">断开</a-button>
<a-button size="small" @click="handleFullscreen">全屏</a-button>
</a-space>
</div>
<div class="terminal-content" ref="terminalRef">
<div v-for="(line, index) in terminalLines" :key="index" class="terminal-line">
<span v-if="line.type === 'input'" class="prompt">{{ line.prompt }}</span>
<span :class="line.type">{{ line.content }}</span>
</div>
<div class="terminal-input">
<span class="prompt">{{ prompt }}</span>
<input
ref="inputRef"
v-model="currentInput"
@keyup.enter="handleCommand"
class="command-input"
spellcheck="false"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, nextTick, onMounted, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconClose } from '@arco-design/web-vue/es/icon'
interface Props {
record: any
}
const props = defineProps<Props>()
const emit = defineEmits(['close'])
const isConnected = ref(false)
const loginLoading = ref(false)
const terminalRef = ref<HTMLElement>()
const inputRef = ref<HTMLInputElement>()
const loginForm = ref({
protocol: 'ssh',
host: props.record?.ip || '',
port: 22,
username: 'root',
password: '',
})
const terminalLines = ref<Array<{type: string, content: string, prompt?: string}>>([
{ type: 'output', content: `Welcome to ${props.record?.name || 'Server'}!` },
{ type: 'output', content: `Last login: ${new Date().toLocaleString()}` },
])
const currentInput = ref('')
const prompt = computed(() => `${loginForm.value.username}@${loginForm.value.host}:~# `)
const getStatusColor = (status?: string) => {
const map: Record<string, string> = { online: 'green', offline: 'red', maintenance: 'orange' }
return map[status || ''] || 'gray'
}
const getStatusText = (status?: string) => {
const map: Record<string, string> = { online: '在线', offline: '离线', maintenance: '维护中' }
return map[status || ''] || '-'
}
const handleLogin = async () => {
if (!loginForm.value.host || !loginForm.value.username) {
Message.warning('请填写完整信息')
return
}
loginLoading.value = true
await new Promise(r => setTimeout(r, 1000))
isConnected.value = true
loginLoading.value = false
Message.success('连接成功')
nextTick(() => inputRef.value?.focus())
}
const handleCommand = () => {
const cmd = currentInput.value.trim()
if (!cmd) return
terminalLines.value.push({ type: 'input', content: cmd, prompt: prompt.value })
const commands: Record<string, string> = {
help: '可用命令: help, ls, pwd, whoami, date, clear, exit',
ls: 'bin boot dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var',
pwd: '/root',
whoami: loginForm.value.username,
date: new Date().toString(),
}
if (cmd === 'clear') {
terminalLines.value = []
} else if (cmd === 'exit') {
handleDisconnect()
} else {
terminalLines.value.push({ type: 'output', content: commands[cmd] || `命令未找到: ${cmd}` })
}
currentInput.value = ''
nextTick(() => {
terminalRef.value?.scrollTo(0, terminalRef.value.scrollHeight)
})
}
const handleDisconnect = () => {
isConnected.value = false
terminalLines.value = [
{ type: 'output', content: `Welcome to ${props.record?.name || 'Server'}!` },
{ type: 'output', content: `Last login: ${new Date().toLocaleString()}` },
]
Message.info('已断开连接')
}
const handleFullscreen = () => {
if (!document.fullscreenElement) {
terminalRef.value?.requestFullscreen()
} else {
document.exitFullscreen()
}
}
const handleClose = () => {
emit('close')
}
onMounted(() => {
if (props.record?.ip) {
loginForm.value.host = props.record.ip
}
})
</script>
<style scoped lang="less">
.remote-control {
height: 100vh;
display: flex;
flex-direction: column;
background: #f0f2f5;
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
background: #fff;
border-bottom: 1px solid #e5e6eb;
.header-left {
display: flex;
align-items: center;
gap: 12px;
.title {
font-size: 16px;
font-weight: 500;
}
}
}
.login-box {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
.login-card {
width: 480px;
}
}
.terminal-box {
flex: 1;
display: flex;
flex-direction: column;
margin: 16px;
background: #1e1e1e;
border-radius: 4px;
overflow: hidden;
.terminal-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: #2d2d2d;
border-bottom: 1px solid #3a3a3a;
.terminal-info {
color: #c9cdd4;
font-size: 13px;
}
}
.terminal-content {
flex: 1;
padding: 16px;
overflow-y: auto;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 14px;
line-height: 1.6;
.terminal-line {
margin-bottom: 4px;
color: #fff;
.prompt {
color: #00ff00;
margin-right: 8px;
}
&.input {
color: #fff;
}
&.output {
color: #c9cdd4;
}
}
.terminal-input {
display: flex;
align-items: center;
.prompt {
color: #00ff00;
margin-right: 8px;
white-space: nowrap;
}
.command-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: #fff;
font-family: inherit;
font-size: inherit;
caret-color: #00ff00;
}
}
}
}
}
</style>

View File

@@ -195,8 +195,8 @@ import {
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import { searchFormConfig } from './config/search-form'
import ServerFormDialog from '../pc/components/ServerFormDialog.vue'
import QuickConfigDialog from '../pc/components/QuickConfigDialog.vue'
import ServerFormDialog from './components/ServerFormDialog.vue'
import QuickConfigDialog from './components/QuickConfigDialog.vue'
import { columns as columnsConfig } from './config/columns'
import {
fetchServerList,