提交 63547417 authored 作者: 陈泽健's avatar 陈泽健

feat(device-sim): 页面优化 - 批量操作 + 实时消息流 + 环境切换

- 后端: 批量启动/停止/删除 API + WebSocket 实时消息推送

- 前端: DeviceList 批量勾选和操作 + MessageStream 实时消息流组件

- 页面重构: 左右布局(设备列表+消息流) + 环境选择器
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 e4223169
...@@ -10,10 +10,11 @@ ...@@ -10,10 +10,11 @@
""" """
import logging import logging
import json
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
...@@ -22,6 +23,8 @@ from app.schemas.device_sim import ( ...@@ -22,6 +23,8 @@ from app.schemas.device_sim import (
SimulatorCreate, SimulatorUpdate, SimulatorResponse, SimulatorListResponse, SimulatorCreate, SimulatorUpdate, SimulatorResponse, SimulatorListResponse,
ReportLogResponse, ReportLogListResponse, ReportLogResponse, ReportLogListResponse,
ManualReportRequest, TestConnectionResponse, ManualReportRequest, TestConnectionResponse,
TopicTemplateResponse, TopicTemplateListResponse,
BatchOperationRequest, BatchOperationResponse,
) )
from app.services.device_sim_service import DeviceSimService from app.services.device_sim_service import DeviceSimService
...@@ -44,6 +47,40 @@ def get_device_sim_service(db: AsyncSession = Depends(get_db)) -> DeviceSimServi ...@@ -44,6 +47,40 @@ def get_device_sim_service(db: AsyncSession = Depends(get_db)) -> DeviceSimServi
return DeviceSimService(db) return DeviceSimService(db)
# ==================== 主题模板 API ====================
@router.get("/topic-templates/{device_type}", response_model=TopicTemplateListResponse)
async def get_topic_templates(device_type: str):
"""
获取设备类型的主题模板列表
返回该设备类型的所有主题模板,包含参数定义和默认值。
"""
from app.simulators.topic_templates import (
get_topic_templates as _get_topic_templates,
has_real_topics,
)
templates = _get_topic_templates(device_type)
return TopicTemplateListResponse(
device_type=device_type,
templates=[
TopicTemplateResponse(
key=t["key"],
template=t["template"],
label=t["label"],
params=t["params"],
param_labels=t["param_labels"],
param_defaults=t["param_defaults"],
direction=t["direction"],
)
for t in templates
],
has_real_topics=has_real_topics(device_type),
)
# ==================== 环境配置 API ==================== # ==================== 环境配置 API ====================
...@@ -211,6 +248,67 @@ async def create_simulator( ...@@ -211,6 +248,67 @@ async def create_simulator(
raise HTTPException(status_code=500, detail=f"创建模拟设备失败: {str(e)}") raise HTTPException(status_code=500, detail=f"创建模拟设备失败: {str(e)}")
# ==================== 批量操作 API ====================
# 注意:批量操作路由必须放在 /devices/{device_id} 之前,避免 batch 被当作 device_id
@router.post("/devices/batch/start", response_model=BatchOperationResponse)
async def batch_start_simulators(
data: BatchOperationRequest,
service: DeviceSimService = Depends(get_device_sim_service)
):
"""
批量启动模拟设备
批量启动多个设备,返回成功/失败数量。
"""
try:
result = await service.batch_start_simulators(data.device_ids)
return BatchOperationResponse(**result)
except Exception as e:
logger.error(f"批量启动失败: {e}")
raise HTTPException(status_code=500, detail=f"批量启动失败: {str(e)}")
@router.post("/devices/batch/stop", response_model=BatchOperationResponse)
async def batch_stop_simulators(
data: BatchOperationRequest,
service: DeviceSimService = Depends(get_device_sim_service)
):
"""
批量停止模拟设备
批量停止多个设备,返回成功/失败数量。
"""
try:
result = await service.batch_stop_simulators(data.device_ids)
return BatchOperationResponse(**result)
except Exception as e:
logger.error(f"批量停止失败: {e}")
raise HTTPException(status_code=500, detail=f"批量停止失败: {str(e)}")
@router.delete("/devices/batch", response_model=BatchOperationResponse)
async def batch_delete_simulators(
data: BatchOperationRequest,
service: DeviceSimService = Depends(get_device_sim_service)
):
"""
批量删除模拟设备
批量删除多个设备,返回成功/失败数量。
"""
try:
result = await service.batch_delete_simulators(data.device_ids)
return BatchOperationResponse(**result)
except Exception as e:
logger.error(f"批量删除失败: {e}")
raise HTTPException(status_code=500, detail=f"批量删除失败: {str(e)}")
# ==================== 单设备操作 API ====================
@router.get("/devices/{device_id}", response_model=SimulatorResponse) @router.get("/devices/{device_id}", response_model=SimulatorResponse)
async def get_simulator( async def get_simulator(
device_id: str, device_id: str,
...@@ -373,3 +471,111 @@ async def cleanup_report_logs( ...@@ -373,3 +471,111 @@ async def cleanup_report_logs(
except Exception as e: except Exception as e:
logger.error(f"清理上报记录失败: {e}") logger.error(f"清理上报记录失败: {e}")
raise HTTPException(status_code=500, detail=f"清理上报记录失败: {str(e)}") raise HTTPException(status_code=500, detail=f"清理上报记录失败: {str(e)}")
# ==================== WebSocket 实时消息推送 ====================
# 设备模拟消息 WebSocket 连接管理
_device_ws_connections: List[WebSocket] = []
async def _broadcast_device_message(message: dict):
"""向所有连接的 WebSocket 客户端广播设备消息"""
if not _device_ws_connections:
return
message_str = json.dumps(message, ensure_ascii=False)
disconnected = []
for ws in _device_ws_connections:
try:
await ws.send_text(message_str)
except Exception as e:
logger.warning(f"WebSocket 发送失败: {e}")
disconnected.append(ws)
# 清理断开的连接
for ws in disconnected:
if ws in _device_ws_connections:
_device_ws_connections.remove(ws)
@router.websocket("/ws/messages")
async def websocket_device_messages(websocket: WebSocket):
"""
设备消息实时推送 WebSocket 端点
连接后,客户端会实时收到所有设备的上报和下发消息。
消息格式:
{
"event": "device_message",
"data": {
"device_id": "sim_xxx",
"device_name": "门口屏-001",
"device_type": "door",
"topic": "rebootResponseTopic",
"direction": "publish",
"payload": {...},
"timestamp": "2026-08-05T14:32:01"
}
}
"""
await websocket.accept()
_device_ws_connections.append(websocket)
logger.info(f"WebSocket 连接已建立,当前连接数: {len(_device_ws_connections)}")
try:
# 发送连接确认
await websocket.send_text(json.dumps({
"event": "connected",
"message": "已连接到设备消息流"
}))
# 保持连接,等待客户端消息或断开
while True:
data = await websocket.receive_text()
# 可以处理客户端发来的订阅过滤等命令
try:
msg = json.loads(data)
if msg.get("type") == "ping":
await websocket.send_text(json.dumps({"event": "pong"}))
except json.JSONDecodeError:
pass
except WebSocketDisconnect:
logger.info("WebSocket 客户端断开连接")
except Exception as e:
logger.error(f"WebSocket 异常: {e}")
finally:
if websocket in _device_ws_connections:
_device_ws_connections.remove(websocket)
logger.info(f"WebSocket 连接已关闭,当前连接数: {len(_device_ws_connections)}")
# 注册消息推送回调到 service 层
def _register_ws_callback():
"""注册 WebSocket 推送回调到 device_sim_service"""
from app.services.device_sim_service import set_message_callback
async def _on_device_message(device_id: str, device_name: str, device_type: str,
topic: str, direction: str, payload: dict):
"""设备消息回调"""
await _broadcast_device_message({
"event": "device_message",
"data": {
"device_id": device_id,
"device_name": device_name,
"device_type": device_type,
"topic": topic,
"direction": direction,
"payload": payload,
"timestamp": datetime.now().isoformat()
}
})
set_message_callback(_on_device_message)
# 在模块加载时注册回调
_register_ws_callback()
\ No newline at end of file
...@@ -273,3 +273,30 @@ class TopicTemplateListResponse(BaseModel): ...@@ -273,3 +273,30 @@ class TopicTemplateListResponse(BaseModel):
alias_generator=to_camel, alias_generator=to_camel,
populate_by_name=True, populate_by_name=True,
) )
# ==================== 批量操作 Schema ====================
class BatchOperationRequest(BaseModel):
"""批量操作请求"""
device_ids: List[str] = Field(..., min_length=1, description="设备 ID 列表")
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
class BatchOperationResponse(BaseModel):
"""批量操作响应"""
success_count: int = Field(..., description="成功数量")
failed_count: int = Field(..., description="失败数量")
failed_ids: List[str] = Field(default=[], description="失败的设备 ID 列表")
message: str = Field(..., description="操作结果信息")
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
...@@ -31,6 +31,15 @@ from app.config import settings ...@@ -31,6 +31,15 @@ from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 消息推送回调函数(由 WebSocket 路由设置)
_message_callback = None
def set_message_callback(callback):
"""设置消息推送回调函数"""
global _message_callback
_message_callback = callback
# 密码加密密钥(使用 settings 中的密钥,实际生产环境应使用密钥管理服务) # 密码加密密钥(使用 settings 中的密钥,实际生产环境应使用密钥管理服务)
# 简化实现:使用固定的 Fernet 密钥,生产环境应替换 # 简化实现:使用固定的 Fernet 密钥,生产环境应替换
_ENCRYPTION_KEY = None _ENCRYPTION_KEY = None
...@@ -577,6 +586,21 @@ class DeviceSimService: ...@@ -577,6 +586,21 @@ class DeviceSimService:
sim.last_reported_at = datetime.now() sim.last_reported_at = datetime.now()
await self.db.flush() await self.db.flush()
# 触发 WebSocket 推送回调
if _message_callback and sim:
try:
await _message_callback(
device_id=device_id,
device_name=sim.device_name,
device_type=sim.device_type,
topic=topic,
direction=direction,
payload=payload,
)
except Exception as e:
logger.warning(f"WebSocket 推送回调异常: {e}")
except Exception as e: except Exception as e:
logger.error(f"创建上报记录异常: {e}") logger.error(f"创建上报记录异常: {e}")
...@@ -628,6 +652,101 @@ class DeviceSimService: ...@@ -628,6 +652,101 @@ class DeviceSimService:
return sim.report(topic, payload) return sim.report(topic, payload)
# ==================== 批量操作 ====================
async def batch_start_simulators(self, device_ids: List[str]) -> dict:
"""
批量启动模拟设备
Args:
device_ids: 设备 ID 列表
Returns:
dict: {"success_count": int, "failed_count": int, "failed_ids": list, "message": str}
"""
success_count = 0
failed_ids = []
for device_id in device_ids:
try:
result = await self.start_simulator(device_id)
if result:
success_count += 1
else:
failed_ids.append(device_id)
except Exception as e:
logger.warning(f"批量启动设备失败: {device_id}, error={e}")
failed_ids.append(device_id)
return {
"success_count": success_count,
"failed_count": len(failed_ids),
"failed_ids": failed_ids,
"message": f"批量启动完成: 成功 {success_count}, 失败 {len(failed_ids)}"
}
async def batch_stop_simulators(self, device_ids: List[str]) -> dict:
"""
批量停止模拟设备
Args:
device_ids: 设备 ID 列表
Returns:
dict: {"success_count": int, "failed_count": int, "failed_ids": list, "message": str}
"""
success_count = 0
failed_ids = []
for device_id in device_ids:
try:
result = await self.stop_simulator(device_id)
if result:
success_count += 1
else:
failed_ids.append(device_id)
except Exception as e:
logger.warning(f"批量停止设备失败: {device_id}, error={e}")
failed_ids.append(device_id)
return {
"success_count": success_count,
"failed_count": len(failed_ids),
"failed_ids": failed_ids,
"message": f"批量停止完成: 成功 {success_count}, 失败 {len(failed_ids)}"
}
async def batch_delete_simulators(self, device_ids: List[str]) -> dict:
"""
批量删除模拟设备
Args:
device_ids: 设备 ID 列表
Returns:
dict: {"success_count": int, "failed_count": int, "failed_ids": list, "message": str}
"""
success_count = 0
failed_ids = []
for device_id in device_ids:
try:
result = await self.delete_simulator(device_id)
if result:
success_count += 1
else:
failed_ids.append(device_id)
except Exception as e:
logger.warning(f"批量删除设备失败: {device_id}, error={e}")
failed_ids.append(device_id)
return {
"success_count": success_count,
"failed_count": len(failed_ids),
"failed_ids": failed_ids,
"message": f"批量删除完成: 成功 {success_count}, 失败 {len(failed_ids)}"
}
# ==================== 上报记录 ==================== # ==================== 上报记录 ====================
async def list_report_logs( async def list_report_logs(
......
...@@ -18,6 +18,7 @@ import type { ...@@ -18,6 +18,7 @@ import type {
ReportLogListResponse, ReportLogListResponse,
ManualReportRequest, ManualReportRequest,
TestConnectionResponse, TestConnectionResponse,
TopicTemplateListResponse,
} from '@/types/device' } from '@/types/device'
const BASE = '/api/device-sim' const BASE = '/api/device-sim'
...@@ -64,7 +65,14 @@ export function listSimulators(params?: { ...@@ -64,7 +65,14 @@ export function listSimulators(params?: {
envConfigId?: string envConfigId?: string
status?: string status?: string
}): Promise<SimulatorListResponse> { }): Promise<SimulatorListResponse> {
return request.get(`${BASE}/devices`, { params }) // 转换参数名为 snake_case(后端要求)
const snakeParams: Record<string, any> = {}
if (params?.skip !== undefined) snakeParams.skip = params.skip
if (params?.limit !== undefined) snakeParams.limit = params.limit
if (params?.deviceType !== undefined) snakeParams.device_type = params.deviceType
if (params?.envConfigId !== undefined) snakeParams.env_config_id = params.envConfigId
if (params?.status !== undefined) snakeParams.status = params.status
return request.get(`${BASE}/devices`, { params: snakeParams })
} }
/** 获取模拟设备详情 */ /** 获取模拟设备详情 */
...@@ -120,3 +128,70 @@ export function listReportLogs(params?: { ...@@ -120,3 +128,70 @@ export function listReportLogs(params?: {
export function cleanupReportLogs(days: number = 7): Promise<{ message: string; cleanedCount: number }> { export function cleanupReportLogs(days: number = 7): Promise<{ message: string; cleanedCount: number }> {
return request.delete(`${BASE}/report-logs/cleanup`, { params: { days } }) return request.delete(`${BASE}/report-logs/cleanup`, { params: { days } })
} }
// ==================== 主题模板 API ====================
/** 获取设备类型的主题模板 */
export function getTopicTemplates(deviceType: string): Promise<TopicTemplateListResponse> {
return request.get(`${BASE}/topic-templates/${deviceType}`)
}
// ==================== 批量操作 API ====================
/** 批量启动设备 */
export function batchStartDevices(deviceIds: string[]): Promise<{successCount: number, failedCount: number, failedIds: string[], message: string}> {
return request.post(`${BASE}/devices/batch/start`, { deviceIds })
}
/** 批量停止设备 */
export function batchStopDevices(deviceIds: string[]): Promise<{successCount: number, failedCount: number, failedIds: string[], message: string}> {
return request.post(`${BASE}/devices/batch/stop`, { deviceIds })
}
/** 批量删除设备 */
export function batchDeleteDevices(deviceIds: string[]): Promise<{successCount: number, failedCount: number, failedIds: string[], message: string}> {
return request.delete(`${BASE}/devices/batch`, { data: { deviceIds } })
}
// ==================== WebSocket 消息流 ====================
/** 设备消息类型 */
export interface DeviceMessage {
event: string
data?: {
device_id: string
device_name: string
device_type: string
topic: string
direction: 'publish' | 'subscribe'
payload: Record<string, any>
timestamp: string
}
message?: string
}
/** 创建 WebSocket 连接获取实时消息 */
export function createMessageStream(
onMessage: (msg: DeviceMessage) => void,
onError?: (error: Event) => void
): WebSocket {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const ws = new WebSocket(`${protocol}//${host}/api/device-sim/ws/messages`)
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data) as DeviceMessage
onMessage(msg)
} catch (e) {
console.error('解析 WebSocket 消息失败', e)
}
}
ws.onerror = (error) => {
console.error('WebSocket 错误', error)
onError?.(error)
}
return ws
}
\ No newline at end of file
...@@ -19,13 +19,33 @@ ...@@ -19,13 +19,33 @@
<el-option label="异常" value="error" /> <el-option label="异常" value="error" />
</el-select> </el-select>
<div style="flex: 1" /> <div style="flex: 1" />
<!-- 批量操作 -->
<template v-if="selectedIds.length > 0">
<el-tag size="small" type="info" style="margin-right: 8px">已选 {{ selectedIds.length }}</el-tag>
<el-button type="success" size="small" :loading="batchOperating" @click="handleBatchStart">批量启动</el-button>
<el-button type="warning" size="small" :loading="batchOperating" @click="handleBatchStop">批量停止</el-button>
<el-popconfirm title="确定删除选中的设备?" @confirm="handleBatchDelete">
<template #reference>
<el-button type="danger" size="small" :loading="batchOperating">批量删除</el-button>
</template>
</el-popconfirm>
</template>
<el-button type="primary" @click="openCreateDialog"> <el-button type="primary" @click="openCreateDialog">
<el-icon><Plus /></el-icon>新增设备 <el-icon><Plus /></el-icon>新增设备
</el-button> </el-button>
</div> </div>
<!-- 设备列表 --> <!-- 设备列表 -->
<el-table :data="devices" v-loading="loading" stripe style="width: 100%"> <el-table
:data="devices"
v-loading="loading"
stripe
style="width: 100%"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="50" />
<el-table-column prop="deviceName" label="设备名称" min-width="140" /> <el-table-column prop="deviceName" label="设备名称" min-width="140" />
<el-table-column prop="deviceId" label="设备 ID" min-width="160" /> <el-table-column prop="deviceId" label="设备 ID" min-width="160" />
<el-table-column label="状态" width="100"> <el-table-column label="状态" width="100">
...@@ -233,6 +253,9 @@ import { ...@@ -233,6 +253,9 @@ import {
manualReport, manualReport,
listEnvConfigs, listEnvConfigs,
getTopicTemplates, getTopicTemplates,
batchStartDevices,
batchStopDevices,
batchDeleteDevices,
} from '@/api/deviceSim' } from '@/api/deviceSim'
import type { Simulator, SimulatorCreate, EnvConfig, DeviceType, TopicTemplate } from '@/types/device' import type { Simulator, SimulatorCreate, EnvConfig, DeviceType, TopicTemplate } from '@/types/device'
import { DEVICE_TYPE_LABELS } from '@/types/device' import { DEVICE_TYPE_LABELS } from '@/types/device'
...@@ -255,6 +278,10 @@ const currentPage = ref(1) ...@@ -255,6 +278,10 @@ const currentPage = ref(1)
const limit = 20 const limit = 20
const operatingId = ref('') const operatingId = ref('')
// 批量操作
const selectedIds = ref<string[]>([])
const batchOperating = ref(false)
// 筛选 // 筛选
const envConfigs = ref<EnvConfig[]>([]) const envConfigs = ref<EnvConfig[]>([])
const filterEnvConfigId = ref('') const filterEnvConfigId = ref('')
...@@ -560,6 +587,59 @@ function formatTime(t: string) { ...@@ -560,6 +587,59 @@ function formatTime(t: string) {
return t?.replace('T', ' ').substring(0, 19) || '-' return t?.replace('T', ' ').substring(0, 19) || '-'
} }
/** 表格选择变化 */
function handleSelectionChange(rows: Simulator[]) {
selectedIds.value = rows.map(r => r.id)
}
/** 批量启动 */
async function handleBatchStart() {
batchOperating.value = true
try {
const res = await batchStartDevices(selectedIds.value)
ElMessage.success(res.message)
selectedIds.value = []
await loadDevices()
emit('device-changed')
} catch (e: any) {
ElMessage.error('批量启动失败: ' + (e.message || ''))
} finally {
batchOperating.value = false
}
}
/** 批量停止 */
async function handleBatchStop() {
batchOperating.value = true
try {
const res = await batchStopDevices(selectedIds.value)
ElMessage.success(res.message)
selectedIds.value = []
await loadDevices()
emit('device-changed')
} catch (e: any) {
ElMessage.error('批量停止失败: ' + (e.message || ''))
} finally {
batchOperating.value = false
}
}
/** 批量删除 */
async function handleBatchDelete() {
batchOperating.value = true
try {
const res = await batchDeleteDevices(selectedIds.value)
ElMessage.success(res.message)
selectedIds.value = []
await loadDevices()
emit('device-changed')
} catch (e: any) {
ElMessage.error('批量删除失败: ' + (e.message || ''))
} finally {
batchOperating.value = false
}
}
onMounted(() => { onMounted(() => {
loadEnvConfigs() loadEnvConfigs()
loadDevices() loadDevices()
......
<!--
组件名称:MessageStream.vue
组件描述:实时消息流组件 - 终端风格显示设备上报/下发消息
@author czj
@date 2026-08-05
-->
<template>
<div class="message-stream">
<!-- 工具栏 -->
<div class="toolbar">
<div class="filters">
<el-radio-group v-model="directionFilter" size="small">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button label="publish">上报</el-radio-button>
<el-radio-button label="subscribe">下发</el-radio-button>
</el-radio-group>
<el-input
v-model="searchText"
placeholder="搜索设备/主题"
size="small"
clearable
style="width: 180px; margin-left: 12px"
/>
</div>
<div class="actions">
<el-button :type="paused ? 'warning' : 'default'" size="small" @click="paused = !paused">
{{ paused ? '继续' : '暂停' }}
</el-button>
<el-button size="small" @click="messages = []">清空</el-button>
<el-tag size="small" type="info">{{ filteredMessages.length }}</el-tag>
</div>
</div>
<!-- 消息列表 -->
<div class="message-list" ref="listRef">
<div v-if="filteredMessages.length === 0" class="empty-state">
<el-empty description="暂无消息" :image-size="60" />
</div>
<div
v-for="(msg, index) in filteredMessages"
:key="index"
class="message-item"
:class="`direction-${msg.direction}`"
@click="toggleDetail(msg)"
>
<div class="message-header">
<span class="timestamp">{{ formatTime(msg.timestamp) }}</span>
<el-tag
:type="msg.direction === 'publish' ? 'primary' : 'success'"
size="small"
effect="plain"
>
{{ msg.direction === 'publish' ? '上报' : '下发' }}
</el-tag>
<span class="device-name">{{ msg.deviceName }}</span>
<span class="topic">{{ msg.topic }}</span>
</div>
<div v-if="msg._expanded" class="message-detail">
<pre>{{ JSON.stringify(msg.payload, null, 2) }}</pre>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { createMessageStream, type DeviceMessage } from '@/api/deviceSim'
interface StreamMessage {
deviceId: string
deviceName: string
deviceType: string
topic: string
direction: 'publish' | 'subscribe'
payload: Record<string, any>
timestamp: string
_expanded?: boolean
}
const props = defineProps<{
deviceType?: string
selectedDeviceIds?: string[]
}>()
const messages = ref<StreamMessage[]>([])
const directionFilter = ref('')
const searchText = ref('')
const paused = ref(false)
const listRef = ref<HTMLElement>()
let ws: WebSocket | null = null
const filteredMessages = computed(() => {
let result = messages.value
if (directionFilter.value) {
result = result.filter(m => m.direction === directionFilter.value)
}
if (searchText.value) {
const search = searchText.value.toLowerCase()
result = result.filter(m =>
m.deviceName.toLowerCase().includes(search) ||
m.deviceId.toLowerCase().includes(search) ||
m.topic.toLowerCase().includes(search)
)
}
if (props.selectedDeviceIds?.length) {
result = result.filter(m => props.selectedDeviceIds!.includes(m.deviceId))
}
return result
})
function toggleDetail(msg: StreamMessage) {
msg._expanded = !msg._expanded
}
function formatTime(ts: string) {
return ts?.replace('T', ' ').substring(11, 23) || '-'
}
function scrollToBottom() {
if (listRef.value) {
listRef.value.scrollTop = listRef.value.scrollHeight
}
}
function connect() {
ws = createMessageStream((msg: DeviceMessage) => {
if (paused.value) return
if (msg.event === 'device_message' && msg.data) {
// 按设备类型过滤
if (props.deviceType && msg.data.device_type !== props.deviceType) return
messages.value.push({
deviceId: msg.data.device_id,
deviceName: msg.data.device_name,
deviceType: msg.data.device_type,
topic: msg.data.topic,
direction: msg.data.direction,
payload: msg.data.payload,
timestamp: msg.data.timestamp,
_expanded: false,
})
// 限制最大消息数
if (messages.value.length > 500) {
messages.value = messages.value.slice(-300)
}
requestAnimationFrame(scrollToBottom)
}
})
}
onMounted(() => {
connect()
})
onUnmounted(() => {
ws?.close()
})
</script>
<style scoped>
.message-stream {
display: flex;
flex-direction: column;
height: 100%;
border: 1px solid #e4e7ed;
border-radius: 4px;
overflow: hidden;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid #e4e7ed;
background: #f5f7fa;
}
.filters {
display: flex;
align-items: center;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
}
.message-list {
flex: 1;
overflow-y: auto;
font-family: 'Courier New', Consolas, monospace;
font-size: 12px;
background: #1e1e1e;
color: #d4d4d4;
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.message-item {
padding: 6px 12px;
border-bottom: 1px solid #333;
cursor: pointer;
transition: background 0.15s;
}
.message-item:hover {
background: #2a2a2a;
}
.message-item.direction-publish {
border-left: 3px solid #409eff;
}
.message-item.direction-subscribe {
border-left: 3px solid #67c23a;
}
.message-header {
display: flex;
align-items: center;
gap: 8px;
}
.timestamp {
color: #888;
font-size: 11px;
}
.device-name {
color: #e6a23c;
font-weight: 500;
}
.topic {
color: #aaa;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
.message-detail {
margin-top: 6px;
padding: 8px;
background: #252525;
border-radius: 4px;
overflow-x: auto;
}
.message-detail pre {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
font-size: 11px;
color: #d4d4d4;
}
</style>
<!-- <!--
组件名称:CentralSim.vue 组件名称:CentralSim.vue
组件描述:中控设备模拟页面 组件描述:中控设备模拟页面 - 设备列表 + 实时消息流
@author czj @author czj
@date 2026-07-29 @date 2026-07-29
...@@ -10,57 +10,58 @@ ...@@ -10,57 +10,58 @@
<div class="device-sim-page"> <div class="device-sim-page">
<div class="page-header"> <div class="page-header">
<h2>中控设备模拟</h2> <h2>中控设备模拟</h2>
<div class="header-actions">
<el-select v-model="currentEnvId" placeholder="选择环境" clearable style="width: 220px">
<el-option v-for="env in envConfigs" :key="env.id" :label="env.name" :value="env.id" />
</el-select>
</div>
</div> </div>
<el-card shadow="never" class="device-card"> <!-- 主体:左侧设备列表 + 右侧消息流 -->
<template #header> <div class="main-content">
<span>模拟设备</span> <div class="left-panel">
</template>
<DeviceList device-type="central" @device-changed="onDeviceChanged" /> <DeviceList device-type="central" @device-changed="onDeviceChanged" />
</el-card> </div>
<div class="right-panel">
<el-card shadow="never" class="log-card"> <div class="panel-title">实时消息流</div>
<template #header> <MessageStream device-type="central" />
<span>上报记录</span> </div>
</template> </div>
<ReportLog :device-id="selectedDeviceId" />
</el-card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import DeviceList from '@/components/device/DeviceList.vue' import DeviceList from '@/components/device/DeviceList.vue'
import ReportLog from '@/components/device/ReportLog.vue' import MessageStream from '@/components/device/MessageStream.vue'
import { listEnvConfigs } from '@/api/deviceSim'
import type { EnvConfig } from '@/types/device'
const selectedDeviceId = ref('') const envConfigs = ref<EnvConfig[]>([])
const currentEnvId = ref('')
function onDeviceChanged() { function onDeviceChanged() {}
// 设备列表变化时刷新
} onMounted(async () => {
try {
const res = await listEnvConfigs({ limit: 100 })
envConfigs.value = res.items
if (res.items.length > 0) currentEnvId.value = res.items[0].id
} catch (e: any) {
ElMessage.error('加载环境配置失败')
}
})
</script> </script>
<style scoped> <style scoped>
.device-sim-page { .device-sim-page { padding: 0; display: flex; flex-direction: column; height: 100%; }
padding: 0; .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
} .page-header h2 { font-size: 20px; font-weight: 600; color: #303133; margin: 0; }
.header-actions { display: flex; align-items: center; gap: 12px; }
.page-header { .main-content { display: flex; gap: 16px; flex: 1; min-height: 0; }
margin-bottom: 16px; .left-panel { flex: 3; min-width: 0; }
} .right-panel { flex: 2; display: flex; flex-direction: column; min-width: 0; }
.panel-title { font-size: 14px; font-weight: 600; color: #303133; margin-bottom: 8px; }
.page-header h2 { .right-panel :deep(.message-stream) { flex: 1; min-height: 400px; }
font-size: 20px;
font-weight: 600;
color: #303133;
margin: 0;
}
.device-card {
margin-bottom: 16px;
}
.log-card {
margin-bottom: 16px;
}
</style> </style>
\ No newline at end of file
<!-- <!--
组件名称:ClientSim.vue 组件名称:ClientSim.vue
组件描述:集控客户端模拟页面 组件描述:集控客户端模拟页面 - 设备列表 + 实时消息流
@author czj @author czj
@date 2026-07-29 @date 2026-07-29
...@@ -10,57 +10,58 @@ ...@@ -10,57 +10,58 @@
<div class="device-sim-page"> <div class="device-sim-page">
<div class="page-header"> <div class="page-header">
<h2>集控客户端模拟</h2> <h2>集控客户端模拟</h2>
<div class="header-actions">
<el-select v-model="currentEnvId" placeholder="选择环境" clearable style="width: 220px">
<el-option v-for="env in envConfigs" :key="env.id" :label="env.name" :value="env.id" />
</el-select>
</div>
</div> </div>
<el-card shadow="never" class="device-card"> <!-- 主体:左侧设备列表 + 右侧消息流 -->
<template #header> <div class="main-content">
<span>模拟设备</span> <div class="left-panel">
</template>
<DeviceList device-type="client" @device-changed="onDeviceChanged" /> <DeviceList device-type="client" @device-changed="onDeviceChanged" />
</el-card> </div>
<div class="right-panel">
<el-card shadow="never" class="log-card"> <div class="panel-title">实时消息流</div>
<template #header> <MessageStream device-type="client" />
<span>上报记录</span> </div>
</template> </div>
<ReportLog :device-id="selectedDeviceId" />
</el-card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import DeviceList from '@/components/device/DeviceList.vue' import DeviceList from '@/components/device/DeviceList.vue'
import ReportLog from '@/components/device/ReportLog.vue' import MessageStream from '@/components/device/MessageStream.vue'
import { listEnvConfigs } from '@/api/deviceSim'
import type { EnvConfig } from '@/types/device'
const selectedDeviceId = ref('') const envConfigs = ref<EnvConfig[]>([])
const currentEnvId = ref('')
function onDeviceChanged() { function onDeviceChanged() {}
// 设备列表变化时刷新
} onMounted(async () => {
try {
const res = await listEnvConfigs({ limit: 100 })
envConfigs.value = res.items
if (res.items.length > 0) currentEnvId.value = res.items[0].id
} catch (e: any) {
ElMessage.error('加载环境配置失败')
}
})
</script> </script>
<style scoped> <style scoped>
.device-sim-page { .device-sim-page { padding: 0; display: flex; flex-direction: column; height: 100%; }
padding: 0; .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
} .page-header h2 { font-size: 20px; font-weight: 600; color: #303133; margin: 0; }
.header-actions { display: flex; align-items: center; gap: 12px; }
.page-header { .main-content { display: flex; gap: 16px; flex: 1; min-height: 0; }
margin-bottom: 16px; .left-panel { flex: 3; min-width: 0; }
} .right-panel { flex: 2; display: flex; flex-direction: column; min-width: 0; }
.panel-title { font-size: 14px; font-weight: 600; color: #303133; margin-bottom: 8px; }
.page-header h2 { .right-panel :deep(.message-stream) { flex: 1; min-height: 400px; }
font-size: 20px;
font-weight: 600;
color: #303133;
margin: 0;
}
.device-card {
margin-bottom: 16px;
}
.log-card {
margin-bottom: 16px;
}
</style> </style>
\ No newline at end of file
<!-- <!--
组件名称:DoorSim.vue 组件名称:DoorSim.vue
组件描述:门口屏模拟页面 组件描述:门口屏模拟页面 - 设备列表 + 实时消息流
@author czj @author czj
@date 2026-07-29 @date 2026-07-29
...@@ -10,42 +10,95 @@ ...@@ -10,42 +10,95 @@
<div class="device-sim-page"> <div class="device-sim-page">
<div class="page-header"> <div class="page-header">
<h2>门口屏模拟</h2> <h2>门口屏模拟</h2>
<div class="header-actions">
<el-select v-model="currentEnvId" placeholder="选择环境" clearable style="width: 220px" @change="onEnvChange">
<el-option v-for="env in envConfigs" :key="env.id" :label="`${env.name} (${env.brokerHost})`" :value="env.id">
<template #default>
<span>{{ env.name }}</span>
<el-tag :type="env.status === 'connected' ? 'success' : 'info'" size="small" style="margin-left: 8px">
{{ env.status === 'connected' ? '已连接' : '未连接' }}
</el-tag>
</template>
</el-option>
</el-select>
</div>
</div> </div>
<el-card shadow="never" class="device-card"> <!-- 统计卡片 -->
<template #header> <div class="stats-row">
<span>模拟设备</span> <div class="stat-card">
</template> <div class="stat-value">{{ total }}</div>
<DeviceList device-type="door" @device-changed="onDeviceChanged" /> <div class="stat-label">总设备</div>
</el-card> </div>
<div class="stat-card running">
<div class="stat-value">{{ runningCount }}</div>
<div class="stat-label">运行中</div>
</div>
<div class="stat-card stopped">
<div class="stat-value">{{ total - runningCount }}</div>
<div class="stat-label">已停止</div>
</div>
</div>
<el-card shadow="never" class="log-card"> <!-- 主体:左侧设备列表 + 右侧消息流 -->
<template #header> <div class="main-content">
<span>上报记录</span> <div class="left-panel">
</template> <DeviceList device-type="door" :env-config-id="currentEnvId" @device-changed="onDeviceChanged" />
<ReportLog :device-id="selectedDeviceId" /> </div>
</el-card> <div class="right-panel">
<div class="panel-title">实时消息流</div>
<MessageStream device-type="door" />
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import DeviceList from '@/components/device/DeviceList.vue' import DeviceList from '@/components/device/DeviceList.vue'
import ReportLog from '@/components/device/ReportLog.vue' import MessageStream from '@/components/device/MessageStream.vue'
import { listEnvConfigs } from '@/api/deviceSim'
import type { EnvConfig } from '@/types/device'
const selectedDeviceId = ref('') const envConfigs = ref<EnvConfig[]>([])
const currentEnvId = ref('')
const total = ref(0)
const runningCount = ref(0)
function onEnvChange() {
// 环境切换时重新加载
}
function onDeviceChanged() { function onDeviceChanged() {
// 设备列表变化时刷新 // 设备变化时刷新统计
} }
onMounted(async () => {
try {
const res = await listEnvConfigs({ limit: 100 })
envConfigs.value = res.items
if (res.items.length > 0 && !currentEnvId.value) {
currentEnvId.value = res.items[0].id
}
} catch (e: any) {
ElMessage.error('加载环境配置失败')
}
})
</script> </script>
<style scoped> <style scoped>
.device-sim-page { .device-sim-page {
padding: 0; padding: 0;
display: flex;
flex-direction: column;
height: 100%;
} }
.page-header { .page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px; margin-bottom: 16px;
} }
...@@ -56,11 +109,82 @@ function onDeviceChanged() { ...@@ -56,11 +109,82 @@ function onDeviceChanged() {
margin: 0; margin: 0;
} }
.device-card { .header-actions {
margin-bottom: 16px; display: flex;
align-items: center;
gap: 12px;
} }
.log-card { .stats-row {
display: flex;
gap: 16px;
margin-bottom: 16px; margin-bottom: 16px;
} }
.stat-card {
flex: 1;
padding: 16px 20px;
background: #f5f7fa;
border-radius: 8px;
text-align: center;
}
.stat-card .stat-value {
font-size: 28px;
font-weight: 700;
color: #303133;
}
.stat-card .stat-label {
font-size: 13px;
color: #909399;
margin-top: 4px;
}
.stat-card.running {
background: #f0f9eb;
}
.stat-card.running .stat-value {
color: #67c23a;
}
.stat-card.stopped {
background: #f4f4f5;
}
.stat-card.stopped .stat-value {
color: #909399;
}
.main-content {
display: flex;
gap: 16px;
flex: 1;
min-height: 0;
}
.left-panel {
flex: 3;
min-width: 0;
}
.right-panel {
flex: 2;
display: flex;
flex-direction: column;
min-width: 0;
}
.panel-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
}
.right-panel :deep(.message-stream) {
flex: 1;
min-height: 400px;
}
</style> </style>
<!-- <!--
组件名称:PaperlessSim.vue 组件名称:PaperlessSim.vue
组件描述:无纸化模拟页面 组件描述:无纸化模拟页面 - 设备列表 + 实时消息流
@author czj @author czj
@date 2026-07-29 @date 2026-07-29
...@@ -10,57 +10,58 @@ ...@@ -10,57 +10,58 @@
<div class="device-sim-page"> <div class="device-sim-page">
<div class="page-header"> <div class="page-header">
<h2>无纸化模拟</h2> <h2>无纸化模拟</h2>
<div class="header-actions">
<el-select v-model="currentEnvId" placeholder="选择环境" clearable style="width: 220px">
<el-option v-for="env in envConfigs" :key="env.id" :label="env.name" :value="env.id" />
</el-select>
</div>
</div> </div>
<el-card shadow="never" class="device-card"> <!-- 主体:左侧设备列表 + 右侧消息流 -->
<template #header> <div class="main-content">
<span>模拟设备</span> <div class="left-panel">
</template>
<DeviceList device-type="paperless" @device-changed="onDeviceChanged" /> <DeviceList device-type="paperless" @device-changed="onDeviceChanged" />
</el-card> </div>
<div class="right-panel">
<el-card shadow="never" class="log-card"> <div class="panel-title">实时消息流</div>
<template #header> <MessageStream device-type="paperless" />
<span>上报记录</span> </div>
</template> </div>
<ReportLog :device-id="selectedDeviceId" />
</el-card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import DeviceList from '@/components/device/DeviceList.vue' import DeviceList from '@/components/device/DeviceList.vue'
import ReportLog from '@/components/device/ReportLog.vue' import MessageStream from '@/components/device/MessageStream.vue'
import { listEnvConfigs } from '@/api/deviceSim'
import type { EnvConfig } from '@/types/device'
const selectedDeviceId = ref('') const envConfigs = ref<EnvConfig[]>([])
const currentEnvId = ref('')
function onDeviceChanged() { function onDeviceChanged() {}
// 设备列表变化时刷新
} onMounted(async () => {
try {
const res = await listEnvConfigs({ limit: 100 })
envConfigs.value = res.items
if (res.items.length > 0) currentEnvId.value = res.items[0].id
} catch (e: any) {
ElMessage.error('加载环境配置失败')
}
})
</script> </script>
<style scoped> <style scoped>
.device-sim-page { .device-sim-page { padding: 0; display: flex; flex-direction: column; height: 100%; }
padding: 0; .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
} .page-header h2 { font-size: 20px; font-weight: 600; color: #303133; margin: 0; }
.header-actions { display: flex; align-items: center; gap: 12px; }
.page-header { .main-content { display: flex; gap: 16px; flex: 1; min-height: 0; }
margin-bottom: 16px; .left-panel { flex: 3; min-width: 0; }
} .right-panel { flex: 2; display: flex; flex-direction: column; min-width: 0; }
.panel-title { font-size: 14px; font-weight: 600; color: #303133; margin-bottom: 8px; }
.page-header h2 { .right-panel :deep(.message-stream) { flex: 1; min-height: 400px; }
font-size: 20px;
font-weight: 600;
color: #303133;
margin: 0;
}
.device-card {
margin-bottom: 16px;
}
.log-card {
margin-bottom: 16px;
}
</style> </style>
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论