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

feat(device-sim): 设备模拟多主题配置 + 执行引擎死锁修复

新增设备模拟多主题配置(前后端),支持按设备类型独立配置主题路径;
修复执行引擎三项死锁:专用 threading.Thread 替代 run_in_executor、--loop asyncio 禁用 uvloop、headless 默认值取自环境变量、paho-mqtt 可选导入
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 60b0212e
......@@ -129,6 +129,8 @@ async def _ensure_columns(conn) -> None:
("modules", "parent_id", "VARCHAR(32) DEFAULT NULL"),
("test_cases", "case_type", "VARCHAR(20) DEFAULT 'ui'"),
("executions", "case_type", "VARCHAR(20) DEFAULT 'ui'"),
# 设备模拟:环境配置多主题字段(旧库升级)
("device_env_configs", "topics", "JSON"),
]
def _do_ensure(sync_conn) -> None:
......
......@@ -145,7 +145,9 @@ class PlaywrightExecutor:
self.config = config or {}
self.timeout: int = self.config.get("timeout", 30000)
self.screenshot_enabled: bool = self.config.get("screenshot", True)
self.headless: bool = self.config.get("headless", False)
# headless 默认值取自全局配置 settings.PLAYWRIGHT_HEADLESS(由环境变量 PLAYWRIGHT_HEADLESS 控制)。
# 容器内无 X Server,必须 headless=True;原默认 False(headed)会导致容器内启动崩溃。
self.headless: bool = self.config.get("headless", settings.PLAYWRIGHT_HEADLESS)
self.screenshot_dir: str = self.config.get(
"screenshot_dir", settings.SCREENSHOT_DIR
)
......
......@@ -48,7 +48,8 @@ class EnvConfig(Base):
broker_port: Mapped[int] = mapped_column(Integer, default=1883, comment="端口号")
username: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, comment="MQTT 用户名")
password_encrypted: Mapped[Optional[str]] = mapped_column(String(500), nullable=True, comment="密码(加密存储)")
topic_prefix: Mapped[str] = mapped_column(String(200), default="device", comment="主题前缀")
topic_prefix: Mapped[str] = mapped_column(String(200), default="device", comment="主题前缀(已弃用,建议使用 topics)")
topics: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="多主题前缀映射,如 {'door': 'device/door', 'paperless': 'device/paperless'}")
use_tls: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否启用 TLS")
client_id_prefix: Mapped[str] = mapped_column(String(100), default="sim", comment="客户端 ID 前缀")
status: Mapped[str] = mapped_column(String(20), default="disconnected", comment="连接状态")
......
......@@ -327,6 +327,7 @@ async def manual_report(
@router.get("/report-logs", response_model=ReportLogListResponse)
async def list_report_logs(
device_id: Optional[str] = Query(None, description="设备 ID 筛选"),
direction: Optional[str] = Query(None, description="方向筛选:publish(上报)/subscribe(接收)"),
skip: int = Query(0, ge=0, description="跳过的记录数"),
limit: int = Query(100, ge=1, le=500, description="返回记录数上限"),
start_time: Optional[datetime] = Query(None, description="开始时间"),
......@@ -336,11 +337,12 @@ async def list_report_logs(
"""
获取上报记录列表
支持按设备、时间范围筛选和分页。
支持按设备、方向、时间范围筛选和分页。
"""
try:
items, total = await service.list_report_logs(
device_id=device_id,
direction=direction,
skip=skip,
limit=limit,
start_time=start_time,
......
......@@ -10,10 +10,16 @@
"""
from datetime import datetime
from typing import Optional, List
from typing import Optional, List, Dict
from pydantic import BaseModel, Field, ConfigDict
def to_camel(string: str) -> str:
"""Convert snake_case to camelCase"""
components = string.split('_')
return components[0] + ''.join(x.title() for x in components[1:])
# ==================== 环境配置 Schema ====================
class EnvConfigBase(BaseModel):
......@@ -24,10 +30,16 @@ class EnvConfigBase(BaseModel):
broker_port: int = Field(default=1883, ge=1, le=65535, description="端口号")
username: Optional[str] = Field(default=None, max_length=100, description="MQTT 用户名")
password: Optional[str] = Field(default=None, max_length=500, description="MQTT 密码")
topic_prefix: str = Field(default="device", max_length=200, description="主题前缀")
topic_prefix: str = Field(default="device", max_length=200, description="主题前缀(已弃用,建议使用 topics)")
topics: Optional[Dict[str, str]] = Field(default=None, description="多主题前缀映射,key为设备类型,value为主题前缀")
use_tls: bool = Field(default=False, description="是否启用 TLS")
client_id_prefix: str = Field(default="sim", max_length=100, description="客户端 ID 前缀")
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
class EnvConfigCreate(EnvConfigBase):
"""创建环境配置请求"""
......@@ -42,10 +54,16 @@ class EnvConfigUpdate(BaseModel):
broker_port: Optional[int] = Field(default=None, ge=1, le=65535, description="端口号")
username: Optional[str] = Field(default=None, max_length=100, description="MQTT 用户名")
password: Optional[str] = Field(default=None, max_length=500, description="MQTT 密码")
topic_prefix: Optional[str] = Field(default=None, max_length=200, description="主题前缀")
topic_prefix: Optional[str] = Field(default=None, max_length=200, description="主题前缀(已弃用,建议使用 topics)")
topics: Optional[Dict[str, str]] = Field(default=None, description="多主题前缀映射")
use_tls: Optional[bool] = Field(default=None, description="是否启用 TLS")
client_id_prefix: Optional[str] = Field(default=None, max_length=100, description="客户端 ID 前缀")
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
class EnvConfigResponse(EnvConfigBase):
"""环境配置响应"""
......@@ -56,7 +74,11 @@ class EnvConfigResponse(EnvConfigBase):
created_at: datetime = Field(..., description="创建时间")
updated_at: datetime = Field(..., description="更新时间")
model_config = ConfigDict(from_attributes=True)
model_config = ConfigDict(
from_attributes=True,
alias_generator=to_camel,
populate_by_name=True,
)
class EnvConfigListResponse(BaseModel):
......@@ -78,6 +100,11 @@ class SimulatorBase(BaseModel):
auto_reconnect: bool = Field(default=True, description="是否自动重连")
metadata: Optional[dict] = Field(default=None, description="设备自定义属性")
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
class SimulatorCreate(SimulatorBase):
"""创建模拟设备请求"""
......@@ -91,6 +118,11 @@ class SimulatorUpdate(BaseModel):
auto_reconnect: Optional[bool] = Field(default=None, description="是否自动重连")
metadata: Optional[dict] = Field(default=None, description="设备自定义属性")
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
class SimulatorResponse(SimulatorBase):
"""模拟设备响应"""
......@@ -102,7 +134,11 @@ class SimulatorResponse(SimulatorBase):
created_at: datetime = Field(..., description="创建时间")
updated_at: datetime = Field(..., description="更新时间")
model_config = ConfigDict(from_attributes=True)
model_config = ConfigDict(
from_attributes=True,
alias_generator=to_camel,
populate_by_name=True,
)
class SimulatorListResponse(BaseModel):
......@@ -126,7 +162,11 @@ class ReportLogResponse(BaseModel):
error_message: Optional[str] = Field(default=None, description="错误信息")
created_at: datetime = Field(..., description="创建时间")
model_config = ConfigDict(from_attributes=True)
model_config = ConfigDict(
from_attributes=True,
alias_generator=to_camel,
populate_by_name=True,
)
class ReportLogListResponse(BaseModel):
......
......@@ -136,6 +136,16 @@ class DeviceSimService:
Returns:
EnvConfig: 创建的配置对象
"""
# 自动生成默认多主题映射(如果未传入 topics)
topics = data.topics
if not topics:
topics = {
"door": f"{data.topic_prefix}/door",
"paperless": f"{data.topic_prefix}/paperless",
"central": f"{data.topic_prefix}/central",
"client": f"{data.topic_prefix}/client",
}
config = EnvConfig(
id=generate_id("env"),
name=data.name,
......@@ -143,6 +153,7 @@ class DeviceSimService:
broker_port=data.broker_port,
username=data.username,
topic_prefix=data.topic_prefix,
topics=topics,
use_tls=data.use_tls,
client_id_prefix=data.client_id_prefix,
status="disconnected",
......@@ -175,6 +186,15 @@ class DeviceSimService:
if update_data["password"]:
config.password_encrypted = _encrypt_password(update_data["password"])
del update_data["password"]
# 如果 topics 有更新,同步更新 topic_prefix(向后兼容)
if "topics" in update_data and update_data["topics"]:
# 取第一个设备类型的前缀作为 topic_prefix 的兼容值
first_type = next(iter(update_data["topics"].values()), "")
if first_type:
# 提取公共前缀部分(如 "device/door" -> "device")
parts = first_type.split("/")
if len(parts) >= 1:
config.topic_prefix = parts[0]
for key, value in update_data.items():
setattr(config, key, value)
......@@ -458,12 +478,24 @@ class DeviceSimService:
config.status = "connected"
config.last_connected_at = datetime.now()
# 创建并启动模拟器
# 创建并启动模拟器,传递多主题配置
# 兼容旧数据:topics 为空时,从 topic_prefix 自动生成默认映射
topics = config.topics
if not topics:
prefix = config.topic_prefix or "device"
topics = {
"door": f"{prefix}/door",
"paperless": f"{prefix}/paperless",
"central": f"{prefix}/central",
"client": f"{prefix}/client",
}
sim = create_simulator(
device_type=simulator.device_type,
device_id=simulator.device_id,
env_config_id=config.id,
mqtt_manager=mqtt_manager,
topics=topics,
)
if not sim:
raise ValueError(f"不支持的设备类型: {simulator.device_type}")
......@@ -582,6 +614,7 @@ class DeviceSimService:
async def list_report_logs(
self,
device_id: Optional[str] = None,
direction: Optional[str] = None,
skip: int = 0,
limit: int = 100,
start_time: Optional[datetime] = None,
......@@ -592,6 +625,7 @@ class DeviceSimService:
Args:
device_id: 设备 ID 筛选
direction: 方向筛选 (publish/subscribe)
skip: 跳过的记录数
limit: 返回记录数上限
start_time: 开始时间
......@@ -603,6 +637,8 @@ class DeviceSimService:
conditions = []
if device_id:
conditions.append(ReportLog.device_id == device_id)
if direction:
conditions.append(ReportLog.direction == direction)
if start_time:
conditions.append(ReportLog.created_at >= start_time)
if end_time:
......
......@@ -15,6 +15,7 @@ import subprocess
import sys
import os
import json
import threading
from pathlib import Path
from typing import Optional, List, Tuple, Dict, Any, Callable
from datetime import datetime
......@@ -329,15 +330,36 @@ class ExecutionService:
"case_dict": case.to_dict(),
})
# 在线程池中批量执行(传入事件循环和广播函数以接通 WebSocket 步骤回调)
all_results = await loop.run_in_executor(
None,
run_all_cases_sync,
# 用专用线程执行 Playwright,避免 asyncio 线程池复用线程导致 greenlet 死锁。
# 根因:sync_playwright().start() 在 ThreadPoolExecutor 复用的 worker 线程上
# 会永久卡死(容器内已多次复现:仅打印「开始执行」,永不打印「执行器启动成功」)。
# 专用线程不关联任何 asyncio 事件循环,sync API 可正常启动;
# 步骤回调已通过 asyncio.run_coroutine_threadsafe(..., ev_loop) 跨线程回桥。
exec_holder = {} # {"data": [...]} 或 {"err": Exception}
def _playwright_worker():
try:
exec_holder["data"] = run_all_cases_sync(
[c["case_dict"] for c in cases_to_run],
config,
loop,
manager.broadcast,
)
except Exception as e: # noqa: BLE001 - 线程内异常需透传到主协程
exec_holder["err"] = e
worker_thread = threading.Thread(
target=_playwright_worker,
name=f"playwright-exec-{execution_id}",
daemon=True,
)
worker_thread.start()
# 主协程挂起等待专用线程结束(run_in_executor 仅用于 join,不执行 Playwright)
await loop.run_in_executor(None, worker_thread.join)
if "err" in exec_holder:
raise exec_holder["err"]
all_results = exec_holder.get("data", [])
# 更新结果
for i, case_info in enumerate(cases_to_run):
......
......@@ -9,14 +9,24 @@
最后修改:2026-07-29
"""
from __future__ import annotations
import json
import logging
import threading
import time
from datetime import datetime
from typing import Callable, Optional, Dict
import paho.mqtt.client as mqtt
from typing import Callable, Optional, Dict, Any
# paho-mqtt 为可选依赖:设备模拟(device_sim)模块未启用 / 未联调时,
# Broker 不配置,paho 可能未安装。此处降级为可选导入,避免阻塞整个应用启动。
# 仅在真正调用 MQTT 功能时(连接/发布)才会因 mqtt 为 None 抛错。
try:
import paho.mqtt.client as mqtt
PAHO_AVAILABLE: bool = True
except ImportError: # pragma: no cover - 依赖未安装时的优雅降级
mqtt = None # type: ignore[assignment]
PAHO_AVAILABLE = False
logger = logging.getLogger(__name__)
......
......@@ -37,7 +37,8 @@ DEFAULT_INTERVAL_MAP = {
def create_simulator(device_type: str, device_id: str,
env_config_id: str, mqtt_manager: MqttManager,
report_interval: Optional[int] = None) -> Optional[BaseSimulator]:
report_interval: Optional[int] = None,
topics: Optional[dict] = None) -> Optional[BaseSimulator]:
"""
模拟器工厂方法
......@@ -49,6 +50,7 @@ def create_simulator(device_type: str, device_id: str,
env_config_id: 环境配置 ID
mqtt_manager: MQTT 管理器实例
report_interval: 上报间隔(秒),None 使用默认值
topics: 多主题前缀映射,key 为设备类型,value 为主题前缀
Returns:
BaseSimulator: 模拟器实例,如果设备类型不支持则返回 None
......@@ -64,7 +66,8 @@ def create_simulator(device_type: str, device_id: str,
device_id=device_id,
env_config_id=env_config_id,
mqtt_manager=mqtt_manager,
report_interval=report_interval
report_interval=report_interval,
topics=topics or {}
)
......
......@@ -42,7 +42,8 @@ class BaseSimulator(ABC):
def __init__(self, device_id: str, env_config_id: str,
device_type: str, mqtt_manager: MqttManager,
report_interval: int = 30):
report_interval: int = 30,
topics: Optional[dict] = None):
"""
初始化模拟设备
......@@ -52,6 +53,7 @@ class BaseSimulator(ABC):
device_type: 设备类型
mqtt_manager: MQTT 管理器实例
report_interval: 上报间隔(秒)
topics: 多主题前缀映射,key 为设备类型,value 为主题前缀
"""
self.device_id = device_id
self.env_config_id = env_config_id
......@@ -62,16 +64,20 @@ class BaseSimulator(ABC):
self._report_interval = report_interval
self._on_report_callback = None
self._lock = threading.Lock()
self.topics = topics or {}
@abstractmethod
def get_topic_prefix(self) -> str:
"""
获取设备主题前缀
优先使用环境配置中的多主题映射(topics),如果没有配置则使用默认值。
Returns:
str: 主题前缀,如 "device/door"
"""
pass
if self.topics and self.device_type in self.topics:
return self.topics[self.device_type]
return f"device/{self.device_type}"
@abstractmethod
def build_register_payload(self) -> dict:
......
......@@ -97,8 +97,10 @@ class CentralSimulator(BaseSimulator):
}
def __init__(self, device_id: str, env_config_id: str,
mqtt_manager: MqttManager, report_interval: int = 30):
super().__init__(device_id, env_config_id, "central", mqtt_manager, report_interval)
mqtt_manager: MqttManager, report_interval: int = 30,
topics: Optional[dict] = None):
super().__init__(device_id, env_config_id, "central", mqtt_manager,
report_interval, topics)
self._sub_devices = {}
self._current_scene = "power_off"
self._init_sub_devices()
......@@ -108,10 +110,6 @@ class CentralSimulator(BaseSimulator):
for dev_id, config in self._DEFAULT_SUB_DEVICES.items():
self._sub_devices[dev_id] = dict(config)
def get_topic_prefix(self) -> str:
"""获取中控设备主题前缀"""
return "device/central"
def build_register_payload(self) -> dict:
"""构建设备注册消息"""
return {
......@@ -162,6 +160,15 @@ class CentralSimulator(BaseSimulator):
command = payload.get("command", "")
logger.info(f"中控设备收到指令: device_id={self.device_id}, command={command}")
# 记录接收到的平台下发指令(direction=subscribe)
command_topic = f"{self.get_topic_prefix()}/{self.device_id}/command"
self._notify_report(
command_topic,
payload,
direction="subscribe",
status="success"
)
response_topic = f"{self.get_topic_prefix()}/{self.device_id}/command_response"
if command == "control_sub_device":
......
......@@ -42,7 +42,8 @@ class ClientSimulator(BaseSimulator):
_VERSIONS = ["V1.2.0", "V1.3.0", "V1.4.0", "V2.0.0", "V2.1.0"]
def __init__(self, device_id: str, env_config_id: str,
mqtt_manager: MqttManager, report_interval: int = 5):
mqtt_manager: MqttManager, report_interval: int = 5,
topics: Optional[dict] = None):
"""
初始化集控客户端模拟器
......@@ -51,17 +52,15 @@ class ClientSimulator(BaseSimulator):
env_config_id: 环境配置 ID
mqtt_manager: MQTT 管理器
report_interval: 上报间隔(秒),集控客户端心跳频率更高
topics: 多主题前缀映射
"""
super().__init__(device_id, env_config_id, "client", mqtt_manager, report_interval)
super().__init__(device_id, env_config_id, "client", mqtt_manager,
report_interval, topics)
self._version = random.choice(self._VERSIONS)
self._heartbeat_count = 0
self._log_level = "info"
self._firmware_updating = False
def get_topic_prefix(self) -> str:
"""获取集控客户端主题前缀"""
return "device/client"
def build_register_payload(self) -> dict:
"""构建设备注册消息"""
return {
......@@ -112,6 +111,15 @@ class ClientSimulator(BaseSimulator):
command = payload.get("command", "")
logger.info(f"集控客户端收到指令: device_id={self.device_id}, command={command}")
# 记录接收到的平台下发指令(direction=subscribe)
command_topic = f"{self.get_topic_prefix()}/{self.device_id}/command"
self._notify_report(
command_topic,
payload,
direction="subscribe",
status="success"
)
response_topic = f"{self.get_topic_prefix()}/{self.device_id}/command_response"
if command == "remote_restart":
......
......@@ -38,7 +38,8 @@ class DoorSimulator(BaseSimulator):
"""
def __init__(self, device_id: str, env_config_id: str,
mqtt_manager: MqttManager, report_interval: int = 30):
mqtt_manager: MqttManager, report_interval: int = 30,
topics: Optional[dict] = None):
"""
初始化门口屏模拟器
......@@ -47,16 +48,14 @@ class DoorSimulator(BaseSimulator):
env_config_id: 环境配置 ID
mqtt_manager: MQTT 管理器
report_interval: 上报间隔(秒)
topics: 多主题前缀映射
"""
super().__init__(device_id, env_config_id, "door", mqtt_manager, report_interval)
super().__init__(device_id, env_config_id, "door", mqtt_manager,
report_interval, topics)
self._door_status = "closed"
self._battery_level = random.randint(60, 100)
self._call_active = False
def get_topic_prefix(self) -> str:
"""获取门口屏主题前缀"""
return "device/door"
def build_register_payload(self) -> dict:
"""构建设备注册消息"""
return {
......@@ -101,7 +100,15 @@ class DoorSimulator(BaseSimulator):
command = payload.get("command", "")
logger.info(f"门口屏收到指令: device_id={self.device_id}, command={command}")
# 记录接收到的平台下发指令(direction=subscribe)
command_topic = f"{self.get_topic_prefix()}/{self.device_id}/command"
self._notify_report(
command_topic,
payload,
direction="subscribe",
status="success"
)
response_topic = f"{self.get_topic_prefix()}/{self.device_id}/command_response"
if command == "unlock":
......
......@@ -52,18 +52,16 @@ class PaperlessSimulator(BaseSimulator):
]
def __init__(self, device_id: str, env_config_id: str,
mqtt_manager: MqttManager, report_interval: int = 30):
super().__init__(device_id, env_config_id, "paperless", mqtt_manager, report_interval)
mqtt_manager: MqttManager, report_interval: int = 30,
topics: Optional[dict] = None):
super().__init__(device_id, env_config_id, "paperless", mqtt_manager,
report_interval, topics)
self._meeting_status = "idle"
self._signed_in = False
self._file_sync_progress = 0
self._screen_sharing = False
self._current_agenda = ""
def get_topic_prefix(self) -> str:
"""获取无纸化设备主题前缀"""
return "device/paperless"
def build_register_payload(self) -> dict:
"""构建设备注册消息"""
return {
......@@ -106,6 +104,15 @@ class PaperlessSimulator(BaseSimulator):
command = payload.get("command", "")
logger.info(f"无纸化设备收到指令: device_id={self.device_id}, command={command}")
# 记录接收到的平台下发指令(direction=subscribe)
command_topic = f"{self.get_topic_prefix()}/{self.device_id}/command"
self._notify_report(
command_topic,
payload,
direction="subscribe",
status="success"
)
response_topic = f"{self.get_topic_prefix()}/{self.device_id}/command_response"
if command == "start_meeting":
......
......@@ -34,4 +34,7 @@ RUN mkdir -p /app/data/screenshots /app/data/reports \
EXPOSE 80
# 启动 uvicorn,监听 80 端口(同时提供 API + 前端静态文件)
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]
\ No newline at end of file
# --loop asyncio:禁用 uvloop。根因——uvloop(libuv) 与 Playwright sync API 的
# greenlet 线程桥接不兼容,在工作线程调用 sync_playwright().start() 会永久死锁
# (容器内多次复现)。强制默认 asyncio 事件循环即可正常执行用例。
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80", "--loop", "asyncio"]
\ No newline at end of file
......@@ -40,6 +40,9 @@ services:
dockerfile: ../deploy/Dockerfile
container_name: plat-auto-test-app
restart: unless-stopped
# 强制 asyncio 事件循环(禁用 uvloop),修复 Playwright sync API 死锁。
# 覆盖镜像 CMD,无需重建镜像即可生效。详见 Dockerfile 注释。
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80", "--loop", "asyncio"]
depends_on:
mysql:
condition: service_healthy
......
......@@ -107,6 +107,7 @@ export function manualReport(id: string, data: ManualReportRequest): Promise<{ m
/** 获取上报记录列表 */
export function listReportLogs(params?: {
deviceId?: string
direction?: string
skip?: number
limit?: number
startTime?: string
......
......@@ -11,6 +11,17 @@
<div class="log-header">
<h4>上报记录</h4>
<div class="log-actions">
<el-select
v-model="directionFilter"
placeholder="方向"
size="small"
style="width: 100px"
@change="onFilterChange"
>
<el-option label="全部" value="" />
<el-option label="上报" value="publish" />
<el-option label="接收" value="subscribe" />
</el-select>
<el-date-picker
v-model="timeRange"
type="datetimerange"
......@@ -95,6 +106,7 @@ const total = ref(0)
const currentPage = ref(1)
const pageSize = 20
const timeRange = ref<any>(null)
const directionFilter = ref('')
const cleaning = ref(false)
/** 加载上报记录 */
......@@ -106,6 +118,7 @@ async function loadLogs() {
limit: pageSize,
}
if (props.deviceId) params.deviceId = props.deviceId
if (directionFilter.value) params.direction = directionFilter.value
if (timeRange.value) {
params.startTime = timeRange.value[0]
params.endTime = timeRange.value[1]
......@@ -121,6 +134,12 @@ async function loadLogs() {
}
}
/** 方向筛选变更:回到第 1 页并重新加载 */
function onFilterChange() {
currentPage.value = 1
loadLogs()
}
/** 清理旧记录 */
async function handleCleanup() {
cleaning.value = true
......
......@@ -33,6 +33,7 @@ export interface EnvConfig {
username: string | null
password: string | null
topicPrefix: string
topics: Record<string, string> | null
useTls: boolean
clientIdPrefix: string
status: string
......@@ -49,6 +50,7 @@ export interface EnvConfigCreate {
username?: string | null
password?: string | null
topicPrefix?: string
topics?: Record<string, string> | null
useTls?: boolean
clientIdPrefix?: string
}
......@@ -61,6 +63,7 @@ export interface EnvConfigUpdate {
username?: string | null
password?: string | null
topicPrefix?: string
topics?: Record<string, string> | null
useTls?: boolean
clientIdPrefix?: string
}
......
<!--
组件名称:EnvConfig.vue
组件描述:环境配置管理页面 - MQTT Broker 连接配置 CRUD
组件描述:环境配置管理页面 - MQTT Broker 连接配置 CRUD,支持多主题配置
@author czj
@date 2026-07-29
......@@ -28,7 +28,22 @@
</el-tag>
</template>
</el-table-column>
<el-table-column prop="topicPrefix" label="主题前缀" min-width="140" />
<el-table-column label="主题配置" min-width="220">
<template #default="{ row }">
<div class="topic-tags">
<el-tag
v-for="(prefix, type) in normalizeTopics(row)"
:key="type"
size="small"
type="info"
class="topic-tag"
>
{{ deviceTypeLabel(type as string) }}: {{ prefix }}
</el-tag>
<span v-if="!hasTopics(row)" class="topic-empty">{{ row.topicPrefix || 'device' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="最后连接" width="170">
<template #default="{ row }">
{{ row.lastConnectedAt ? formatTime(row.lastConnectedAt) : '-' }}
......@@ -49,7 +64,7 @@
</el-card>
<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑环境配置' : '新增环境配置'" width="600px">
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑环境配置' : '新增环境配置'" width="640px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" label-position="top">
<el-row :gutter="20">
<el-col :span="12">
......@@ -58,8 +73,8 @@
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="主题前缀" prop="topicPrefix">
<el-input v-model="form.topicPrefix" placeholder="如:device" />
<el-form-item label="主题基础前缀" prop="topicPrefix">
<el-input v-model="form.topicPrefix" placeholder="如:device" @input="onBasePrefixChange" />
</el-form-item>
</el-col>
</el-row>
......@@ -87,7 +102,27 @@
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<!-- 多主题配置 -->
<el-divider content-position="left">主题配置(按设备类型)</el-divider>
<div class="topics-editor">
<div class="topic-item" v-for="dt in deviceTypes" :key="dt">
<span class="topic-label">{{ deviceTypeLabel(dt) }}</span>
<el-input
v-model="topicValues[dt]"
:placeholder="`${form.topicPrefix || 'device'}/${dt}`"
size="default"
class="topic-input"
>
<template #append>/{{ dt }}</template>
</el-input>
</div>
<el-tag type="info" size="small" effect="plain" class="topic-tip">
修改基础前缀可批量重置各设备主题;也可单独自定义每个设备类型的主题路径
</el-tag>
</div>
<el-row :gutter="20" style="margin-top: 12px">
<el-col :span="12">
<el-form-item label="客户端 ID 前缀">
<el-input v-model="form.clientIdPrefix" placeholder="如:sim" />
......@@ -121,6 +156,20 @@ import {
} from '@/api/deviceSim'
import type { EnvConfig, EnvConfigCreate } from '@/types/device'
// 支持的设备类型
const deviceTypes = ['door', 'paperless', 'central', 'client'] as const
const deviceTypeLabels: Record<string, string> = {
door: '门口屏',
paperless: '无纸化',
central: '中控设备',
client: '集控客户端',
}
/** 设备类型中文名 */
function deviceTypeLabel(type: string): string {
return deviceTypeLabels[type] || type
}
const loading = ref(false)
const configs = ref<EnvConfig[]>([])
const dialogVisible = ref(false)
......@@ -129,6 +178,16 @@ const saving = ref(false)
const editId = ref('')
const formRef = ref()
/** 根据基础前缀生成默认多主题映射 */
function buildDefaultTopics(prefix?: string | null): Record<string, string> {
const p = prefix || 'device'
const result: Record<string, string> = {}
for (const dt of deviceTypes) {
result[dt] = `${p}/${dt}`
}
return result
}
const defaultForm = (): EnvConfigCreate => ({
name: '',
brokerHost: '',
......@@ -136,17 +195,41 @@ const defaultForm = (): EnvConfigCreate => ({
username: '',
password: '',
topicPrefix: 'device',
topics: buildDefaultTopics('device'),
useTls: false,
clientIdPrefix: 'sim',
})
const form = reactive<EnvConfigCreate>(defaultForm())
// 主题编辑对象(保证非空,用于模板双向绑定)
const topicValues = reactive<Record<string, string>>(buildDefaultTopics('device'))
const rules = {
name: [{ required: true, message: '请输入配置名称', trigger: 'blur' }],
brokerHost: [{ required: true, message: '请输入 Broker 地址', trigger: 'blur' }],
brokerPort: [{ required: true, message: '请输入端口号', trigger: 'blur' }],
topicPrefix: [{ required: true, message: '请输入主题前缀', trigger: 'blur' }],
topicPrefix: [{ required: true, message: '请输入主题基础前缀', trigger: 'blur' }],
}
/** 基础前缀变更时,批量重置各设备主题(仅当用户未自定义时) */
function onBasePrefixChange() {
const fresh = buildDefaultTopics(form.topicPrefix)
Object.keys(topicValues).forEach((k) => delete topicValues[k])
Object.assign(topicValues, fresh)
}
/** 规范化主题配置:兼容旧数据(topics 为空时从 topicPrefix 生成) */
function normalizeTopics(row: EnvConfig): Record<string, string> {
if (row.topics && Object.keys(row.topics).length > 0) {
return row.topics
}
return buildDefaultTopics(row.topicPrefix || 'device')
}
/** 是否已有多主题配置 */
function hasTopics(row: EnvConfig): boolean {
return !!(row.topics && Object.keys(row.topics).length > 0)
}
/** 加载配置列表 */
......@@ -167,6 +250,8 @@ function openCreateDialog() {
isEdit.value = false
editId.value = ''
Object.assign(form, defaultForm())
Object.keys(topicValues).forEach((k) => delete topicValues[k])
Object.assign(topicValues, buildDefaultTopics('device'))
dialogVisible.value = true
}
......@@ -174,6 +259,7 @@ function openCreateDialog() {
function openEditDialog(row: EnvConfig) {
isEdit.value = true
editId.value = row.id
const topics = normalizeTopics(row)
Object.assign(form, {
name: row.name,
brokerHost: row.brokerHost,
......@@ -181,9 +267,12 @@ function openEditDialog(row: EnvConfig) {
username: row.username,
password: '',
topicPrefix: row.topicPrefix,
topics,
useTls: row.useTls,
clientIdPrefix: row.clientIdPrefix,
})
Object.keys(topicValues).forEach((k) => delete topicValues[k])
Object.assign(topicValues, topics)
dialogVisible.value = true
}
......@@ -192,6 +281,9 @@ async function handleSave() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
// 将主题编辑对象回写到 form
form.topics = { ...topicValues }
saving.value = true
try {
if (isEdit.value) {
......@@ -275,4 +367,51 @@ onMounted(() => {
color: #303133;
margin: 0;
}
.topic-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.topic-tag {
margin: 0;
}
.topic-empty {
color: #909399;
font-size: 13px;
}
.topics-editor {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
background: #fafafa;
border-radius: 6px;
border: 1px solid #ebeef5;
}
.topic-item {
display: flex;
align-items: center;
gap: 10px;
}
.topic-label {
width: 80px;
flex-shrink: 0;
font-size: 13px;
color: #606266;
}
.topic-input {
flex: 1;
}
.topic-tip {
margin-top: 4px;
align-self: flex-start;
}
</style>
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论