<template>
|
<div class="admin-page robot-admin-page">
|
<div class="page-header">
|
<div>
|
<h2>机械手客户端管理台</h2>
|
<p class="text-muted">多实例连接目标服务端、消息收发监控</p>
|
</div>
|
<div class="toolbar-actions">
|
<el-button :loading="refreshing" @click="loadStatus">刷新</el-button>
|
<el-button type="danger" :loading="stoppingAll" @click="handleStopAll">停止全部</el-button>
|
</div>
|
</div>
|
|
<el-row :gutter="12" class="stats-row">
|
<el-col :xs="24" :sm="8">
|
<el-card shadow="hover" class="stat-card">
|
<el-statistic title="运行实例" :value="status?.runningServerCount ?? 0" />
|
</el-card>
|
</el-col>
|
<el-col :xs="24" :sm="8">
|
<el-card shadow="hover" class="stat-card">
|
<el-statistic title="实例总数" :value="status?.servers.length ?? 0" />
|
</el-card>
|
</el-col>
|
<el-col :xs="24" :sm="8">
|
<el-card shadow="hover" class="stat-card">
|
<el-statistic title="在线连接总数" :value="totalConnectedCount" />
|
</el-card>
|
</el-col>
|
</el-row>
|
|
<el-card shadow="never" class="create-card">
|
<template #header>
|
<span>新增客户端实例</span>
|
</template>
|
<el-form :inline="true" :model="startForm" class="create-form">
|
<el-form-item label="实例ID">
|
<el-input v-model="startForm.serverId" placeholder="robot-client-1" style="width: 180px" />
|
</el-form-item>
|
<el-form-item label="服务端地址">
|
<el-input v-model="startForm.listenIp" placeholder="127.0.0.1" style="width: 160px" />
|
</el-form-item>
|
<el-form-item label="服务端端口">
|
<el-input-number v-model="startForm.listenPort" :min="1" :max="65535" />
|
</el-form-item>
|
<el-form-item label="本地端口">
|
<el-input-number v-model="startForm.localPort" :min="1" :max="65535" />
|
</el-form-item>
|
<el-form-item>
|
<el-button type="primary" :loading="starting" @click="handleStart">连接实例</el-button>
|
</el-form-item>
|
</el-form>
|
</el-card>
|
|
<el-row :gutter="12" class="main-row">
|
<el-col :xs="24" :lg="8">
|
<el-card shadow="never" class="panel-card">
|
<template #header>
|
<span>客户端实例列表</span>
|
</template>
|
<el-table
|
:data="status?.servers || []"
|
border
|
size="small"
|
highlight-current-row
|
:row-class-name="serverRowClassName"
|
@row-click="onServerRowClick"
|
>
|
<el-table-column prop="serverId" label="实例ID" min-width="120" />
|
<el-table-column label="目标服务端" min-width="130">
|
<template #default="{ row }">{{ row.listenIp }}:{{ row.listenPort }}</template>
|
</el-table-column>
|
<el-table-column prop="localPort" label="本地端口" width="100" />
|
<el-table-column prop="connectedCount" label="连接" width="70" />
|
<el-table-column label="操作" width="90">
|
<template #default="{ row }">
|
<el-button link type="danger" @click.stop="handleStopOne(row.serverId)">停止</el-button>
|
</template>
|
</el-table-column>
|
</el-table>
|
</el-card>
|
</el-col>
|
|
<el-col :xs="24" :lg="16">
|
<el-card v-if="selectedServer" shadow="never" class="panel-card">
|
<template #header>
|
<div class="panel-header">
|
<span>实例详情:{{ selectedServer.serverId }}</span>
|
<el-tag :type="selectedServer.running ? 'success' : 'info'">
|
{{ selectedServer.running ? '运行中' : '已停止' }}
|
</el-tag>
|
</div>
|
</template>
|
|
<el-descriptions :column="4" border class="desc-block">
|
<el-descriptions-item label="服务端地址">{{ selectedServer.listenIp }}</el-descriptions-item>
|
<el-descriptions-item label="服务端端口">{{ selectedServer.listenPort }}</el-descriptions-item>
|
<el-descriptions-item label="本地端口">{{ selectedServer.localPort }}</el-descriptions-item>
|
<el-descriptions-item label="连接状态">{{ selectedServer.connectedCount > 0 ? '已连接' : '未连接' }}</el-descriptions-item>
|
</el-descriptions>
|
|
<div class="message-actions">
|
<el-button type="primary" @click="openMessageCenter">
|
消息中心
|
</el-button>
|
</div>
|
|
<el-card shadow="never" class="connection-card">
|
<template #header>
|
<span>连接列表</span>
|
</template>
|
<el-table :data="selectedServer.clients || []" border size="small">
|
<el-table-column prop="clientId" label="连接ID" width="100" />
|
<el-table-column prop="remoteEndPoint" label="远端地址" min-width="170" />
|
<el-table-column label="状态" width="100">
|
<template #default="{ row }">
|
<el-tag :type="row.connected ? 'success' : 'danger'">{{ row.connected ? '在线' : '离线' }}</el-tag>
|
</template>
|
</el-table-column>
|
<el-table-column prop="connectedAt" label="连接时间" min-width="170" />
|
<el-table-column prop="lastReceivedAt" label="最近接收" min-width="170" />
|
<el-table-column prop="lastSentAt" label="最近发送" min-width="170" />
|
<el-table-column prop="lastError" label="最后错误" min-width="190" />
|
</el-table>
|
</el-card>
|
</el-card>
|
|
<el-empty v-else description="请选择左侧客户端实例查看详情" />
|
</el-col>
|
</el-row>
|
|
<el-dialog
|
v-model="messageCenterVisible"
|
title="消息中心"
|
width="78vw"
|
destroy-on-close
|
>
|
<el-card shadow="never" class="message-send-card">
|
<template #header>
|
<span>发送指令</span>
|
</template>
|
<div class="send-row">
|
<el-input v-model="sendMessage" placeholder="例如 Pickbattery,1" />
|
<el-button type="primary" :loading="sending" @click="handleSend">发送</el-button>
|
</div>
|
</el-card>
|
|
<el-tabs v-model="messageTab">
|
<el-tab-pane
|
:label="`接收消息 (${selectedServer?.receivedMessages?.length || 0})`"
|
name="received"
|
>
|
<div class="message-toolbar">
|
<el-button
|
link
|
type="danger"
|
:loading="clearingMessages"
|
@click="handleClearMessages"
|
>
|
清空消息
|
</el-button>
|
</div>
|
<el-table :data="selectedServer?.receivedMessages || []" border size="small" max-height="430">
|
<el-table-column prop="receivedAt" label="接收时间" min-width="170" />
|
<el-table-column prop="clientId" label="连接ID" width="100" />
|
<el-table-column prop="remoteEndPoint" label="远端地址" min-width="170" />
|
<el-table-column prop="message" label="消息内容" min-width="250" />
|
</el-table>
|
</el-tab-pane>
|
<el-tab-pane
|
:label="`发送消息 (${selectedServer?.sentMessages?.length || 0})`"
|
name="sent"
|
>
|
<el-table :data="selectedServer?.sentMessages || []" border size="small" max-height="430">
|
<el-table-column prop="sentAt" label="发送时间" min-width="170" />
|
<el-table-column prop="clientId" label="连接ID" width="100" />
|
<el-table-column prop="remoteEndPoint" label="远端地址" min-width="170" />
|
<el-table-column prop="message" label="消息内容" min-width="250" />
|
</el-table>
|
</el-tab-pane>
|
</el-tabs>
|
</el-dialog>
|
</div>
|
</template>
|
|
<script setup lang="ts">
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { ElMessage } from 'element-plus'
|
import {
|
clearRobotClientReceivedMessages,
|
getRobotClientStatus,
|
sendRobotClientMessage,
|
startRobotClients,
|
stopRobotClients
|
} from '../api'
|
import type {
|
RobotClientStartRequest,
|
RobotClientStatusResponse,
|
RobotServerStatusItem
|
} from '../types'
|
|
const startForm = ref<RobotClientStartRequest>({
|
serverId: 'robot-client-1',
|
listenIp: '127.0.0.1',
|
listenPort: 2000,
|
localPort: 2001
|
})
|
|
const status = ref<RobotClientStatusResponse | null>(null)
|
const selectedServerId = ref('')
|
|
const refreshing = ref(false)
|
const starting = ref(false)
|
const stoppingAll = ref(false)
|
const sending = ref(false)
|
const clearingMessages = ref(false)
|
const messageCenterVisible = ref(false)
|
const messageTab = ref<'received' | 'sent'>('received')
|
|
const sendMessage = ref('')
|
|
let timer: number | null = null
|
|
const totalConnectedCount = computed(() =>
|
(status.value?.servers || []).reduce((sum, x) => sum + (x.connectedCount || 0), 0)
|
)
|
|
const selectedServer = computed<RobotServerStatusItem | null>(() => {
|
if (!status.value) return null
|
return status.value.servers.find(x => x.serverId === selectedServerId.value) || null
|
})
|
|
async function loadStatus() {
|
refreshing.value = true
|
try {
|
status.value = await getRobotClientStatus()
|
if ((status.value.servers || []).length > 0) {
|
const exists = status.value.servers.some(x => x.serverId === selectedServerId.value)
|
if (!exists) {
|
selectedServerId.value = status.value.servers[0].serverId
|
}
|
} else {
|
selectedServerId.value = ''
|
}
|
} catch (error) {
|
console.error(error)
|
} finally {
|
refreshing.value = false
|
}
|
}
|
|
function onServerRowClick(row: RobotServerStatusItem) {
|
selectedServerId.value = row.serverId
|
}
|
|
function serverRowClassName(args: { row: RobotServerStatusItem }) {
|
return args.row.serverId === selectedServerId.value ? 'is-selected-row' : ''
|
}
|
|
function openMessageCenter() {
|
if (!selectedServer.value) {
|
ElMessage.warning('请先选择一个客户端实例')
|
return
|
}
|
|
messageTab.value = 'received'
|
messageCenterVisible.value = true
|
}
|
|
async function handleStart() {
|
if (!startForm.value.serverId.trim()) {
|
ElMessage.warning('请输入实例ID')
|
return
|
}
|
|
starting.value = true
|
try {
|
status.value = await startRobotClients({
|
...startForm.value,
|
serverId: startForm.value.serverId.trim()
|
})
|
selectedServerId.value = startForm.value.serverId.trim()
|
ElMessage.success('客户端实例连接成功')
|
} catch (error) {
|
console.error(error)
|
ElMessage.error('连接失败,请检查服务端地址和端口')
|
} finally {
|
starting.value = false
|
}
|
}
|
|
async function handleStopAll() {
|
stoppingAll.value = true
|
try {
|
await stopRobotClients()
|
await loadStatus()
|
ElMessage.success('全部客户端实例已停止')
|
} catch (error) {
|
console.error(error)
|
ElMessage.error('停止失败,请查看日志')
|
} finally {
|
stoppingAll.value = false
|
}
|
}
|
|
async function handleStopOne(serverId: string) {
|
try {
|
await stopRobotClients(serverId)
|
await loadStatus()
|
ElMessage.success(`实例 ${serverId} 已停止`)
|
} catch (error) {
|
console.error(error)
|
ElMessage.error('停止实例失败')
|
}
|
}
|
|
async function handleSend() {
|
if (!selectedServer.value) {
|
ElMessage.warning('请先选择一个客户端实例')
|
return
|
}
|
|
if (!sendMessage.value.trim()) {
|
ElMessage.warning('请输入发送内容')
|
return
|
}
|
|
sending.value = true
|
try {
|
await sendRobotClientMessage({
|
serverId: selectedServer.value.serverId,
|
clientId: null,
|
message: sendMessage.value.trim()
|
})
|
ElMessage.success('发送成功')
|
} catch (error) {
|
console.error(error)
|
ElMessage.error('发送失败,请检查连接状态')
|
} finally {
|
sending.value = false
|
}
|
}
|
|
async function handleClearMessages() {
|
if (!selectedServer.value) {
|
ElMessage.warning('请先选择一个客户端实例')
|
return
|
}
|
|
clearingMessages.value = true
|
try {
|
await clearRobotClientReceivedMessages(selectedServer.value.serverId)
|
await loadStatus()
|
ElMessage.success('接收消息已清空')
|
} catch (error) {
|
console.error(error)
|
ElMessage.error('清空失败,请查看日志')
|
} finally {
|
clearingMessages.value = false
|
}
|
}
|
|
onMounted(async () => {
|
await loadStatus()
|
timer = window.setInterval(() => {
|
loadStatus()
|
}, 2000)
|
})
|
|
onUnmounted(() => {
|
if (timer !== null) {
|
clearInterval(timer)
|
}
|
})
|
</script>
|
|
<style scoped>
|
.robot-admin-page {
|
display: flex;
|
flex-direction: column;
|
gap: 14px;
|
}
|
|
.toolbar-actions {
|
display: flex;
|
gap: 8px;
|
}
|
|
.stats-row {
|
margin-bottom: 0;
|
}
|
|
.stat-card {
|
min-height: 92px;
|
}
|
|
.create-card,
|
.panel-card {
|
border-radius: 8px;
|
}
|
|
.create-form {
|
display: flex;
|
flex-wrap: wrap;
|
gap: 8px 12px;
|
}
|
|
.main-row {
|
margin-top: 0;
|
}
|
|
.panel-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
}
|
|
.desc-block {
|
margin-bottom: 12px;
|
}
|
|
.connection-card {
|
margin-top: 8px;
|
}
|
|
.message-send-card {
|
margin-bottom: 8px;
|
}
|
|
.send-row {
|
display: flex;
|
gap: 8px;
|
align-items: center;
|
flex-wrap: wrap;
|
}
|
|
.message-actions {
|
display: flex;
|
gap: 10px;
|
margin-bottom: 8px;
|
}
|
|
.message-toolbar {
|
display: flex;
|
justify-content: flex-end;
|
margin-bottom: 6px;
|
}
|
|
:deep(.is-selected-row td) {
|
background-color: #ecf5ff !important;
|
}
|
</style>
|