Sfoglia il codice sorgente

feat: 新增多组业务页面并完善相关功能

1. 新增端口状态、面板状态、告警显示、OTA升级、环境传感器、跳线器状态页面
2. 安装echarts依赖用于图表展示
3. 新增侧边导航菜单对应的新页面入口
4. 扩展API服务,新增MQTT广播、DTU OTA、端口/面板/传感器相关接口
5. 在DTU配置组件中添加广播控制功能
wenhongquan 8 ore fa
parent
commit
a0a5a58a4b

+ 681 - 2
backend/app.py

@@ -268,6 +268,13 @@ def mqtt_status_handler(status):
             control_topic = build_dtu_topic(dtu_config['customer_id'], 'dtu', dtu_config['dtu_id'], 'control')
             mqtt_client.subscribe(control_topic)
             logger.info(f"已订阅控制主题: {control_topic}")
+
+            # 订阅广播主题 (DTU发现 7.14 和批量配置 7.15)
+            discover_topic = build_dtu_topic('broadcast', 'dtu', 'discover')
+            config_topic = build_dtu_topic('broadcast', 'dtu', 'config')
+            mqtt_client.subscribe(discover_topic)
+            mqtt_client.subscribe(config_topic)
+            logger.info(f"已订阅广播主题: {discover_topic}, {config_topic}")
     except Exception as e:
         logger.error(f"处理MQTT状态时出错: {str(e)}")
 
@@ -329,6 +336,74 @@ def dtu_register():
         return False
 
 
+def handle_broadcast_discover(payload):
+    """处理广播发现消息 (7.14)
+
+    服务器通过广播主题发送DISCOVER消息,
+    DTU收到后应立即回应REGISTER消息进行注册
+    """
+    try:
+        logger.info(f"收到广播发现消息: {payload}")
+
+        # 检查消息是否是有效的DISCOVER类型
+        if isinstance(payload, dict):
+            msg_type = payload.get('type', '')
+            if msg_type == 'DISCOVER':
+                # 立即响应注册消息
+                logger.info("收到DISCOVER广播,响应注册消息")
+                dtu_register()
+
+                # 可选:也发送一次状态消息
+                socketio.sleep(0.5)
+                dtu_publish_status()
+    except Exception as e:
+        logger.error(f"处理广播发现消息失败: {str(e)}")
+
+
+def handle_broadcast_config(payload):
+    """处理广播批量配置消息 (7.15)
+
+    服务器通过广播主题发送CONFIG消息,
+    用于批量配置所有在线DTU
+    """
+    try:
+        logger.info(f"收到广播配置消息: {payload}")
+
+        if isinstance(payload, dict):
+            msg_type = payload.get('type', '')
+            if msg_type == 'CONFIG':
+                config_data = payload.get('payload', {})
+
+                # 处理批量配置
+                # 这里可以处理各种配置项,如:
+                # - 上报间隔配置
+                # - 告警阈值配置
+                # - 网络配置等
+                logger.info(f"应用批量配置: {config_data}")
+
+                # 可以在这里添加配置应用逻辑
+                # 例如:更新本地配置、发送确认等
+
+                # 发送配置确认响应
+                response_topic = build_dtu_topic(
+                    dtu_config['customer_id'], 'dtu', dtu_config['dtu_id'], 'status'
+                )
+                response_payload = {
+                    'msg_id': f"cfg_ack_{int(time.time() * 1000)}",
+                    'timestamp': int(time.time() * 1000),
+                    'dtu_id': dtu_config['dtu_id'],
+                    'type': 'CONFIG_ACK',
+                    'payload': {
+                        'config_received': True,
+                        'applied_settings': config_data
+                    }
+                }
+                mqtt_client.publish(response_topic, json.dumps(response_payload))
+                logger.info("配置确认响应已发送")
+    except Exception as e:
+        logger.error(f"处理广播配置消息失败: {str(e)}")
+
+
 def dtu_publish_status(force=False):
     """发送DTU状态/心跳消息"""
     if not mqtt_client.get_status() or not dtu_config.get('enabled'):
@@ -395,6 +470,18 @@ def dtu_publish_event(panel_id, port_id, event_type, jumper_uid, previous_jumper
 
         if success:
             logger.info(f"端口事件已发送: {panel_id}:{port_id} - {event_type}")
+            # 记录到事件历史
+            port_event_history.append({
+                'timestamp': int(time.time() * 1000),
+                'panel_id': panel_id,
+                'port_id': port_id,
+                'event_type': event_type,
+                'jumper_uid': jumper_uid,
+                'previous_jumper_uid': previous_jumper_uid
+            })
+            # 限制历史记录数量
+            if len(port_event_history) > 1000:
+                port_event_history[:] = port_event_history[-1000:]
 
         return success
     except Exception as e:
@@ -431,6 +518,19 @@ def dtu_publish_alarm(panel_id, port_id, alarm_type, expected_jumper_uid, actual
 
         if success:
             logger.warning(f"非法告警已发送: {panel_id}:{port_id} - {alarm_type}")
+            # 记录到事件历史
+            port_event_history.append({
+                'timestamp': int(time.time() * 1000),
+                'panel_id': panel_id,
+                'port_id': port_id,
+                'event_type': 'ALARM',
+                'alarm_type': alarm_type,
+                'expected_jumper_uid': expected_jumper_uid,
+                'actual_jumper_uid': actual_jumper_uid
+            })
+            # 限制历史记录数量
+            if len(port_event_history) > 1000:
+                port_event_history[:] = port_event_history[-1000:]
 
         return success
     except Exception as e:
@@ -519,6 +619,102 @@ def dtu_handle_control(topic, payload):
             # 重启DTU(模拟)
             response_payload['payload']['result'] = {'message': 'Reboot command received'}
 
+        elif command == 'READ_PANEL_STATUS':
+            # 读取面板状态
+            # 读取面板的多个寄存器获取状态信息
+            # 寄存器地址定义: 0x0000=运行状态, 0x0001=LED控制, 0x0002-0x0009=天线卡状态
+            device_address = 1
+            for panel_id, cfg in panel_config.items():
+                if panel_id == target:
+                    device_address = cfg.get('address', 1)
+                    break
+
+            # 读取面板状态寄存器 (地址0x0000开始,读取10个寄存器)
+            status_result = modbus_client.read_holding_registers(device_address, 0x0000, 10)
+
+            # 解析状态
+            registers = status_result.get('registers', [])
+            panel_status = {
+                'device_address': device_address,
+                'run_status': registers[0] if len(registers) > 0 else None,
+                'led_control': registers[1] if len(registers) > 1 else None,
+                'antenna_status': registers[2:10] if len(registers) >= 10 else [],
+                'raw_registers': registers
+            }
+
+            response_payload['payload']['success'] = 'error' not in status_result
+            response_payload['payload']['result'] = panel_status
+
+        elif command == 'QUERY_JUMPER_STATUS':
+            # 查询跳线器状态
+            # 跳线器状态寄存器: 0x0100=跳线连接状态, 0x0101=跳线ID高字节, 0x0102=跳线ID低字节
+            device_address = 1
+            for panel_id, cfg in panel_config.items():
+                if panel_id == target:
+                    device_address = cfg.get('address', 1)
+                    break
+
+            # 读取跳线器状态寄存器 (地址0x0100开始,读取3个寄存器)
+            jumper_result = modbus_client.read_holding_registers(device_address, 0x0100, 3)
+
+            registers = jumper_result.get('registers', [])
+            jumper_status = {
+                'device_address': device_address,
+                'connected': bool(registers[0]) if len(registers) > 0 else False,
+                'jumper_id_high': registers[1] if len(registers) > 1 else 0,
+                'jumper_id_low': registers[2] if len(registers) > 2 else 0,
+                'jumper_id': (registers[1] << 16 | registers[2]) if len(registers) > 2 else 0,
+                'raw_registers': registers
+            }
+
+            response_payload['payload']['success'] = 'error' not in jumper_result
+            response_payload['payload']['result'] = jumper_status
+
+        elif command == 'QUERY_ENV_SENSOR':
+            # 查询环境传感器
+            # 环境传感器寄存器: 0x0200=温度, 0x0201=湿度, 0x0202=光照, 0x0203=烟雾
+            device_address = 1
+            for panel_id, cfg in panel_config.items():
+                if panel_id == target:
+                    device_address = cfg.get('address', 1)
+                    break
+
+            # 读取环境传感器寄存器 (地址0x0200开始,读取4个寄存器)
+            sensor_result = modbus_client.read_holding_registers(device_address, 0x0200, 4)
+
+            registers = sensor_result.get('registers', [])
+            # 温度和湿度为有符号16位整数,需要除以100转换为实际值
+            env_sensor = {
+                'device_address': device_address,
+                'temperature': registers[0] / 100.0 if len(registers) > 0 else None,
+                'humidity': registers[1] / 100.0 if len(registers) > 1 else None,
+                'light_level': registers[2] if len(registers) > 2 else None,
+                'smoke_detected': bool(registers[3]) if len(registers) > 3 else False,
+                'raw_registers': registers
+            }
+
+            response_payload['payload']['success'] = 'error' not in sensor_result
+            response_payload['payload']['result'] = env_sensor
+
+        elif command == 'OTA_CANCEL':
+            # 取消OTA升级
+            # 向OTA控制寄存器写入取消命令 (0x0000=取消)
+            device_address = 1
+            for panel_id, cfg in panel_config.items():
+                if panel_id == target:
+                    device_address = cfg.get('address', 1)
+                    break
+
+            # OTA控制寄存器地址: 0x0300
+            # 值: 0x0000=取消升级
+            cancel_result = modbus_client.write_single_register(device_address, 0x0300, 0x0000)
+
+            response_payload['payload']['success'] = 'error' not in cancel_result
+            response_payload['payload']['result'] = {
+                'message': 'OTA upgrade cancelled' if 'error' not in cancel_result else 'Failed to cancel OTA',
+                'raw_result': cancel_result
+            }
+
         # 发送响应
         response_topic = build_dtu_topic(dtu_config['customer_id'], 'dtu', dtu_config['dtu_id'], 'response')
         mqtt_client.publish(response_topic, json.dumps(response_payload))
@@ -574,6 +770,33 @@ def mqtt_data_handler_extended(data):
         if topic == expected_control_topic:
             dtu_handle_control(topic, payload)
 
+        # 检查是否是状态主题,用于处理OTA状态上报
+        expected_status_topic = build_dtu_topic(dtu_config['customer_id'], 'dtu', dtu_config['dtu_id'], 'status')
+        if topic == expected_status_topic and isinstance(payload, dict):
+            if 'ota_status' in payload or 'firmware_version' in payload:
+                handle_ota_status(dtu_config['dtu_id'], payload)
+
+        # 检查是否是环境传感器数据主题
+        sensor_topic = build_dtu_topic(dtu_config['customer_id'], 'sensor', dtu_config['dtu_id'], 'data')
+        if topic == sensor_topic and isinstance(payload, dict):
+            if 'temperature' in payload or 'humidity' in payload:
+                update_env_sensor_data(
+                    payload.get('temperature'),
+                    payload.get('humidity'),
+                    payload.get('dtu_temperature'),
+                    payload.get('sensor_update_time')
+                )
+
+        # 检查是否是广播主题 - DTU发现 (7.14)
+        discover_topic = build_dtu_topic('broadcast', 'dtu', 'discover')
+        if topic == discover_topic:
+            handle_broadcast_discover(payload)
+
+        # 检查是否是广播主题 - 批量配置 (7.15)
+        config_topic = build_dtu_topic('broadcast', 'dtu', 'config')
+        if topic == config_topic:
+            handle_broadcast_config(payload)
+
         # 转发到串口(如果启用)
         if forward_mqtt_to_serial and serial_client.get_status():
             success, msg = serial_client.send_data(payload_str)
@@ -1138,19 +1361,84 @@ def mqtt_subscribe():
     """订阅MQTT主题"""
     data = request.json
     topics = data.get('topics', [])
-    
+
     if not topics:
         return jsonify({
             'success': False,
             'message': '请至少订阅一个主题'
         })
-    
+
     success, message = mqtt_client.subscribe(topics)
     return jsonify({
         'success': success,
         'message': message
     })
 
+
+@app.route('/api/mqtt/broadcast_discover', methods=['POST'])
+def mqtt_broadcast_discover():
+    """发送DTU发现广播 (7.14)
+
+    服务器通过广播主题发送DISCOVER消息,
+    触发所有在线DTU进行注册响应
+    """
+    data = request.json or {}
+    customer_id = data.get('customer_id', dtu_config.get('customer_id', 'default'))
+
+    discover_topic = build_dtu_topic('broadcast', 'dtu', 'discover')
+    payload = {
+        'msg_id': f"disc_{int(time.time() * 1000)}",
+        'timestamp': int(time.time() * 1000),
+        'type': 'DISCOVER',
+        'payload': {
+            'customer_id': customer_id,
+            'action': 'register'
+        }
+    }
+
+    success, message = mqtt_client.publish(discover_topic, json.dumps(payload))
+    logger.info(f"发送DTU发现广播: topic={discover_topic}, payload={payload}")
+
+    return jsonify({
+        'success': success,
+        'message': 'DTU发现广播已发送' if success else message,
+        'topic': discover_topic
+    })
+
+
+@app.route('/api/mqtt/broadcast_config', methods=['POST'])
+def mqtt_broadcast_config():
+    """发送DTU批量配置广播 (7.15)
+
+    服务器通过广播主题发送CONFIG消息,
+    用于批量配置所有在线DTU
+    """
+    data = request.json or {}
+    customer_id = data.get('customer_id', dtu_config.get('customer_id', 'default'))
+    config_settings = data.get('config', {})
+
+    config_topic = build_dtu_topic('broadcast', 'dtu', 'config')
+    payload = {
+        'msg_id': f"cfg_{int(time.time() * 1000)}",
+        'timestamp': int(time.time() * 1000),
+        'type': 'CONFIG',
+        'payload': {
+            'customer_id': customer_id,
+            'config': config_settings
+        }
+    }
+
+    success, message = mqtt_client.publish(config_topic, json.dumps(payload))
+    logger.info(f"发送DTU批量配置广播: topic={config_topic}, payload={payload}")
+
+    return jsonify({
+        'success': success,
+        'message': 'DTU批量配置广播已发送' if success else message,
+        'topic': config_topic,
+        'config': config_settings
+    })
+
+
 @app.route('/api/data/serial', methods=['GET'])
 def get_serial_data():
     """获取串口数据"""
@@ -1708,6 +1996,72 @@ def confirm_devices():
         return jsonify({'success': False, 'message': str(e)}), 500
 
 
+# 端口状态追踪 - 用于存储每个端口的事件状态
+port_event_history = []  # 存储最近的端口事件
+
+
+@app.route('/api/port/status', methods=['GET'])
+def get_port_status():
+    """获取所有端口状态"""
+    return jsonify({'success': True, 'port_state': port_state})
+
+
+@app.route('/api/port/events', methods=['GET'])
+def get_port_events():
+    """获取端口事件历史"""
+    limit = request.args.get('limit', 100, type=int)
+    return jsonify({
+        'success': True,
+        'events': port_event_history[-limit:]
+    })
+
+
+@app.route('/api/port/clear_events', methods=['POST'])
+def clear_port_events():
+    """清除端口事件历史"""
+    global port_event_history
+    port_event_history = []
+    return jsonify({'success': True, 'message': '事件历史已清除'})
+
+
+@app.route('/api/panel/status', methods=['GET'])
+def get_panel_status():
+    """获取所有面板状态"""
+    # 构建面板状态数据
+    panel_status = {}
+    for panel_id, ports in port_state.items():
+        port_count = len(ports)
+        alarm_count = sum(1 for p in ports.values() if p.get('alarm_count', 0) > 0)
+        connected_count = sum(1 for p in ports.values() if p.get('last_uid'))
+        panel_status[panel_id] = {
+            'panel_id': panel_id,
+            'port_count': port_count,
+            'connected_count': connected_count,
+            'alarm_count': alarm_count,
+            'status': 'online' if connected_count > 0 else 'offline'
+        }
+    # 添加面板配置信息
+    for panel_id, cfg in panel_config.items():
+        if panel_id in panel_status:
+            panel_status[panel_id].update({
+                'address': cfg.get('address'),
+                'position': cfg.get('position'),
+                'panel_uid': cfg.get('panel_uid')
+            })
+        else:
+            panel_status[panel_id] = {
+                'panel_id': panel_id,
+                'address': cfg.get('address'),
+                'position': cfg.get('position'),
+                'panel_uid': cfg.get('panel_uid'),
+                'port_count': 0,
+                'connected_count': 0,
+                'alarm_count': 0,
+                'status': 'offline'
+            }
+    return jsonify({'success': True, 'panels': panel_status})
+
+
 # LED 状态追踪 (内存中跟踪每个灯的最后设置状态)
 led_states = {i: 0 for i in range(1, 25)}  # 0=off, 1=red, 2=green, 3=blue
 
@@ -1877,6 +2231,331 @@ def get_dtu_status():
         return jsonify({'success': False, 'message': str(e)}), 500
 
 
+# OTA状态存储
+ota_status = {
+    'status': 'IDLE',  # IDLE, DOWNLOADING, VERIFYING, FLASHING, SUCCESS, FAILED
+    'progress': 0,
+    'firmware_version': None,
+    'target_version': None,
+    'error_code': None,
+    'error_message': None,
+    'last_update': None
+}
+
+
+@app.route('/api/dtu/ota_status', methods=['GET'])
+def get_ota_status():
+    """获取OTA升级状态"""
+    try:
+        return jsonify({
+            'success': True,
+            'data': {
+                'current_firmware': dtu_config.get('firmware_version', 'v1.0.0'),
+                'ota_status': ota_status.get('status', 'IDLE'),
+                'ota_progress': ota_status.get('progress', 0),
+                'target_version': ota_status.get('target_version'),
+                'error_code': ota_status.get('error_code'),
+                'error_message': ota_status.get('error_message'),
+                'last_update': ota_status.get('last_update')
+            }
+        })
+    except Exception as e:
+        logger.error(f"获取OTA状态失败: {str(e)}")
+        return jsonify({'success': False, 'message': str(e)}), 500
+
+
+@app.route('/api/dtu/ota_upgrade', methods=['POST'])
+def trigger_ota_upgrade():
+    """触发OTA升级"""
+    try:
+        if not mqtt_status:
+            return jsonify({'success': False, 'message': 'MQTT未连接'}), 400
+
+        data = request.json
+        if not data:
+            return jsonify({'success': False, 'message': '请求体不能为空'}), 400
+
+        # 验证必填字段
+        required_fields = ['firmware_url', 'firmware_version', 'file_size', 'checksum', 'checksum_type']
+        missing = [f for f in required_fields if not data.get(f)]
+        if missing:
+            return jsonify({'success': False, 'message': f'缺少必填字段: {", ".join(missing)}'}), 400
+
+        # 构建OTA升级命令
+        msg_id = f"ota_{int(time.time() * 1000)}"
+        control_topic = build_dtu_topic(
+            dtu_config['customer_id'],
+            'dtu',
+            dtu_config['dtu_id'],
+            'control'
+        )
+
+        payload = {
+            'msg_id': msg_id,
+            'timestamp': int(time.time() * 1000),
+            'dtu_id': dtu_config['dtu_id'],
+            'type': 'CONTROL',
+            'payload': {
+                'command': 'OTA_UPGRADE',
+                'target': 'dtu',
+                'params': {
+                    'firmware_url': data['firmware_url'],
+                    'firmware_version': data['firmware_version'],
+                    'file_size': data['file_size'],
+                    'checksum': data['checksum'],
+                    'checksum_type': data['checksum_type'],
+                    'force_upgrade': data.get('force_upgrade', False)
+                }
+            }
+        }
+
+        # 发布OTA升级命令
+        mqtt_client.publish(control_topic, json.dumps(payload))
+        logger.info(f"OTA升级命令已发送: {data['firmware_version']}")
+
+        # 更新OTA状态
+        ota_status['status'] = 'DOWNLOADING'
+        ota_status['progress'] = 0
+        ota_status['target_version'] = data['firmware_version']
+        ota_status['error_code'] = None
+        ota_status['error_message'] = None
+        ota_status['last_update'] = time.strftime('%Y-%m-%d %H:%M:%S')
+
+        return jsonify({
+            'success': True,
+            'message': 'OTA升级命令已发送',
+            'data': {
+                'msg_id': msg_id,
+                'target_version': data['firmware_version'],
+                'ota_status': 'DOWNLOADING'
+            }
+        })
+    except Exception as e:
+        logger.error(f"触发OTA升级失败: {str(e)}")
+        return jsonify({'success': False, 'message': str(e)}), 500
+
+
+# 处理OTA状态上报
+def handle_ota_status(dtu_id, payload):
+    """处理OTA状态上报"""
+    ota_status['status'] = payload.get('ota_status', 'IDLE')
+    ota_status['progress'] = payload.get('ota_progress', 0)
+    ota_status['firmware_version'] = payload.get('firmware_version')
+    ota_status['last_update'] = time.strftime('%Y-%m-%d %H:%M:%S')
+
+    error_code = payload.get('error_code')
+    if error_code and error_code != 0:
+        ota_status['error_code'] = error_code
+        ota_status['error_message'] = payload.get('error_message', get_ota_error_message(error_code))
+        ota_status['status'] = 'FAILED'
+    elif ota_status['status'] == 'SUCCESS':
+        ota_status['error_code'] = None
+        ota_status['error_message'] = None
+        # 更新DTU配置中的固件版本
+        dtu_config['firmware_version'] = ota_status['firmware_version']
+
+    logger.info(f"OTA状态更新: {ota_status['status']}, 进度: {ota_status['progress']}%")
+
+
+def get_ota_error_message(error_code):
+    """获取OTA错误码对应的错误信息"""
+    error_messages = {
+        1021: '已是目标版本',
+        1022: '校验失败',
+        1023: '下载失败',
+        1024: '写入失败',
+        1025: '存储空间不足'
+    }
+    return error_messages.get(error_code, f'未知错误码: {error_code}')
+
+
+# ==================== 环境传感器 API ====================
+
+# 环境传感器数据存储
+env_sensor_data = {
+    'temperature': None,          # 环境温度 (℃)
+    'humidity': None,             # 环境湿度 (%)
+    'dtu_temperature': None,      # DTU主板温度 (℃)
+    'update_time': None,          # 数据更新时间
+    'sensor_update_time': None,   # 传感器更新时间
+    'connected': False            # 传感器连接状态
+}
+
+# 环境传感器告警阈值
+env_sensor_threshold = {
+    'temp_high': 45.0,            # 环境温度上限 (℃)
+    'temp_low': -10.0,            # 环境温度下限 (℃)
+    'humidity_high': 80.0,        # 环境湿度上限 (%)
+    'humidity_low': 20.0,         # 环境湿度下限 (%)
+    'dtu_temp_high': 70.0         # DTU主板温度上限 (℃)
+}
+
+# 环境传感器历史数据 (保留最近1000条)
+env_sensor_history = []
+
+
+@app.route('/api/sensor/env', methods=['GET'])
+def get_env_sensor_data():
+    """获取环境传感器当前数据"""
+    return jsonify({
+        'success': True,
+        'data': env_sensor_data
+    })
+
+
+@app.route('/api/sensor/threshold', methods=['GET', 'POST'])
+def handle_env_sensor_threshold():
+    """获取或设置环境传感器告警阈值"""
+    if request.method == 'GET':
+        return jsonify({
+            'success': True,
+            'data': env_sensor_threshold
+        })
+    else:
+        try:
+            data = request.get_json()
+            for key in env_sensor_threshold.keys():
+                if key in data:
+                    env_sensor_threshold[key] = float(data[key])
+            logger.info(f"更新环境传感器告警阈值: {env_sensor_threshold}")
+            return jsonify({
+                'success': True,
+                'message': '阈值设置成功',
+                'data': env_sensor_threshold
+            })
+        except Exception as e:
+            logger.error(f"设置告警阈值失败: {str(e)}")
+            return jsonify({'success': False, 'message': str(e)}), 400
+
+
+@app.route('/api/sensor/history', methods=['GET'])
+def get_env_sensor_history():
+    """获取环境传感器历史数据"""
+    try:
+        import datetime
+        # 获取查询参数
+        range_param = request.args.get('range', '24h')
+        start_time = request.args.get('start_time')
+        end_time = request.args.get('end_time')
+        limit = request.args.get('limit', 100, type=int)
+
+        filtered_history = env_sensor_history
+
+        # 解析range参数
+        if not start_time and not end_time:
+            now = datetime.datetime.now()
+            if range_param == '1h':
+                start_time = (now - datetime.timedelta(hours=1)).strftime('%Y-%m-%d %H:%M:%S')
+            elif range_param == '6h':
+                start_time = (now - datetime.timedelta(hours=6)).strftime('%Y-%m-%d %H:%M:%S')
+            elif range_param == '24h':
+                start_time = (now - datetime.timedelta(hours=24)).strftime('%Y-%m-%d %H:%M:%S')
+            elif range_param == '7d':
+                start_time = (now - datetime.timedelta(days=7)).strftime('%Y-%m-%d %H:%M:%S')
+            elif range_param == '30d':
+                start_time = (now - datetime.timedelta(days=30)).strftime('%Y-%m-%d %H:%M:%S')
+
+        # 按时间过滤
+        if start_time:
+            filtered_history = [h for h in filtered_history if h.get('update_time') >= start_time]
+        if end_time:
+            filtered_history = [h for h in filtered_history if h.get('update_time') <= end_time]
+
+        # 按时间排序 (新到旧)
+        filtered_history = sorted(filtered_history, key=lambda x: x.get('update_time', ''), reverse=True)
+
+        # 限制返回数量
+        filtered_history = filtered_history[:limit]
+
+        return jsonify({
+            'success': True,
+            'data': filtered_history,
+            'total': len(filtered_history)
+        })
+    except Exception as e:
+        logger.error(f"获取历史数据失败: {str(e)}")
+        return jsonify({'success': False, 'message': str(e)}), 500
+
+
+def update_env_sensor_data(temperature, humidity, dtu_temperature, sensor_update_time):
+    """更新环境传感器数据 (由MQTT消息触发)"""
+    import datetime
+    now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+
+    env_sensor_data['temperature'] = temperature
+    env_sensor_data['humidity'] = humidity
+    env_sensor_data['dtu_temperature'] = dtu_temperature
+    env_sensor_data['sensor_update_time'] = sensor_update_time
+    env_sensor_data['update_time'] = now
+    env_sensor_data['connected'] = True
+
+    # 添加到历史记录
+    history_record = {
+        'temperature': temperature,
+        'humidity': humidity,
+        'dtu_temperature': dtu_temperature,
+        'sensor_update_time': sensor_update_time,
+        'update_time': now
+    }
+    env_sensor_history.append(history_record)
+
+    # 保留最近1000条
+    if len(env_sensor_history) > 1000:
+        env_sensor_history[:] = env_sensor_history[-1000:]
+
+    # 检查告警
+    check_env_sensor_alarms(temperature, humidity, dtu_temperature)
+
+    logger.info(f"环境传感器数据更新: 温度={temperature}℃, 湿度={humidity}%, DTU温度={dtu_temperature}℃")
+
+
+def check_env_sensor_alarms(temperature, humidity, dtu_temperature):
+    """检查环境传感器告警"""
+    alarms = []
+
+    if temperature is not None:
+        if temperature > env_sensor_threshold['temp_high']:
+            alarms.append({
+                'type': 'temperature_high',
+                'message': f'环境温度过高: {temperature}℃ (阈值: {env_sensor_threshold["temp_high"]}℃)',
+                'level': 'warning'
+            })
+        elif temperature < env_sensor_threshold['temp_low']:
+            alarms.append({
+                'type': 'temperature_low',
+                'message': f'环境温度过低: {temperature}℃ (阈值: {env_sensor_threshold["temp_low"]}℃)',
+                'level': 'warning'
+            })
+
+    if humidity is not None:
+        if humidity > env_sensor_threshold['humidity_high']:
+            alarms.append({
+                'type': 'humidity_high',
+                'message': f'环境湿度过高: {humidity}% (阈值: {env_sensor_threshold["humidity_high"]}%)',
+                'level': 'warning'
+            })
+        elif humidity < env_sensor_threshold['humidity_low']:
+            alarms.append({
+                'type': 'humidity_low',
+                'message': f'环境湿度过低: {humidity}% (阈值: {env_sensor_threshold["humidity_low"]}%)',
+                'level': 'warning'
+            })
+
+    if dtu_temperature is not None:
+        if dtu_temperature > env_sensor_threshold['dtu_temp_high']:
+            alarms.append({
+                'type': 'dtu_temp_high',
+                'message': f'DTU主板温度过高: {dtu_temperature}℃ (阈值: {env_sensor_threshold["dtu_temp_high"]}℃)',
+                'level': 'critical'
+            })
+
+    # 发送告警通知
+    for alarm in alarms:
+        logger.warning(f"环境传感器告警: {alarm['message']}")
+        # 可以通过WebSocket发送告警
+        socketio.emit('env_sensor_alarm', alarm)
+
+
 if __name__ == '__main__':
     try:
         # 启动前的初始化工作

+ 1 - 0
frontend/package.json

@@ -12,6 +12,7 @@
     "ant-design-vue": "^4.2.6",
     "axios": "^1.6.2",
     "core-js": "^3.8.3",
+    "echarts": "^6.1.0",
     "pinia": "^2.1.6",
     "socket.io-client": "^4.7.2",
     "vue": "^3.2.13",

+ 18 - 0
frontend/src/App.vue

@@ -47,6 +47,24 @@
             <a-menu-item key="/led-debug" icon="<bulb />">
               <router-link to="/led-debug">LED调试</router-link>
             </a-menu-item>
+            <a-menu-item key="/port-status" icon="<api />">
+              <router-link to="/port-status">端口状态</router-link>
+            </a-menu-item>
+            <a-menu-item key="/panel-status" icon="<appstore />">
+              <router-link to="/panel-status">面板状态</router-link>
+            </a-menu-item>
+            <a-menu-item key="/alarm-status" icon="<alert />">
+              <router-link to="/alarm-status">告警显示</router-link>
+            </a-menu-item>
+            <a-menu-item key="/env-sensor" icon="<dashboard />">
+              <router-link to="/env-sensor">环境传感器</router-link>
+            </a-menu-item>
+            <a-menu-item key="/jumper-status" icon="<link-outlined />">
+              <router-link to="/jumper-status">跳线器状态</router-link>
+            </a-menu-item>
+            <a-menu-item key="/ota-upgrade" icon="<cloud-upload-outlined />">
+              <router-link to="/ota-upgrade">OTA升级</router-link>
+            </a-menu-item>
             <a-menu-item key="/network-config" icon="<wifi />">
               <router-link to="/network-config">网络配置</router-link>
             </a-menu-item>

+ 49 - 6
frontend/src/api/apiService.js

@@ -33,18 +33,24 @@ const apiService = {
   mqtt: {
     // 连接MQTT
     connect: (config) => apiClient.post('/mqtt/connect', config),
-    
+
     // 断开MQTT
     disconnect: () => apiClient.post('/mqtt/disconnect'),
-    
+
     // 获取MQTT状态
     getStatus: () => apiClient.get('/mqtt/status'),
-    
+
     // 发布消息
     publish: (topic, message) => apiClient.post('/mqtt/publish', { topic, message }),
-    
+
     // 订阅主题
-    subscribe: (topics) => apiClient.post('/mqtt/subscribe', { topics })
+    subscribe: (topics) => apiClient.post('/mqtt/subscribe', { topics }),
+
+    // 发送DTU发现广播 (7.14)
+    broadcastDiscover: (customerId) => apiClient.post('/mqtt/broadcast_discover', { customer_id: customerId }),
+
+    // 发送DTU批量配置广播 (7.15)
+    broadcastConfig: (customerId, config) => apiClient.post('/mqtt/broadcast_config', { customer_id: customerId, config })
   },
   
   // 数据相关API
@@ -96,7 +102,44 @@ const apiService = {
     getStatus: () => apiClient.get('/dtu/status'),
 
     // 发送控制命令
-    sendControl: (command) => apiClient.post('/dtu/control', command)
+    sendControl: (command) => apiClient.post('/dtu/control', command),
+
+    // 获取OTA升级状态
+    getOtaStatus: () => apiClient.get('/dtu/ota_status'),
+
+    // 触发OTA升级
+    triggerOtaUpgrade: (data) => apiClient.post('/dtu/ota_upgrade', data)
+  },
+
+  // 端口状态API
+  port: {
+    // 获取所有端口状态
+    getStatus: () => apiClient.get('/port/status'),
+
+    // 获取端口事件历史
+    getEvents: (limit = 100) => apiClient.get('/port/events', { params: { limit } }),
+
+    // 清除端口事件历史
+    clearEvents: () => apiClient.post('/port/clear_events')
+  },
+
+  // 面板状态API
+  panel: {
+    // 获取所有面板状态
+    getStatus: () => apiClient.get('/panel/status')
+  },
+
+  // 传感器API
+  sensor: {
+    // 获取环境传感器数据
+    getEnvData: () => apiClient.get('/sensor/env'),
+
+    // 获取/设置告警阈值
+    getThreshold: () => apiClient.get('/sensor/threshold'),
+    setThreshold: (threshold) => apiClient.post('/sensor/threshold', threshold),
+
+    // 获取历史数据
+    getHistory: (range = '1h') => apiClient.get('/sensor/history', { params: { range } })
   }
 }
 

+ 103 - 0
frontend/src/components/DTUConfig.vue

@@ -154,6 +154,53 @@
             </a-button>
           </a-space>
         </div>
+
+        <a-divider orientation="left">广播控制</a-divider>
+
+        <a-alert
+          type="info"
+          show-icon
+          style="margin-bottom: 16px"
+        >
+          <template #message>
+            <div>发送广播消息到所有在线DTU设备</div>
+          </template>
+        </a-alert>
+
+        <a-space wrap style="margin-bottom: 16px">
+          <a-button
+            @click="sendDiscoverBroadcast"
+            :loading="isBroadcastingDiscover"
+            :disabled="!formData.customer_id"
+          >
+            <template #icon><ScanOutlined /></template>
+            发送DTU发现广播
+          </a-button>
+          <a-button
+            @click="sendConfigBroadcast"
+            :loading="isBroadcastingConfig"
+            :disabled="!formData.customer_id"
+          >
+            <template #icon><SettingOutlined /></template>
+            发送批量配置广播
+          </a-button>
+        </a-space>
+
+        <a-collapse v-model:activeKey="configCollapseKey">
+          <a-collapse-panel key="configPayload" header="配置广播内容">
+            <a-form layout="vertical">
+              <a-form-item label="主题前缀">
+                <a-input v-model:value="broadcastConfig.topic_prefix" placeholder="线架系统" />
+              </a-form-item>
+              <a-form-item label="MQTT服务器">
+                <a-input v-model:value="broadcastConfig.mqtt_broker" placeholder="mqtt://localhost:1883" />
+              </a-form-item>
+              <a-form-item label="心跳间隔(秒)">
+                <a-input-number v-model:value="broadcastConfig.heartbeat_interval" :min="10" :max="3600" style="width: 100%" />
+              </a-form-item>
+            </a-form>
+          </a-collapse-panel>
+        </a-collapse>
       </a-form>
     </a-space>
   </div>
@@ -165,13 +212,17 @@ import { useDataStore } from '../stores/dataStore'
 import apiService from '../api/apiService'
 import StatusIndicator from './StatusIndicator.vue'
 import { message } from 'ant-design-vue'
+import { ScanOutlined, SettingOutlined } from '@ant-design/icons-vue'
 
 const dataStore = useDataStore()
 
 const isSaving = ref(false)
 const isRegistering = ref(false)
 const isRefreshing = ref(false)
+const isBroadcastingDiscover = ref(false)
+const isBroadcastingConfig = ref(false)
 const showErrors = ref(false)
+const configCollapseKey = ref(['configPayload'])
 
 const formData = ref({
   topic_prefix: '线架系统',
@@ -182,6 +233,12 @@ const formData = ref({
   enabled: true
 })
 
+const broadcastConfig = ref({
+  topic_prefix: '线架系统',
+  mqtt_broker: 'mqtt://localhost:1883',
+  heartbeat_interval: 30
+})
+
 const topicPreview = computed(() => {
   if (formData.value.topic_prefix && formData.value.customer_id && formData.value.dtu_id) {
     return `${formData.value.topic_prefix}/${formData.value.customer_id}/dtu/${formData.value.dtu_id}/register`
@@ -273,6 +330,52 @@ const refreshStatus = async () => {
   }
 }
 
+const sendDiscoverBroadcast = async () => {
+  if (!formData.value.customer_id) {
+    message.error('请先配置客户ID')
+    return
+  }
+
+  try {
+    isBroadcastingDiscover.value = true
+    const response = await apiService.mqtt.broadcastDiscover(formData.value.customer_id)
+
+    if (response.data.success) {
+      message.success('DTU发现广播已发送')
+    } else {
+      message.error(response.data.message || '发送失败')
+    }
+  } catch (error) {
+    console.error('发送发现广播失败:', error)
+    message.error('发送失败: ' + (error.response?.data?.message || error.message))
+  } finally {
+    isBroadcastingDiscover.value = false
+  }
+}
+
+const sendConfigBroadcast = async () => {
+  if (!formData.value.customer_id) {
+    message.error('请先配置客户ID')
+    return
+  }
+
+  try {
+    isBroadcastingConfig.value = true
+    const response = await apiService.mqtt.broadcastConfig(formData.value.customer_id, broadcastConfig.value)
+
+    if (response.data.success) {
+      message.success('批量配置广播已发送')
+    } else {
+      message.error(response.data.message || '发送失败')
+    }
+  } catch (error) {
+    console.error('发送配置广播失败:', error)
+    message.error('发送失败: ' + (error.response?.data?.message || error.message))
+  } finally {
+    isBroadcastingConfig.value = false
+  }
+}
+
 onMounted(() => {
   refreshStatus()
 })

+ 36 - 0
frontend/src/router/index.js

@@ -31,6 +31,42 @@ const routes = [
     meta: { title: 'LED调试' }
   },
   {
+    path: '/port-status',
+    name: 'PortStatus',
+    component: () => import('../views/PortStatus.vue'),
+    meta: { title: '端口状态' }
+  },
+  {
+    path: '/panel-status',
+    name: 'PanelStatus',
+    component: () => import('../views/PanelStatus.vue'),
+    meta: { title: '面板状态' }
+  },
+  {
+    path: '/alarm-status',
+    name: 'AlarmStatus',
+    component: () => import('../views/AlarmStatus.vue'),
+    meta: { title: '告警显示' }
+  },
+  {
+    path: '/ota-upgrade',
+    name: 'OTAUpgrade',
+    component: () => import('../views/OTAUpgrade.vue'),
+    meta: { title: 'OTA升级' }
+  },
+  {
+    path: '/env-sensor',
+    name: 'EnvSensor',
+    component: () => import('../views/EnvSensor.vue'),
+    meta: { title: '环境传感器' }
+  },
+  {
+    path: '/jumper-status',
+    name: 'JumperStatus',
+    component: () => import('../views/JumperStatus.vue'),
+    meta: { title: '跳线器状态' }
+  },
+  {
     path: '/about',
     name: 'About',
     component: () => import('../views/About.vue'),

+ 301 - 0
frontend/src/views/AlarmStatus.vue

@@ -0,0 +1,301 @@
+<template>
+  <div class="alarm-status">
+    <!-- 页面标题 -->
+    <div class="page-header">
+      <h2 class="page-title">告警显示</h2>
+    </div>
+
+    <!-- 概览信息栏 -->
+    <a-card :bordered="false" class="overview-card">
+      <div class="overview-bar">
+        <div class="overview-item">
+          <span class="overview-label">告警总数:</span>
+          <a-tag color="red">{{ alarmCount }}</a-tag>
+        </div>
+        <div class="overview-item">
+          <span class="overview-label">事件总数:</span>
+          <a-tag color="blue">{{ events.length }}</a-tag>
+        </div>
+        <div class="overview-item">
+          <span class="overview-label">最后更新:</span>
+          <span class="overview-value">{{ lastUpdate || '--' }}</span>
+        </div>
+        <div class="overview-actions">
+          <a-button type="primary" danger @click="clearEvents" :loading="clearing">
+            <template #icon><delete-outlined /></template>
+            清除所有事件
+          </a-button>
+          <a-button @click="refreshData" :loading="loading">
+            <template #icon><reload-outlined /></template>
+            刷新
+          </a-button>
+        </div>
+      </div>
+    </a-card>
+
+    <!-- 告警事件列表 -->
+    <a-card :bordered="false" class="events-card">
+      <a-table
+        :columns="columns"
+        :data-source="events"
+        :loading="loading"
+        :pagination="{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }"
+        :scroll="{ x: 800 }"
+        row-key="key"
+        size="middle"
+      >
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'event_type'">
+            <a-tag :color="record.event_type === 'ALARM' ? 'red' : 'blue'">
+              {{ record.event_type === 'ALARM' ? '告警' : '事件' }}
+            </a-tag>
+          </template>
+          <template v-else-if="column.key === 'alarm_type'">
+            <span v-if="record.alarm_type">{{ record.alarm_type }}</span>
+            <span v-else class="text-muted">-</span>
+          </template>
+          <template v-else-if="column.key === 'expected_jumper'">
+            <span>{{ record.expected_jumper_uid || '-' }}</span>
+          </template>
+          <template v-else-if="column.key === 'actual_jumper'">
+            <span :class="{ 'text-danger': record.actual_jumper_uid !== record.expected_jumper_uid }">
+              {{ record.actual_jumper_uid || '未读到' }}
+            </span>
+          </template>
+          <template v-else-if="column.key === 'timestamp'">
+            <span class="timestamp">{{ formatTime(record.timestamp) }}</span>
+          </template>
+        </template>
+        <template #emptyText>
+          <a-empty description="暂无告警事件" />
+        </template>
+      </a-table>
+    </a-card>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, onUnmounted } from 'vue'
+import { message } from 'ant-design-vue'
+import { DeleteOutlined, ReloadOutlined } from '@ant-design/icons-vue'
+import apiService from '../api/apiService'
+
+// 状态
+const loading = ref(false)
+const clearing = ref(false)
+const events = ref([])
+const lastUpdate = ref('')
+const pollTimer = ref(null)
+
+// 表格列定义
+const columns = [
+  {
+    title: '时间',
+    key: 'timestamp',
+    dataIndex: 'timestamp',
+    width: 180,
+    sorter: (a, b) => new Date(b.timestamp) - new Date(a.timestamp)
+  },
+  {
+    title: '面板',
+    key: 'panel_id',
+    dataIndex: 'panel_id',
+    width: 80
+  },
+  {
+    title: '端口',
+    key: 'port_id',
+    dataIndex: 'port_id',
+    width: 80
+  },
+  {
+    title: '类型',
+    key: 'event_type',
+    dataIndex: 'event_type',
+    width: 100
+  },
+  {
+    title: '告警类型',
+    key: 'alarm_type',
+    dataIndex: 'alarm_type',
+    width: 150
+  },
+  {
+    title: '期望跳线UID',
+    key: 'expected_jumper',
+    dataIndex: 'expected_jumper_uid',
+    width: 130
+  },
+  {
+    title: '实际跳线UID',
+    key: 'actual_jumper',
+    dataIndex: 'actual_jumper_uid',
+    width: 130
+  }
+]
+
+// 计算告警数量
+const alarmCount = computed(() => {
+  return events.value.filter(e => e.event_type === 'ALARM').length
+})
+
+// 格式化时间
+function formatTime(timestamp) {
+  if (!timestamp) return '-'
+  try {
+    const date = new Date(timestamp)
+    return date.toLocaleString('zh-CN', {
+      year: 'numeric',
+      month: '2-digit',
+      day: '2-digit',
+      hour: '2-digit',
+      minute: '2-digit',
+      second: '2-digit'
+    })
+  } catch {
+    return timestamp
+  }
+}
+
+// 加载事件数据
+async function loadEvents() {
+  try {
+    const res = await apiService.port.getEvents(100)
+    if (res.data.success) {
+      // 添加唯一key
+      events.value = (res.data.events || []).map((e, i) => ({
+        ...e,
+        key: `${e.timestamp}-${i}`
+      }))
+    }
+  } catch (e) {
+    console.error('load events error:', e)
+  }
+}
+
+// 刷新数据
+async function refreshData() {
+  loading.value = true
+  try {
+    await loadEvents()
+    lastUpdate.value = new Date().toLocaleTimeString()
+  } finally {
+    loading.value = false
+  }
+}
+
+// 清除所有事件
+async function clearEvents() {
+  clearing.value = true
+  try {
+    const res = await apiService.port.clearEvents()
+    if (res.data.success) {
+      message.success('事件已清除')
+      events.value = []
+    }
+  } catch (e) {
+    console.error('clear events error:', e)
+    message.error('清除失败')
+  } finally {
+    clearing.value = false
+  }
+}
+
+// 生命周期
+onMounted(async () => {
+  await refreshData()
+  pollTimer.value = setInterval(refreshData, 5000)
+})
+
+onUnmounted(() => {
+  if (pollTimer.value) clearInterval(pollTimer.value)
+})
+</script>
+
+<style scoped>
+.alarm-status {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.page-header {
+  margin-bottom: 8px;
+}
+
+.page-title {
+  margin: 0;
+  font-size: 20px;
+  font-weight: 600;
+  color: #333;
+}
+
+.overview-card {
+  flex-shrink: 0;
+}
+
+.overview-bar {
+  display: flex;
+  align-items: center;
+  gap: 24px;
+  flex-wrap: wrap;
+}
+
+.overview-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.overview-label {
+  font-size: 14px;
+  color: #666;
+}
+
+.overview-value {
+  font-size: 14px;
+  color: #333;
+}
+
+.overview-actions {
+  margin-left: auto;
+  display: flex;
+  gap: 12px;
+}
+
+.events-card {
+  flex: 1;
+  min-height: 400px;
+}
+
+.timestamp {
+  font-family: 'Monaco', 'Menlo', monospace;
+  font-size: 13px;
+}
+
+.text-muted {
+  color: #999;
+}
+
+.text-danger {
+  color: #ff4d4f;
+  font-weight: 500;
+}
+
+@media (max-width: 768px) {
+  .overview-bar {
+    flex-direction: column;
+    align-items: flex-start;
+  }
+
+  .overview-actions {
+    margin-left: 0;
+    margin-top: 12px;
+    width: 100%;
+  }
+
+  .overview-actions button {
+    flex: 1;
+  }
+}
+</style>

+ 550 - 0
frontend/src/views/EnvSensor.vue

@@ -0,0 +1,550 @@
+<template>
+  <div class="env-sensor">
+    <!-- 页面标题 -->
+    <div class="page-header">
+      <h2 class="page-title">环境传感器</h2>
+    </div>
+
+    <!-- 概览信息栏 -->
+    <a-row :gutter="[24, 24]">
+      <a-col :xs="24" :sm="8">
+        <a-card :bordered="false" class="sensor-card temp-card">
+          <div class="sensor-content">
+            <div class="sensor-icon">
+              <FireOutlined />
+            </div>
+            <div class="sensor-info">
+              <div class="sensor-label">温度</div>
+              <div class="sensor-value">
+                {{ sensorData.temperature !== null ? `${sensorData.temperature}°C` : '--' }}
+              </div>
+            </div>
+          </div>
+        </a-card>
+      </a-col>
+
+      <a-col :xs="24" :sm="8">
+        <a-card :bordered="false" class="sensor-card humidity-card">
+          <div class="sensor-content">
+            <div class="sensor-icon">
+              <CloudOutlined />
+            </div>
+            <div class="sensor-info">
+              <div class="sensor-label">湿度</div>
+              <div class="sensor-value">
+                {{ sensorData.humidity !== null ? `${sensorData.humidity}%` : '--' }}
+              </div>
+            </div>
+          </div>
+        </a-card>
+      </a-col>
+
+      <a-col :xs="24" :sm="8">
+        <a-card :bordered="false" class="sensor-card dtu-temp-card">
+          <div class="sensor-content">
+            <div class="sensor-icon">
+              <DesktopOutlined />
+            </div>
+            <div class="sensor-info">
+              <div class="sensor-label">DTU 主板温度</div>
+              <div class="sensor-value">
+                {{ sensorData.dtu_temperature !== null ? `${sensorData.dtu_temperature}°C` : '--' }}
+              </div>
+            </div>
+          </div>
+        </a-card>
+      </a-col>
+    </a-row>
+
+    <!-- 详细信息卡片 -->
+    <a-card :bordered="false" class="detail-card">
+      <template #title>
+        <div class="card-header">
+          <InfoCircleOutlined />
+          <span>传感器详情</span>
+        </div>
+      </template>
+      <template #extra>
+        <a-button type="primary" @click="refreshData" :loading="loading">
+          <template #icon><ReloadOutlined /></template>
+          刷新
+        </a-button>
+      </template>
+
+      <a-descriptions :column="2" bordered size="small">
+        <a-descriptions-item label="环境温度">
+          <span :class="getTempClass(sensorData.temperature)">
+            {{ sensorData.temperature !== null ? `${sensorData.temperature}°C` : '未连接' }}
+          </span>
+        </a-descriptions-item>
+        <a-descriptions-item label="环境湿度">
+          <span :class="getHumidityClass(sensorData.humidity)">
+            {{ sensorData.humidity !== null ? `${sensorData.humidity}%` : '未连接' }}
+          </span>
+        </a-descriptions-item>
+        <a-descriptions-item label="DTU 主板温度">
+          <span :class="getDtuTempClass(sensorData.dtu_temperature)">
+            {{ sensorData.dtu_temperature !== null ? `${sensorData.dtu_temperature}°C` : '未连接' }}
+          </span>
+        </a-descriptions-item>
+        <a-descriptions-item label="更新时间">
+          {{ sensorData.update_time ? formatTime(sensorData.update_time) : '--' }}
+        </a-descriptions-item>
+        <a-descriptions-item label="传感器数据时间戳">
+          {{ sensorData.sensor_update_time ? formatTime(sensorData.sensor_update_time) : '--' }}
+        </a-descriptions-item>
+        <a-descriptions-item label="连接状态">
+          <a-tag :color="sensorData.connected ? 'green' : 'red'">
+            {{ sensorData.connected ? '已连接' : '未连接' }}
+          </a-tag>
+        </a-descriptions-item>
+      </a-descriptions>
+    </a-card>
+
+    <!-- 告警阈值设置 -->
+    <a-card :bordered="false" class="threshold-card">
+      <template #title>
+        <div class="card-header">
+          <WarningOutlined />
+          <span>告警阈值设置</span>
+        </div>
+      </template>
+
+      <a-form
+        :model="thresholdForm"
+        :label-col="{ span: 6 }"
+        :wrapper-col="{ span: 12 }"
+        @finish="saveThreshold"
+      >
+        <a-form-item label="温度过高阈值" name="temp_high">
+          <a-input-number
+            v-model:value="thresholdForm.temp_high"
+            :min="-40"
+            :max="100"
+            addon-after="°C"
+            style="width: 100%"
+          />
+        </a-form-item>
+        <a-form-item label="温度过低阈值" name="temp_low">
+          <a-input-number
+            v-model:value="thresholdForm.temp_low"
+            :min="-40"
+            :max="100"
+            addon-after="°C"
+            style="width: 100%"
+          />
+        </a-form-item>
+        <a-form-item label="湿度过高阈值" name="humidity_high">
+          <a-input-number
+            v-model:value="thresholdForm.humidity_high"
+            :min="0"
+            :max="100"
+            addon-after="%"
+            style="width: 100%"
+          />
+        </a-form-item>
+        <a-form-item label="湿度过低阈值" name="humidity_low">
+          <a-input-number
+            v-model:value="thresholdForm.humidity_low"
+            :min="0"
+            :max="100"
+            addon-after="%"
+            style="width: 100%"
+          />
+        </a-form-item>
+        <a-form-item label="DTU温度过高阈值" name="dtu_temp_high">
+          <a-input-number
+            v-model:value="thresholdForm.dtu_temp_high"
+            :min="0"
+            :max="120"
+            addon-after="°C"
+            style="width: 100%"
+          />
+        </a-form-item>
+        <a-form-item :wrapper-col="{ offset: 6, span: 12 }">
+          <a-button type="primary" html-type="submit" :loading="saving">
+            保存阈值
+          </a-button>
+        </a-form-item>
+      </a-form>
+    </a-card>
+
+    <!-- 历史数据图表 -->
+    <a-card :bordered="false" class="chart-card">
+      <template #title>
+        <div class="card-header">
+          <LineChartOutlined />
+          <span>历史数据</span>
+        </div>
+      </template>
+      <template #extra>
+        <a-segmented v-model:value="timeRange" :options="timeRangeOptions" @change="loadHistoryData" />
+      </template>
+
+      <div v-if="historyData.length > 0" class="chart-container">
+        <div ref="chartRef" style="width: 100%; height: 300px;"></div>
+      </div>
+      <a-empty v-else description="暂无历史数据" />
+    </a-card>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue'
+import { message } from 'ant-design-vue'
+import {
+  FireOutlined,
+  CloudOutlined,
+  DesktopOutlined,
+  InfoCircleOutlined,
+  WarningOutlined,
+  LineChartOutlined,
+  ReloadOutlined
+} from '@ant-design/icons-vue'
+
+const loading = ref(false)
+const saving = ref(false)
+const timeRange = ref('1h')
+const timeRangeOptions = [
+  { label: '1小时', value: '1h' },
+  { label: '6小时', value: '6h' },
+  { label: '24小时', value: '24h' },
+  { label: '7天', value: '7d' }
+]
+
+const sensorData = ref({
+  temperature: null,
+  humidity: null,
+  dtu_temperature: null,
+  update_time: null,
+  sensor_update_time: null,
+  connected: false
+})
+
+const thresholdForm = reactive({
+  temp_high: 50,
+  temp_low: -10,
+  humidity_high: 80,
+  humidity_low: 20,
+  dtu_temp_high: 70
+})
+
+const historyData = ref([])
+const chartRef = ref(null)
+let chartInstance = null
+let pollInterval = null
+
+const formatTime = (timestamp) => {
+  if (!timestamp) return '--'
+  const date = new Date(timestamp)
+  return date.toLocaleString('zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+    second: '2-digit'
+  })
+}
+
+const getTempClass = (temp) => {
+  if (temp === null) return ''
+  if (temp > thresholdForm.temp_high) return 'text-danger'
+  if (temp < thresholdForm.temp_low) return 'text-warning'
+  return 'text-success'
+}
+
+const getHumidityClass = (humidity) => {
+  if (humidity === null) return ''
+  if (humidity > thresholdForm.humidity_high) return 'text-danger'
+  if (humidity < thresholdForm.humidity_low) return 'text-warning'
+  return 'text-success'
+}
+
+const getDtuTempClass = (temp) => {
+  if (temp === null) return ''
+  if (temp > thresholdForm.dtu_temp_high) return 'text-danger'
+  return 'text-success'
+}
+
+const fetchSensorData = async () => {
+  try {
+    const response = await fetch('/api/sensor/env')
+    const result = await response.json()
+    if (result.success) {
+      sensorData.value = {
+        temperature: result.data?.temperature,
+        humidity: result.data?.humidity,
+        dtu_temperature: result.data?.dtu_temperature,
+        update_time: result.data?.update_time,
+        sensor_update_time: result.data?.sensor_update_time,
+        connected: result.data?.connected ?? (result.data?.temperature !== null)
+      }
+    }
+  } catch (error) {
+    console.error('获取传感器数据失败:', error)
+  }
+}
+
+const fetchThresholds = async () => {
+  try {
+    const response = await fetch('/api/sensor/threshold')
+    const result = await response.json()
+    if (result.success && result.data) {
+      Object.assign(thresholdForm, result.data)
+    }
+  } catch (error) {
+    console.error('获取阈值失败:', error)
+  }
+}
+
+const saveThreshold = async () => {
+  saving.value = true
+  try {
+    const response = await fetch('/api/sensor/threshold', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(thresholdForm)
+    })
+    const result = await response.json()
+    if (result.success) {
+      message.success('阈值保存成功')
+    } else {
+      message.error(result.error || '保存失败')
+    }
+  } catch (error) {
+    message.error('保存失败: ' + error.message)
+  } finally {
+    saving.value = false
+  }
+}
+
+const loadHistoryData = async () => {
+  try {
+    const response = await fetch(`/api/sensor/history?range=${timeRange.value}`)
+    const result = await response.json()
+    if (result.success) {
+      historyData.value = result.data || []
+      await nextTick()
+      renderChart()
+    }
+  } catch (error) {
+    console.error('获取历史数据失败:', error)
+  }
+}
+
+const renderChart = async () => {
+  if (!chartRef.value || historyData.value.length === 0) return
+
+  const ECharts = await import('echarts')
+  if (!chartInstance) {
+    chartInstance = ECharts.init(chartRef.value)
+  }
+
+  const times = historyData.value.map(d => d.update_time || d.timestamp)
+  const temps = historyData.value.map(d => d.temperature)
+  const humidities = historyData.value.map(d => d.humidity)
+  const dtuTemps = historyData.value.map(d => d.dtu_temperature || d.dtu_temp)
+
+  const option = {
+    tooltip: {
+      trigger: 'axis',
+      formatter: (params) => {
+        let result = params[0].axisValueLabel + '<br/>'
+        params.forEach(p => {
+          result += `${p.marker} ${p.seriesName}: ${p.value ?? '--'}${p.seriesName.includes('温度') ? '°C' : '%'}<br/>`
+        })
+        return result
+      }
+    },
+    legend: {
+      data: ['环境温度', '环境湿度', 'DTU温度']
+    },
+    grid: {
+      left: '3%',
+      right: '4%',
+      bottom: '3%',
+      containLabel: true
+    },
+    xAxis: {
+      type: 'category',
+      boundaryGap: false,
+      data: times
+    },
+    yAxis: [
+      {
+        type: 'value',
+        name: '温度 (°C)',
+        position: 'left',
+        axisLine: { show: true },
+        axisLabel: { formatter: '{value}°C' }
+      },
+      {
+        type: 'value',
+        name: '湿度 (%)',
+        min: 0,
+        max: 100,
+        position: 'right',
+        axisLine: { show: true },
+        axisLabel: { formatter: '{value}%' }
+      }
+    ],
+    series: [
+      {
+        name: '环境温度',
+        type: 'line',
+        data: temps,
+        smooth: true,
+        lineStyle: { color: '#ff6b6b' },
+        itemStyle: { color: '#ff6b6b' }
+      },
+      {
+        name: 'DTU温度',
+        type: 'line',
+        data: dtuTemps,
+        smooth: true,
+        lineStyle: { color: '#ffa502' },
+        itemStyle: { color: '#ffa502' }
+      },
+      {
+        name: '环境湿度',
+        type: 'line',
+        yAxisIndex: 1,
+        data: humidities,
+        smooth: true,
+        lineStyle: { color: '#74b9ff' },
+        itemStyle: { color: '#74b9ff' }
+      }
+    ]
+  }
+
+  chartInstance.setOption(option)
+}
+
+const refreshData = async () => {
+  loading.value = true
+  try {
+    await Promise.all([fetchSensorData(), fetchThresholds(), loadHistoryData()])
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(async () => {
+  await refreshData()
+
+  // 定时刷新
+  pollInterval = setInterval(fetchSensorData, 5000)
+
+  // 窗口大小变化时重新调整图表
+  window.addEventListener('resize', () => {
+    chartInstance?.resize()
+  })
+})
+
+onUnmounted(() => {
+  if (pollInterval) {
+    clearInterval(pollInterval)
+  }
+  if (chartInstance) {
+    chartInstance.dispose()
+    chartInstance = null
+  }
+})
+</script>
+
+<style scoped>
+.env-sensor {
+  padding: 24px;
+}
+
+.page-header {
+  margin-bottom: 24px;
+}
+
+.page-title {
+  font-size: 24px;
+  font-weight: 600;
+  margin: 0;
+}
+
+.sensor-card {
+  text-align: center;
+}
+
+.sensor-card :deep(.ant-card-body) {
+  padding: 24px;
+}
+
+.sensor-content {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 16px;
+}
+
+.sensor-icon {
+  font-size: 48px;
+  padding: 16px;
+  border-radius: 12px;
+}
+
+.temp-card .sensor-icon {
+  color: #ff6b6b;
+  background: rgba(255, 107, 107, 0.1);
+}
+
+.humidity-card .sensor-icon {
+  color: #74b9ff;
+  background: rgba(116, 185, 255, 0.1);
+}
+
+.dtu-temp-card .sensor-icon {
+  color: #ffa502;
+  background: rgba(255, 165, 2, 0.1);
+}
+
+.sensor-info {
+  text-align: left;
+}
+
+.sensor-label {
+  font-size: 14px;
+  color: rgba(0, 0, 0, 0.45);
+  margin-bottom: 4px;
+}
+
+.sensor-value {
+  font-size: 28px;
+  font-weight: 600;
+  color: rgba(0, 0, 0, 0.85);
+}
+
+.detail-card,
+.threshold-card,
+.chart-card {
+  margin-top: 24px;
+}
+
+.card-header {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.text-success {
+  color: #52c41a;
+}
+
+.text-warning {
+  color: #faad14;
+}
+
+.text-danger {
+  color: #ff4d4f;
+}
+
+.chart-container {
+  min-height: 300px;
+}
+</style>

+ 290 - 0
frontend/src/views/JumperStatus.vue

@@ -0,0 +1,290 @@
+<template>
+  <div class="jumper-status">
+    <a-row :gutter="[24, 24]">
+      <!-- 顶部控制栏 -->
+      <a-col :span="24">
+        <a-card>
+          <div class="control-bar">
+            <div class="control-item">
+              <span class="control-label">选择面板:</span>
+              <a-select
+                v-model:value="selectedPanel"
+                style="width: 200px"
+                placeholder="请选择面板"
+                :options="panelOptions"
+                @change="handlePanelChange"
+              />
+            </div>
+            <div class="control-item">
+              <a-button
+                type="primary"
+                @click="queryJumperStatus"
+                :loading="loading"
+                :disabled="!selectedPanel"
+              >
+                查询跳线器状态
+              </a-button>
+            </div>
+            <div class="control-item">
+              <a-button @click="refreshData" :loading="loading">
+                刷新
+              </a-button>
+            </div>
+          </div>
+        </a-card>
+      </a-col>
+
+      <!-- 跳线器状态显示 -->
+      <a-col :span="24" v-if="jumperData">
+        <a-card title="跳线器状态信息">
+          <a-descriptions :column="2" bordered>
+            <a-descriptions-item label="面板地址">
+              <a-tag color="blue">{{ jumperData.device_address }}</a-tag>
+            </a-descriptions-item>
+            <a-descriptions-item label="连接状态">
+              <a-tag :color="jumperData.connected ? 'green' : 'default'">
+                {{ jumperData.connected ? '已连接' : '未连接' }}
+              </a-tag>
+            </a-descriptions-item>
+            <a-descriptions-item label="跳线器ID(高16位)">
+              <span class="register-value">{{ jumperData.jumper_id_high }}</span>
+            </a-descriptions-item>
+            <a-descriptions-item label="跳线器ID(低16位)">
+              <span class="register-value">{{ jumperData.jumper_id_low }}</span>
+            </a-descriptions-item>
+            <a-descriptions-item label="跳线器ID(合并)" :span="2">
+              <span class="register-value">{{ jumperData.jumper_id }}</span>
+            </a-descriptions-item>
+          </a-descriptions>
+
+          <a-divider>原始寄存器数据</a-divider>
+          <div class="registers-display">
+            <a-tag
+              v-for="(value, index) in jumperData.raw_registers"
+              :key="index"
+              class="register-tag"
+            >
+              R{{ index }}: 0x{{ toHex(value) }} ({{ value }})
+            </a-tag>
+          </div>
+        </a-card>
+      </a-col>
+
+      <!-- 无数据提示 -->
+      <a-col :span="24" v-else-if="!loading">
+        <a-card>
+          <a-empty description="请选择面板并查询跳线器状态">
+            <template #image>
+              <InboxOutlined style="font-size: 48px; color: #999" />
+            </template>
+          </a-empty>
+        </a-card>
+      </a-col>
+
+      <!-- 历史查询记录 -->
+      <a-col :span="24">
+        <a-card title="查询历史">
+          <a-table
+            :columns="columns"
+            :data-source="historyRecords"
+            :pagination="{ pageSize: 10 }"
+            row-key="timestamp"
+          >
+            <template #bodyCell="{ column, record }">
+              <template v-if="column.key === 'connected'">
+                <a-tag :color="record.connected ? 'green' : 'default'">
+                  {{ record.connected ? '已连接' : '未连接' }}
+                </a-tag>
+              </template>
+              <template v-if="column.key === 'status'">
+                <a-tag :color="record.success ? 'green' : 'red'">
+                  {{ record.success ? '成功' : '失败' }}
+                </a-tag>
+              </template>
+            </template>
+          </a-table>
+        </a-card>
+      </a-col>
+    </a-row>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, onUnmounted } from 'vue'
+import { InboxOutlined } from '@ant-design/icons-vue'
+import { message } from 'ant-design-vue'
+import apiService from '../api/apiService'
+
+const loading = ref(false)
+const selectedPanel = ref(null)
+const panels = ref([])
+const jumperData = ref(null)
+const historyRecords = ref([])
+const pollTimer = ref(null)
+
+const panelOptions = computed(() => {
+  return panels.value.map(p => ({
+    label: `面板 ${p.panel_id} (地址: ${p.address})`,
+    value: p.panel_id
+  }))
+})
+
+const columns = [
+  {
+    title: '时间',
+    dataIndex: 'timestamp',
+    key: 'timestamp',
+    width: 180
+  },
+  {
+    title: '面板ID',
+    dataIndex: 'panel_id',
+    key: 'panel_id',
+    width: 100
+  },
+  {
+    title: '设备地址',
+    dataIndex: 'device_address',
+    key: 'device_address',
+    width: 100
+  },
+  {
+    title: '连接状态',
+    dataIndex: 'connected',
+    key: 'connected',
+    width: 100
+  },
+  {
+    title: '跳线器ID',
+    dataIndex: 'jumper_id',
+    key: 'jumper_id',
+    width: 120
+  },
+  {
+    title: '状态',
+    dataIndex: 'status',
+    key: 'status',
+    width: 80
+  }
+]
+
+function toHex(value) {
+  if (value === null || value === undefined) return '0'
+  return (value >>> 0).toString(16).toUpperCase().padStart(4, '0')
+}
+
+async function loadPanels() {
+  try {
+    const res = await apiService.panel.getStatus()
+    if (res.data.success) {
+      panels.value = Object.values(res.data.panels || {})
+    }
+  } catch (e) {
+    console.error('load panels error:', e)
+  }
+}
+
+async function queryJumperStatus() {
+  if (!selectedPanel.value) {
+    message.warning('请选择面板')
+    return
+  }
+
+  loading.value = true
+  try {
+    const command = {
+      command: 'QUERY_JUMPER_STATUS',
+      target: selectedPanel.value
+    }
+    const res = await apiService.dtu.sendControl(command)
+
+    if (res.data.success && res.data.payload?.result) {
+      jumperData.value = res.data.payload.result
+      message.success('查询成功')
+
+      // 添加到历史记录
+      historyRecords.value.unshift({
+        timestamp: new Date().toLocaleString(),
+        panel_id: selectedPanel.value,
+        device_address: jumperData.value.device_address,
+        connected: jumperData.value.connected,
+        jumper_id: jumperData.value.jumper_id,
+        success: res.data.payload.success
+      })
+
+      // 限制历史记录数量
+      if (historyRecords.value.length > 50) {
+        historyRecords.value = historyRecords.value.slice(0, 50)
+      }
+    } else {
+      message.error(res.data.payload?.result?.message || '查询失败')
+    }
+  } catch (e) {
+    console.error('query jumper status error:', e)
+    message.error('查询失败: ' + (e.response?.data?.message || e.message))
+  } finally {
+    loading.value = false
+  }
+}
+
+function handlePanelChange(value) {
+  selectedPanel.value = value
+  jumperData.value = null
+}
+
+async function refreshData() {
+  loading.value = true
+  try {
+    await loadPanels()
+    if (selectedPanel.value) {
+      await queryJumperStatus()
+    }
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(async () => {
+  await loadPanels()
+  if (panels.value.length > 0) {
+    selectedPanel.value = panels.value[0].panel_id
+  }
+  pollTimer.value = setInterval(refreshData, 30000)
+})
+
+onUnmounted(() => {
+  if (pollTimer.value) clearInterval(pollTimer.value)
+})
+</script>
+
+<style scoped>
+.control-bar {
+  display: flex;
+  align-items: center;
+  gap: 24px;
+  flex-wrap: wrap;
+}
+.control-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.control-label {
+  font-size: 14px;
+  color: #666;
+}
+.register-value {
+  font-family: monospace;
+  font-size: 14px;
+  color: #1890ff;
+}
+.registers-display {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.register-tag {
+  font-family: monospace;
+  font-size: 12px;
+}
+</style>

+ 449 - 0
frontend/src/views/OTAUpgrade.vue

@@ -0,0 +1,449 @@
+<template>
+  <div class="ota-upgrade">
+    <!-- 页面标题 -->
+    <div class="page-header">
+      <h2 class="page-title">OTA升级</h2>
+    </div>
+
+    <!-- 当前状态卡片 -->
+    <a-card :bordered="false" class="status-card">
+      <div class="status-header">
+        <h3>当前状态</h3>
+        <a-button @click="refreshStatus" :loading="loading">
+          <template #icon><reload-outlined /></template>
+          刷新
+        </a-button>
+      </div>
+
+      <a-descriptions :column="3" bordered size="small">
+        <a-descriptions-item label="升级状态">
+          <a-tag :color="statusColor">{{ statusText }}</a-tag>
+        </a-descriptions-item>
+        <a-descriptions-item label="当前固件版本">
+          {{ currentFirmware || '-' }}
+        </a-descriptions-item>
+        <a-descriptions-item label="目标固件版本">
+          {{ otaStatus.target_version || '-' }}
+        </a-descriptions-item>
+        <a-descriptions-item label="升级进度">
+          <a-progress
+            :percent="otaStatus.progress"
+            :status="progressStatus"
+            :stroke-color="progressColor"
+            style="width: 150px"
+          />
+        </a-descriptions-item>
+        <a-descriptions-item label="最后更新">
+          {{ otaStatus.last_update || '-' }}
+        </a-descriptions-item>
+        <a-descriptions-item label="错误信息">
+          <span v-if="otaStatus.error_message" class="error-text">
+            {{ otaStatus.error_message }}
+          </span>
+          <span v-else class="text-muted">-</span>
+        </a-descriptions-item>
+      </a-descriptions>
+    </a-card>
+
+    <!-- 升级配置卡片 -->
+    <a-card :bordered="false" class="config-card">
+      <h3>发起OTA升级</h3>
+
+      <a-form
+        :model="form"
+        :rules="rules"
+        layout="vertical"
+        @finish="handleUpgrade"
+      >
+        <a-row :gutter="16">
+          <a-col :span="12">
+            <a-form-item label="固件URL" name="firmware_url">
+              <a-input
+                v-model:value="form.firmware_url"
+                placeholder="http://example.com/firmware.bin"
+                :disabled="upgrading"
+              />
+            </a-form-item>
+          </a-col>
+          <a-col :span="6">
+            <a-form-item label="目标版本" name="firmware_version">
+              <a-input
+                v-model:value="form.firmware_version"
+                placeholder="v1.2.0"
+                :disabled="upgrading"
+              />
+            </a-form-item>
+          </a-col>
+          <a-col :span="6">
+            <a-form-item label="文件大小(字节)" name="file_size">
+              <a-input-number
+                v-model:value="form.file_size"
+                :min="0"
+                style="width: 100%"
+                placeholder="1048576"
+                :disabled="upgrading"
+              />
+            </a-form-item>
+          </a-col>
+        </a-row>
+
+        <a-row :gutter="16">
+          <a-col :span="8">
+            <a-form-item label="校验和" name="checksum">
+              <a-input
+                v-model:value="form.checksum"
+                placeholder="MD5/SHA256值"
+                :disabled="upgrading"
+              />
+            </a-form-item>
+          </a-col>
+          <a-col :span="8">
+            <a-form-item label="校验和类型" name="checksum_type">
+              <a-select v-model:value="form.checksum_type" :disabled="upgrading">
+                <a-select-option value="MD5">MD5</a-select-option>
+                <a-select-option value="SHA256">SHA256</a-select-option>
+              </a-select>
+            </a-form-item>
+          </a-col>
+          <a-col :span="8">
+            <a-form-item label="强制升级" name="force_upgrade">
+              <a-switch v-model:checked="form.force_upgrade" :disabled="upgrading" />
+              <span class="switch-hint">忽略版本一致性</span>
+            </a-form-item>
+          </a-col>
+        </a-row>
+
+        <a-form-item>
+          <a-button
+            type="primary"
+            html-type="submit"
+            :loading="upgrading"
+            :disabled="!canUpgrade"
+          >
+            <template #icon><cloud-upload-outlined /></template>
+            开始升级
+          </a-button>
+          <a-button
+            v-if="otaStatus.status !== 'IDLE' && otaStatus.status !== 'SUCCESS' && otaStatus.status !== 'FAILED'"
+            @click="cancelUpgrade"
+            danger
+            style="margin-left: 12px"
+          >
+            取消升级
+          </a-button>
+        </a-form-item>
+      </a-form>
+    </a-card>
+
+    <!-- 升级说明 -->
+    <a-card :bordered="false" class="help-card">
+      <h3>升级说明</h3>
+      <ul>
+        <li><strong>固件URL:</strong> 固件文件的完整下载链接</li>
+        <li><strong>目标版本:</strong> 升级后的固件版本号(如v1.2.0)</li>
+        <li><strong>文件大小:</strong> 固件文件的字节数</li>
+        <li><strong>校验和:</strong> 固件文件的MD5或SHA256值(用于验证完整性)</li>
+        <li><strong>强制升级:</strong> 即使当前版本与目标版本相同也执行升级</li>
+      </ul>
+
+      <a-divider />
+
+      <h4>OTA状态说明</h4>
+      <a-descriptions :column="3" bordered size="small">
+        <a-descriptions-item label="IDLE">空闲,未进行升级</a-descriptions-item>
+        <a-descriptions-item label="DOWNLOADING">正在下载固件</a-descriptions-item>
+        <a-descriptions-item label="VERIFYING">正在验证校验和</a-descriptions-item>
+        <a-descriptions-item label="FLASHING">正在写入固件</a-descriptions-item>
+        <a-descriptions-item label="SUCCESS">升级成功</a-descriptions-item>
+        <a-descriptions-item label="FAILED">升级失败</a-descriptions-item>
+      </a-descriptions>
+
+      <a-divider />
+
+      <h4>常见错误码</h4>
+      <a-descriptions :column="2" bordered size="small">
+        <a-descriptions-item label="1021">已是目标版本</a-descriptions-item>
+        <a-descriptions-item label="1022">校验和验证失败</a-descriptions-item>
+        <a-descriptions-item label="1023">下载失败</a-descriptions-item>
+        <a-descriptions-item label="1024">写入失败</a-descriptions-item>
+        <a-descriptions-item label="1025">存储空间不足</a-descriptions-item>
+      </a-descriptions>
+    </a-card>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, onUnmounted } from 'vue'
+import { message } from 'ant-design-vue'
+import { ReloadOutlined, CloudUploadOutlined } from '@ant-design/icons-vue'
+import apiService from '../api/apiService'
+
+// 状态
+const loading = ref(false)
+const upgrading = ref(false)
+const pollTimer = ref(null)
+const currentFirmware = ref('')
+
+// OTA状态
+const otaStatus = ref({
+  status: 'IDLE',
+  progress: 0,
+  target_version: null,
+  error_code: null,
+  error_message: null,
+  last_update: null
+})
+
+// 表单数据
+const form = ref({
+  firmware_url: '',
+  firmware_version: '',
+  file_size: null,
+  checksum: '',
+  checksum_type: 'MD5',
+  force_upgrade: false
+})
+
+// 表单校验规则
+const rules = {
+  firmware_url: [
+    { required: true, message: '请输入固件URL' },
+    { type: 'url', message: '请输入有效的URL' }
+  ],
+  firmware_version: [
+    { required: true, message: '请输入目标版本' }
+  ],
+  file_size: [
+    { required: true, message: '请输入文件大小' },
+    { type: 'number', min: 1, message: '文件大小必须大于0' }
+  ],
+  checksum: [
+    { required: true, message: '请输入校验和' }
+  ],
+  checksum_type: [
+    { required: true, message: '请选择校验和类型' }
+  ]
+}
+
+// 计算属性
+const canUpgrade = computed(() => {
+  return form.value.firmware_url &&
+    form.value.firmware_version &&
+    form.value.file_size &&
+    form.value.checksum &&
+    !upgrading.value &&
+    (otaStatus.value.status === 'IDLE' || otaStatus.value.status === 'SUCCESS' || otaStatus.value.status === 'FAILED')
+})
+
+const statusText = computed(() => {
+  const statusMap = {
+    'IDLE': '空闲',
+    'DOWNLOADING': '下载中',
+    'VERIFYING': '验证中',
+    'FLASHING': '烧录中',
+    'SUCCESS': '成功',
+    'FAILED': '失败'
+  }
+  return statusMap[otaStatus.value.status] || otaStatus.value.status
+})
+
+const statusColor = computed(() => {
+  const colorMap = {
+    'IDLE': 'default',
+    'DOWNLOADING': 'processing',
+    'VERIFYING': 'processing',
+    'FLASHING': 'processing',
+    'SUCCESS': 'success',
+    'FAILED': 'error'
+  }
+  return colorMap[otaStatus.value.status] || 'default'
+})
+
+const progressStatus = computed(() => {
+  if (otaStatus.value.status === 'FAILED') return 'exception'
+  if (otaStatus.value.status === 'SUCCESS') return 'success'
+  if (otaStatus.value.progress >= 100) return 'success'
+  return 'normal'
+})
+
+const progressColor = computed(() => {
+  if (otaStatus.value.status === 'FAILED') return '#ff4d4f'
+  if (otaStatus.value.status === 'SUCCESS') return '#52c41a'
+  return '#1890ff'
+})
+
+// 获取OTA状态
+async function fetchOtaStatus() {
+  try {
+    const res = await apiService.dtu.getOtaStatus()
+    if (res.data.success) {
+      otaStatus.value = {
+        status: res.data.ota_status,
+        progress: res.data.ota_progress || 0,
+        target_version: res.data.target_version,
+        error_code: res.data.error_code,
+        error_message: res.data.error_message,
+        last_update: res.data.last_update
+      }
+    }
+  } catch (e) {
+    console.error('fetch ota status error:', e)
+  }
+}
+
+// 获取DTU状态(包含固件版本)
+async function fetchDtuStatus() {
+  try {
+    const res = await apiService.dtu.getStatus()
+    if (res.data.success && res.data.status) {
+      currentFirmware.value = res.data.status.firmware_version || ''
+    }
+  } catch (e) {
+    console.error('fetch dtu status error:', e)
+  }
+}
+
+// 刷新状态
+async function refreshStatus() {
+  loading.value = true
+  try {
+    await Promise.all([fetchOtaStatus(), fetchDtuStatus()])
+  } finally {
+    loading.value = false
+  }
+}
+
+// 发起OTA升级
+async function handleUpgrade() {
+  upgrading.value = true
+  try {
+    const res = await apiService.dtu.triggerOtaUpgrade({
+      firmware_url: form.value.firmware_url,
+      firmware_version: form.value.firmware_version,
+      file_size: form.value.file_size,
+      checksum: form.value.checksum,
+      checksum_type: form.value.checksum_type,
+      force_upgrade: form.value.force_upgrade
+    })
+
+    if (res.data.success) {
+      message.success('OTA升级已发起')
+      // 立即刷新状态
+      await fetchOtaStatus()
+    } else {
+      message.error(res.data.message || '升级发起失败')
+    }
+  } catch (e) {
+    console.error('ota upgrade error:', e)
+    message.error('升级发起失败')
+  } finally {
+    upgrading.value = false
+  }
+}
+
+// 取消升级(通过发送取消命令)
+async function cancelUpgrade() {
+  try {
+    await apiService.dtu.sendControl({ command: 'OTA_CANCEL' })
+    message.info('取消命令已发送')
+  } catch (e) {
+    console.error('cancel upgrade error:', e)
+    message.error('取消失败')
+  }
+}
+
+// 生命周期
+onMounted(async () => {
+  await refreshStatus()
+  // 每3秒轮询OTA状态
+  pollTimer.value = setInterval(fetchOtaStatus, 3000)
+})
+
+onUnmounted(() => {
+  if (pollTimer.value) clearInterval(pollTimer.value)
+})
+</script>
+
+<style scoped>
+.ota-upgrade {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.page-header {
+  margin-bottom: 8px;
+}
+
+.page-title {
+  margin: 0;
+  font-size: 20px;
+  font-weight: 600;
+  color: #333;
+}
+
+.status-card {
+  flex-shrink: 0;
+}
+
+.status-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16px;
+}
+
+.status-header h3 {
+  margin: 0;
+  font-size: 16px;
+  font-weight: 500;
+}
+
+.error-text {
+  color: #ff4d4f;
+}
+
+.text-muted {
+  color: #999;
+}
+
+.config-card h3 {
+  margin: 0 0 16px 0;
+  font-size: 16px;
+  font-weight: 500;
+}
+
+.switch-hint {
+  margin-left: 8px;
+  color: #666;
+  font-size: 12px;
+}
+
+.help-card h3 {
+  margin: 0 0 16px 0;
+  font-size: 16px;
+  font-weight: 500;
+}
+
+.help-card h4 {
+  margin: 12px 0 8px 0;
+  font-size: 14px;
+  font-weight: 500;
+}
+
+.help-card ul {
+  margin: 0;
+  padding-left: 20px;
+}
+
+.help-card li {
+  margin-bottom: 8px;
+  color: #666;
+}
+
+@media (max-width: 768px) {
+  .config-card :deep(.ant-row > .ant-col) {
+    margin-bottom: 0;
+  }
+}
+</style>

+ 311 - 0
frontend/src/views/PanelStatus.vue

@@ -0,0 +1,311 @@
+<template>
+  <div class="panel-status">
+    <a-row :gutter="[24, 24]">
+      <!-- 顶部概览 -->
+      <a-col :span="24">
+        <a-card>
+          <div class="overview-bar">
+            <div class="overview-item">
+              <span class="overview-label">面板总数</span>
+              <a-tag color="blue">{{ panels.length }} 个</a-tag>
+            </div>
+            <div class="overview-item">
+              <span class="overview-label">在线面板</span>
+              <a-tag color="green">{{ onlineCount }} 个</a-tag>
+            </div>
+            <div class="overview-item">
+              <span class="overview-label">离线面板</span>
+              <a-tag color="default">{{ offlineCount }} 个</a-tag>
+            </div>
+            <div class="overview-item">
+              <span class="overview-label">告警面板</span>
+              <a-tag color="red">{{ alarmPanelCount }} 个</a-tag>
+            </div>
+            <div class="overview-item">
+              <span class="overview-label">最近更新</span>
+              <span class="overview-value">{{ lastUpdate }}</span>
+            </div>
+            <div class="overview-item">
+              <a-button type="primary" size="small" @click="refreshData" :loading="loading">
+                刷新
+              </a-button>
+            </div>
+          </div>
+        </a-card>
+      </a-col>
+
+      <!-- 面板状态卡片 -->
+      <a-col :span="24">
+        <a-card title="面板状态">
+          <a-empty v-if="panels.length === 0" description="暂无面板数据" />
+          <div v-else class="panel-grid">
+            <div
+              v-for="panel in sortedPanels"
+              :key="panel.panel_id"
+              class="panel-card"
+              :class="getPanelClass(panel)"
+            >
+              <div class="panel-header">
+                <span class="panel-id">面板 {{ panel.panel_id }}</span>
+                <a-tag :color="getStatusColor(panel.status)" size="small">
+                  {{ panel.status === 'online' ? '在线' : '离线' }}
+                </a-tag>
+              </div>
+              <div class="panel-info">
+                <div class="info-row">
+                  <span class="info-label">地址:</span>
+                  <span class="info-value">{{ panel.address ?? '--' }}</span>
+                </div>
+                <div class="info-row">
+                  <span class="info-label">位置:</span>
+                  <span class="info-value">{{ panel.position ?? '--' }}</span>
+                </div>
+                <div class="info-row">
+                  <span class="info-label">UID:</span>
+                  <span class="info-value uid-text">{{ formatUid(panel.panel_uid) }}</span>
+                </div>
+              </div>
+              <a-divider style="margin: 12px 0" />
+              <div class="panel-stats">
+                <div class="stat-item">
+                  <span class="stat-label">端口数</span>
+                  <span class="stat-value">{{ panel.port_count }}</span>
+                </div>
+                <div class="stat-item">
+                  <span class="stat-label">已连接</span>
+                  <span class="stat-value success">{{ panel.connected_count }}</span>
+                </div>
+                <div class="stat-item">
+                  <span class="stat-label">告警数</span>
+                  <span class="stat-value" :class="{ error: panel.alarm_count > 0 }">
+                    {{ panel.alarm_count }}
+                  </span>
+                </div>
+              </div>
+              <div class="panel-progress">
+                <a-progress
+                  :percent="getConnectPercent(panel)"
+                  :show-info="false"
+                  :stroke-color="getProgressColor(panel)"
+                  :trail-color="'#f0f0f0'"
+                  size="small"
+                />
+                <span class="progress-text">{{ getConnectPercent(panel) }}%</span>
+              </div>
+            </div>
+          </div>
+        </a-card>
+      </a-col>
+    </a-row>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, onUnmounted } from 'vue'
+import apiService from '../api/apiService'
+
+const loading = ref(false)
+const lastUpdate = ref('')
+const panels = ref([])
+const pollTimer = ref(null)
+
+const onlineCount = computed(() => panels.value.filter(p => p.status === 'online').length)
+
+const offlineCount = computed(() => panels.value.filter(p => p.status === 'offline').length)
+
+const alarmPanelCount = computed(() => panels.value.filter(p => p.alarm_count > 0).length)
+
+const sortedPanels = computed(() => {
+  return [...panels.value].sort((a, b) => {
+    const idA = Number(a.panel_id) || 0
+    const idB = Number(b.panel_id) || 0
+    return idA - idB
+  })
+})
+
+function getPanelClass(panel) {
+  if (panel.status === 'offline') return 'panel-offline'
+  if (panel.alarm_count > 0) return 'panel-alarm'
+  return 'panel-online'
+}
+
+function getStatusColor(status) {
+  return status === 'online' ? 'green' : 'default'
+}
+
+function getConnectPercent(panel) {
+  if (!panel.port_count) return 0
+  return Math.round((panel.connected_count / panel.port_count) * 100)
+}
+
+function getProgressColor(panel) {
+  if (panel.alarm_count > 0) return '#ff4d4f'
+  if (panel.status === 'offline') return '#d9d9d9'
+  return '#52c41a'
+}
+
+function formatUid(uid) {
+  if (!uid) return '--'
+  if (typeof uid === 'string' && uid.length === 8) {
+    return uid.match(/.{1,2}/g)?.join(':') || uid
+  }
+  return String(uid).substring(0, 8)
+}
+
+async function loadPanelStatus() {
+  try {
+    const res = await apiService.panel.getStatus()
+    if (res.data.success) {
+      panels.value = Object.values(res.data.panels || {})
+    }
+  } catch (e) {
+    console.error('load panel status error:', e)
+  }
+}
+
+async function refreshData() {
+  loading.value = true
+  try {
+    await loadPanelStatus()
+    lastUpdate.value = new Date().toLocaleTimeString()
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(async () => {
+  await refreshData()
+  pollTimer.value = setInterval(refreshData, 5000)
+})
+
+onUnmounted(() => {
+  if (pollTimer.value) clearInterval(pollTimer.value)
+})
+</script>
+
+<style scoped>
+.overview-bar {
+  display: flex;
+  align-items: center;
+  gap: 24px;
+  flex-wrap: wrap;
+}
+.overview-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.overview-label {
+  font-size: 14px;
+  color: #666;
+}
+.overview-value {
+  font-size: 14px;
+  color: #333;
+}
+.panel-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+  gap: 16px;
+}
+.panel-card {
+  border: 2px solid #f0f0f0;
+  border-radius: 12px;
+  padding: 16px;
+  transition: all 0.3s;
+}
+.panel-card:hover {
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+  transform: translateY(-2px);
+}
+.panel-online {
+  border-color: #b7eb8f;
+  background-color: #f6ffed;
+}
+.panel-offline {
+  border-color: #d9d9d9;
+  background-color: #fafafa;
+}
+.panel-alarm {
+  border-color: #ffccc7;
+  background-color: #fff2f0;
+  animation: pulse 1.5s infinite;
+}
+@keyframes pulse {
+  0%, 100% { opacity: 1; }
+  50% { opacity: 0.85; }
+}
+.panel-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+}
+.panel-id {
+  font-size: 16px;
+  font-weight: 600;
+  color: #333;
+}
+.panel-info {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+.info-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 13px;
+}
+.info-label {
+  color: #666;
+  min-width: 40px;
+}
+.info-value {
+  color: #333;
+}
+.uid-text {
+  font-family: monospace;
+  font-size: 12px;
+}
+.panel-stats {
+  display: flex;
+  justify-content: space-between;
+  padding: 8px 0;
+}
+.stat-item {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.stat-label {
+  font-size: 12px;
+  color: #999;
+}
+.stat-value {
+  font-size: 18px;
+  font-weight: 600;
+  color: #333;
+}
+.stat-value.success {
+  color: #52c41a;
+}
+.stat-value.error {
+  color: #ff4d4f;
+}
+.panel-progress {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-top: 8px;
+}
+.panel-progress :deep(.ant-progress) {
+  flex: 1;
+}
+.progress-text {
+  font-size: 12px;
+  color: #666;
+  min-width: 36px;
+  text-align: right;
+}
+</style>

+ 588 - 0
frontend/src/views/PortStatus.vue

@@ -0,0 +1,588 @@
+<template>
+  <div class="port-status">
+    <a-row :gutter="[24, 24]">
+      <!-- 顶部概览 -->
+      <a-col :span="24">
+        <a-card>
+          <div class="overview-bar">
+            <div class="overview-item">
+              <span class="overview-label">面板数量</span>
+              <a-tag color="blue">{{ panelCount }} 个</a-tag>
+            </div>
+            <div class="overview-item">
+              <span class="overview-label">端口总数</span>
+              <a-tag color="purple">{{ totalPorts }} 个</a-tag>
+            </div>
+            <div class="overview-item">
+              <span class="overview-label">告警端口</span>
+              <a-tag color="red">{{ alarmPortCount }} 个</a-tag>
+            </div>
+            <div class="overview-item">
+              <span class="overview-label">最近更新</span>
+              <span class="overview-value">{{ lastUpdate }}</span>
+            </div>
+            <div class="overview-item">
+              <a-button type="primary" size="small" @click="refreshData" :loading="loading">
+                刷新
+              </a-button>
+              <a-button size="small" @click="clearEvents" :style="{ marginLeft: '8px' }">
+                清除事件
+              </a-button>
+              <a-button size="small" type="dashed" @click="openSyncModal" :style="{ marginLeft: '8px' }">
+                同步映射
+              </a-button>
+            </div>
+          </div>
+        </a-card>
+      </a-col>
+
+      <!-- 同步映射弹窗 -->
+      <a-modal
+        v-model:open="syncModalVisible"
+        title="同步端口映射"
+        :confirm-loading="syncing"
+        @ok="handleSyncMapping"
+        @cancel="syncModalVisible = false"
+        width="600px"
+      >
+        <a-alert
+          message="同步映射说明"
+          description="将期望的UID映射到指定端口。同步后,系统会比较实际连接UID与期望UID,不匹配时触发告警。"
+          type="info"
+          show-icon
+          :style="{ marginBottom: '16px' }"
+        />
+        <a-tabs v-model:activeKey="syncMode">
+          <a-tab-pane key="single" tab="单个端口">
+            <a-form layout="vertical">
+              <a-row :gutter="16">
+                <a-col :span="8">
+                  <a-form-item label="面板ID">
+                    <a-input-number
+                      v-model:value="syncForm.panel_id"
+                      :min="1"
+                      style="width: 100%"
+                      placeholder="1"
+                    />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="8">
+                  <a-form-item label="端口ID">
+                    <a-input-number
+                      v-model:value="syncForm.port_id"
+                      :min="1"
+                      style="width: 100%"
+                      placeholder="1"
+                    />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="8">
+                  <a-form-item label="期望UID">
+                    <a-input
+                      v-model:value="syncForm.jumper_uid"
+                      placeholder="A1B2C3D4"
+                      :maxlength="8"
+                    />
+                  </a-form-item>
+                </a-col>
+              </a-row>
+              <a-button type="primary" @click="syncSinglePort" :loading="syncing">
+                同步单个端口
+              </a-button>
+            </a-form>
+          </a-tab-pane>
+          <a-tab-pane key="batch" tab="批量同步">
+            <a-alert
+              message="批量格式"
+              description="每行一个映射,格式: panel_id,port_id,jumper_uid (如: 1,1,A1B2C3D4)"
+              type="warning"
+              show-icon
+              :style="{ marginBottom: '12px' }"
+            />
+            <a-textarea
+              v-model:value="batchSyncText"
+              :rows="8"
+              placeholder="1,1,A1B2C3D4&#10;1,2,A1B2C3D5&#10;2,1,A1B2C3D6"
+            />
+            <a-button
+              type="primary"
+              @click="syncBatchPorts"
+              :loading="syncing"
+              :style="{ marginTop: '12px' }"
+            >
+              批量同步
+            </a-button>
+          </a-tab-pane>
+        </a-tabs>
+      </a-modal>
+
+      <!-- 端口状态面板 -->
+      <a-col :xs="24" :lg="14">
+        <a-card title="端口状态">
+          <a-empty v-if="Object.keys(portState).length === 0" description="暂无端口数据" />
+          <div v-else class="port-grid">
+            <div
+              v-for="(panelData, panelId) in sortedPortState"
+              :key="panelId"
+              class="panel-section"
+            >
+              <div class="panel-header">面板 {{ panelId }}</div>
+              <div class="ports-row">
+                <div
+                  v-for="(portData, portId) in panelData"
+                  :key="portId"
+                  class="port-cell"
+                  :class="getPortClass(portData)"
+                  :title="getPortTitle(panelId, portId, portData)"
+                >
+                  <div class="port-number">{{ portId }}</div>
+                  <div class="port-uid">{{ formatUid(portData.last_uid) }}</div>
+                </div>
+              </div>
+            </div>
+          </div>
+          <!-- 图例 -->
+          <div class="port-legend">
+            <div class="legend-item">
+              <span class="dot" :style="{ background: portColors.idle }"></span> 无设备
+            </div>
+            <div class="legend-item">
+              <span class="dot" :style="{ background: portColors.normal }"></span> 正常
+            </div>
+            <div class="legend-item">
+              <span class="dot" :style="{ background: portColors.alarm }"></span> 告警
+            </div>
+          </div>
+        </a-card>
+      </a-col>
+
+      <!-- 事件历史 -->
+      <a-col :xs="24" :lg="10">
+        <a-card title="事件历史">
+          <a-empty v-if="events.length === 0" description="暂无事件" />
+          <a-timeline v-else>
+            <a-timeline-item
+              v-for="(event, index) in recentEvents"
+              :key="index"
+              :color="getEventColor(event.event_type)"
+            >
+              <div class="event-item">
+                <div class="event-header">
+                  <a-tag :color="getEventTagColor(event.event_type)" size="small">
+                    {{ eventTypeText[event.event_type] || event.event_type }}
+                  </a-tag>
+                  <span class="event-time">{{ formatTime(event.timestamp) }}</span>
+                </div>
+                <div class="event-detail">
+                  面板 {{ event.panel_id }} 端口 {{ event.port_id }}
+                </div>
+                <div class="event-uid">
+                  UID: {{ formatUid(event.jumper_uid) }}
+                </div>
+              </div>
+            </a-timeline-item>
+          </a-timeline>
+        </a-card>
+      </a-col>
+    </a-row>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, onUnmounted } from 'vue'
+import { message } from 'ant-design-vue'
+import apiService from '../api/apiService'
+
+const loading = ref(false)
+const lastUpdate = ref('')
+const portState = ref({})
+const events = ref([])
+const pollTimer = ref(null)
+
+// 同步映射相关
+const syncModalVisible = ref(false)
+const syncing = ref(false)
+const syncMode = ref('single')
+const syncForm = ref({
+  panel_id: 1,
+  port_id: 1,
+  jumper_uid: ''
+})
+const batchSyncText = ref('')
+
+const portColors = {
+  idle: '#d9d9d9',
+  normal: '#52c41a',
+  alarm: '#ff4d4f'
+}
+
+const eventTypeText = {
+  'CONNECT': '连接',
+  'DISCONNECT': '断开',
+  'MOVE': '移动',
+  'ILLEGAL_CONNECT': '非法连接',
+  'ILLEGAL_DISCONNECT': '非法断开'
+}
+
+const panelCount = computed(() => Object.keys(portState.value).length)
+
+const totalPorts = computed(() => {
+  let count = 0
+  for (const panelData of Object.values(portState.value)) {
+    count += Object.keys(panelData).length
+  }
+  return count
+})
+
+const alarmPortCount = computed(() => {
+  let count = 0
+  for (const panelData of Object.values(portState.value)) {
+    for (const portData of Object.values(panelData)) {
+      if (portData.alarm_count > 0) {
+        count++
+      }
+    }
+  }
+  return count
+})
+
+const sortedPortState = computed(() => {
+  const sorted = {}
+  const panelIds = Object.keys(portState.value).sort((a, b) => Number(a) - Number(b))
+  for (const panelId of panelIds) {
+    sorted[panelId] = {}
+    const portIds = Object.keys(portState.value[panelId]).sort((a, b) => Number(a) - Number(b))
+    for (const portId of portIds) {
+      sorted[panelId][portId] = portState.value[panelId][portId]
+    }
+  }
+  return sorted
+})
+
+const recentEvents = computed(() => {
+  return events.value.slice(0, 20).reverse()
+})
+
+function getPortClass(portData) {
+  if (!portData.last_uid) return 'port-idle'
+  if (portData.alarm_count > 0) return 'port-alarm'
+  return 'port-normal'
+}
+
+function getPortTitle(panelId, portId, portData) {
+  const lines = [`面板 ${panelId} 端口 ${portId}`]
+  if (portData.last_uid) {
+    lines.push(`当前UID: ${portData.last_uid}`)
+  }
+  if (portData.expected_uid) {
+    lines.push(`期望UID: ${portData.expected_uid}`)
+  }
+  if (portData.alarm_count > 0) {
+    lines.push(`告警次数: ${portData.alarm_count}`)
+  }
+  return lines.join('\n')
+}
+
+function formatUid(uid) {
+  if (!uid) return '--'
+  if (typeof uid === 'string' && uid.length === 8) {
+    return uid.match(/.{1,2}/g)?.join(':') || uid
+  }
+  return uid.substring(0, 8)
+}
+
+function formatTime(timestamp) {
+  if (!timestamp) return ''
+  const date = new Date(timestamp)
+  return date.toLocaleTimeString()
+}
+
+function getEventColor(eventType) {
+  if (eventType?.includes('ILLEGAL')) return 'red'
+  if (eventType === 'CONNECT') return 'green'
+  if (eventType === 'DISCONNECT') return 'gray'
+  if (eventType === 'MOVE') return 'blue'
+  return 'blue'
+}
+
+function getEventTagColor(eventType) {
+  if (eventType?.includes('ILLEGAL')) return 'red'
+  if (eventType === 'CONNECT') return 'green'
+  if (eventType === 'DISCONNECT') return 'default'
+  if (eventType === 'MOVE') return 'blue'
+  return 'default'
+}
+
+async function loadPortStatus() {
+  try {
+    const res = await apiService.port.getStatus()
+    if (res.data.success) {
+      portState.value = res.data.port_state || {}
+    }
+  } catch (e) {
+    console.error('load port status error:', e)
+  }
+}
+
+async function loadEvents() {
+  try {
+    const res = await apiService.port.getEvents(100)
+    if (res.data.success) {
+      events.value = res.data.events || []
+    }
+  } catch (e) {
+    console.error('load events error:', e)
+  }
+}
+
+async function refreshData() {
+  loading.value = true
+  try {
+    await Promise.all([loadPortStatus(), loadEvents()])
+    lastUpdate.value = new Date().toLocaleTimeString()
+  } finally {
+    loading.value = false
+  }
+}
+
+async function clearEvents() {
+  try {
+    const res = await apiService.port.clearEvents()
+    if (res.data.success) {
+      message.success('事件已清除')
+      events.value = []
+    }
+  } catch (e) {
+    console.error('clear events error:', e)
+    message.error('清除失败')
+  }
+}
+
+function openSyncModal() {
+  syncModalVisible.value = true
+}
+
+async function syncSinglePort() {
+  if (!syncForm.value.jumper_uid) {
+    message.warning('请输入期望UID')
+    return
+  }
+  syncing.value = true
+  try {
+    const res = await apiService.dtu.sendControl({
+      command: 'SYNC_PORT_MAPPING',
+      params: {
+        target: 'port',
+        panel_id: syncForm.value.panel_id,
+        port_id: syncForm.value.port_id,
+        jumper_uid: syncForm.value.jumper_uid.toUpperCase()
+      }
+    })
+    if (res.data.success) {
+      message.success('端口映射已同步')
+      syncForm.value.jumper_uid = ''
+    } else {
+      message.error(res.data.message || '同步失败')
+    }
+  } catch (e) {
+    console.error('sync port mapping error:', e)
+    message.error('同步失败')
+  } finally {
+    syncing.value = false
+  }
+}
+
+async function syncBatchPorts() {
+  if (!batchSyncText.value.trim()) {
+    message.warning('请输入批量映射数据')
+    return
+  }
+  const lines = batchSyncText.value.trim().split('\n')
+  const mappings = []
+  for (const line of lines) {
+    const parts = line.split(',').map(p => p.trim())
+    if (parts.length >= 3) {
+      mappings.push({
+        panel_id: parseInt(parts[0]) || 1,
+        port_id: parseInt(parts[1]) || 1,
+        jumper_uid: parts[2].toUpperCase()
+      })
+    }
+  }
+  if (mappings.length === 0) {
+    message.warning('无效的映射数据')
+    return
+  }
+  syncing.value = true
+  try {
+    const res = await apiService.dtu.sendControl({
+      command: 'SYNC_ALL_MAPPING',
+      params: {
+        target: 'port',
+        mappings
+      }
+    })
+    if (res.data.success) {
+      message.success(`已同步 ${mappings.length} 个端口映射`)
+      batchSyncText.value = ''
+    } else {
+      message.error(res.data.message || '批量同步失败')
+    }
+  } catch (e) {
+    console.error('sync all mapping error:', e)
+    message.error('批量同步失败')
+  } finally {
+    syncing.value = false
+  }
+}
+
+async function handleSyncMapping() {
+  if (syncMode.value === 'single') {
+    await syncSinglePort()
+  } else {
+    await syncBatchPorts()
+  }
+  if (syncModalVisible.value) {
+    syncModalVisible.value = false
+  }
+}
+
+onMounted(async () => {
+  await refreshData()
+  pollTimer.value = setInterval(refreshData, 5000)
+})
+
+onUnmounted(() => {
+  if (pollTimer.value) clearInterval(pollTimer.value)
+})
+</script>
+
+<style scoped>
+.overview-bar {
+  display: flex;
+  align-items: center;
+  gap: 24px;
+  flex-wrap: wrap;
+}
+.overview-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.overview-label {
+  font-size: 14px;
+  color: #666;
+}
+.overview-value {
+  font-size: 14px;
+  color: #333;
+}
+.port-grid {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+.panel-section {
+  border: 1px solid #f0f0f0;
+  border-radius: 8px;
+  padding: 12px;
+}
+.panel-header {
+  font-weight: 600;
+  margin-bottom: 8px;
+  color: #333;
+}
+.ports-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.port-cell {
+  width: 64px;
+  height: 56px;
+  border-radius: 6px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  border: 2px solid rgba(0, 0, 0, 0.1);
+  cursor: default;
+  transition: all 0.2s;
+}
+.port-cell:hover {
+  transform: scale(1.05);
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+}
+.port-idle {
+  background-color: #f5f5f5;
+}
+.port-normal {
+  background-color: #f6ffed;
+  border-color: #b7eb8f;
+}
+.port-alarm {
+  background-color: #fff2f0;
+  border-color: #ffccc7;
+  animation: pulse 1.5s infinite;
+}
+@keyframes pulse {
+  0%, 100% { opacity: 1; }
+  50% { opacity: 0.7; }
+}
+.port-number {
+  font-size: 14px;
+  font-weight: 600;
+  color: #333;
+}
+.port-uid {
+  font-size: 9px;
+  color: #666;
+  margin-top: 2px;
+  max-width: 60px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.port-legend {
+  display: flex;
+  gap: 20px;
+  margin-top: 16px;
+  flex-wrap: wrap;
+}
+.legend-item {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 13px;
+  color: #666;
+}
+.dot {
+  display: inline-block;
+  width: 14px;
+  height: 14px;
+  border-radius: 50%;
+  border: 1px solid rgba(0, 0, 0, 0.1);
+}
+.event-item {
+  padding: 4px 0;
+}
+.event-header {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.event-time {
+  font-size: 12px;
+  color: #999;
+}
+.event-detail {
+  font-size: 13px;
+  color: #333;
+  margin-top: 4px;
+}
+.event-uid {
+  font-size: 12px;
+  color: #666;
+  font-family: monospace;
+}
+</style>