import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:web_frontend/controllers/ControlPanelController.dart'; import 'package:web_frontend/models/LogType.dart'; import 'package:web_frontend/models/UserInfo.dart'; import 'package:web_frontend/services/WebSocketService.dart'; class ControlPanelView extends StatelessWidget { ControlPanelView({super.key}); final GlobalKey _dropdownButtonKey = GlobalKey(); @override Widget build(BuildContext context) { final controller = Get.put(ControlPanelController()); final webSocketService = Get.find(); return Scaffold( body: Row( children: [ // 左侧边栏 - 占15%宽度 _buildSidebar(controller, context), // 右侧主区域 - 占85%宽度 Expanded( child: _buildMainArea(controller, webSocketService), ), ], ), ); } Widget _buildSidebar( ControlPanelController controller, BuildContext context) { return Container( width: MediaQuery.of(context).size.width * 0.15, color: Colors.grey[900], child: Column( children: [ // 用户选择区域 _buildUserSelection(controller, context), // 模糊查询输入框 _buildSearchField(controller), // 信号强度过滤 _buildRssiFilter(controller), // 设备统计 _buildDeviceCount(controller), const SizedBox(height: 8), // 设备列表 - 使用 Expanded 让它占据剩余空间 Expanded( child: _buildDeviceList(controller), ), ], ), ); } Widget _buildUserSelection( ControlPanelController controller, BuildContext context) { return Container( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '当前用户', style: TextStyle(color: Colors.white, fontSize: 12), ), const SizedBox(height: 8), Obx(() { if (controller.users.isEmpty) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: Colors.grey[800], borderRadius: BorderRadius.circular(8), ), child: const Text( '暂无在线设备', style: TextStyle(color: Colors.grey, fontSize: 12), ), ); } // 使用 InkWell 实现可点击的选择器 return InkWell( key: _dropdownButtonKey, onTap: () => _showCustomUserMenu(controller, context), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: Colors.grey[800], borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey[700]!), ), child: Row( children: [ Expanded( child: Text( controller.selectedUser.value != null ? '${controller.selectedUser.value!.deviceModel} (${_getShortUuid(controller.selectedUser.value!.uuid)})' : '选择设备', style: TextStyle( color: controller.selectedUser.value != null ? Colors.white : Colors.grey, fontSize: 12, ), overflow: TextOverflow.ellipsis, ), ), const Icon(Icons.arrow_drop_down, color: Colors.grey, size: 20), ], ), ), ); }), // 添加连接状态提示 Obx(() => Padding( padding: const EdgeInsets.only(top: 8), child: Row( children: [ Icon( controller.webSocketService.isConnected.value ? Icons.wifi : Icons.wifi_off, size: 12, color: controller.webSocketService.isConnected.value ? Colors.green : Colors.red, ), const SizedBox(width: 4), Text( controller.webSocketService.connectionStatus.value, style: TextStyle( color: controller.webSocketService.isConnected.value ? Colors.green : Colors.red, fontSize: 10, ), ), ], ), )), ], ), ); } // 构建 PopupMenu 的菜单项 List> _buildPopupMenuItems( ControlPanelController controller, BuildContext context) { // 使用 StatefulBuilder 来实现搜索功能 final List> entries = []; // 添加搜索框 entries.add( PopupMenuItem( enabled: false, padding: EdgeInsets.zero, child: Container( width: 280, padding: const EdgeInsets.all(8), child: TextField( autofocus: true, style: const TextStyle(color: Colors.white, fontSize: 12), decoration: InputDecoration( hintText: '搜索设备...', hintStyle: const TextStyle(color: Colors.grey, fontSize: 12), prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey), filled: true, fillColor: Colors.grey[800], border: OutlineInputBorder( borderRadius: BorderRadius.circular(6), borderSide: BorderSide.none, ), contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), ), onChanged: (value) { // 这里需要通过 StatefulBuilder 来更新 // 由于 PopupMenuButton 不支持动态更新,我们使用另一种方式 }, ), ), ), ); // 添加分隔线 entries.add(const PopupMenuDivider()); // 添加设备列表 final users = controller.users; for (var user in users) { final isSelected = controller.selectedUser.value?.uuid == user.uuid; entries.add( PopupMenuItem( value: user, padding: EdgeInsets.zero, child: Container( width: 280, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: isSelected ? Colors.grey[800] : null, ), child: Row( children: [ Icon( Icons.devices, size: 16, color: isSelected ? Colors.green : Colors.grey, ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( user.deviceModel, style: TextStyle( color: isSelected ? Colors.green : Colors.grey[500], fontSize: 12, ), ), Text( // _getShortUuid(user.uuid), user.uuid, style: TextStyle( color: Colors.grey[500], fontSize: 10, ), ), ], ), ), if (isSelected) const Icon(Icons.check_circle, size: 16, color: Colors.green), ], ), ), ), ); } return entries; } String _getShortUuid(String uuid) { if (uuid.length >= 8) { return '${uuid.substring(0, 8)}...'; } return uuid; } Widget _buildSearchField(ControlPanelController controller) { return Padding( padding: const EdgeInsets.all(16), child: TextField( style: const TextStyle(color: Colors.white), decoration: InputDecoration( hintText: '模糊查询...', hintStyle: const TextStyle(color: Colors.grey), prefixIcon: const Icon(Icons.search, color: Colors.grey), filled: true, fillColor: Colors.grey[800], border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none, ), ), onChanged: (value) => controller.searchKeyword.value = value, ), ); } // 信号强度过滤组件 Widget _buildRssiFilter(ControlPanelController controller) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '信号强度过滤', style: TextStyle(color: Colors.grey, fontSize: 12), ), const SizedBox(height: 16), LayoutBuilder( builder: (context, constraints) { return Obx(() { final double minValue = -100; final double maxValue = 65; final double valueRange = maxValue - minValue; final double percent = (controller.rssiFilterValue.value - minValue) / valueRange; final double thumbPosition = percent * constraints.maxWidth; return Column( children: [ // 滑块区域 GestureDetector( onHorizontalDragUpdate: (details) { final newPercent = (thumbPosition + details.delta.dx) / constraints.maxWidth; final newPercentClamped = newPercent.clamp(0.0, 1.0); final newValue = minValue + (newPercentClamped * valueRange); controller.updateRssiFilter(newValue.round()); }, onTapDown: (details) { final localX = details.localPosition.dx; final newPercent = (localX / constraints.maxWidth).clamp(0.0, 1.0); final newValue = minValue + (newPercent * valueRange); controller.updateRssiFilter(newValue.round()); }, child: Container( height: 40, child: Stack( alignment: Alignment.centerLeft, children: [ // 背景线(灰色) Container( height: 3, width: double.infinity, decoration: BoxDecoration( borderRadius: BorderRadius.circular(1.5), color: Colors.grey[600], ), ), // 左侧绿色线(从起点到圆点位置) Container( width: thumbPosition, height: 3, decoration: BoxDecoration( borderRadius: BorderRadius.circular(1.5), color: Colors.green, ), ), // 白色实心圆 Positioned( left: thumbPosition - 12, child: Container( width: 24, height: 24, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.white, border: Border.all( color: Colors.green, width: 2, ), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.2), blurRadius: 4, offset: const Offset(0, 2), ), ], ), ), ), ], ), ), ), const SizedBox(height: 16), // 强度值显示和范围标签 Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( '-100 dBm', style: TextStyle(color: Colors.grey, fontSize: 10), ), Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 4), decoration: BoxDecoration( color: Colors.green.withOpacity(0.2), borderRadius: BorderRadius.circular(12), border: Border.all( color: Colors.green, width: 1, ), ), child: Text( '${controller.rssiFilterValue.value} dBm', style: const TextStyle( color: Colors.green, fontSize: 12, fontWeight: FontWeight.bold, ), ), ), const Text( '65 dBm', style: TextStyle(color: Colors.grey, fontSize: 10), ), ], ), ], ); }); }, ), ], ), ); } Color _getRssiColor(int rssi) { if (rssi >= -50) return Colors.green; if (rssi >= -70) return Colors.lightGreen; if (rssi >= -80) return Colors.yellow.shade700; if (rssi >= -90) return Colors.orange; return Colors.red; } Widget _buildDeviceCount(ControlPanelController controller) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Align( alignment: Alignment.centerLeft, child: Obx(() => Text( '匹配出 ${controller.filteredDevices.length} 个设备', style: const TextStyle(color: Colors.grey, fontSize: 12), )), ), ); } Widget _buildDeviceList(ControlPanelController controller) { return Obx(() { if (controller.selectedUser.value == null) { return const Center( child: Text( '请先选择设备', style: TextStyle(color: Colors.grey), ), ); } if (controller.filteredDevices.isEmpty) { return const Center( child: Text( '暂无设备,请点击"扫描"按钮', style: TextStyle(color: Colors.grey), ), ); } return ListView.builder( itemCount: controller.filteredDevices.length, itemBuilder: (context, index) { final device = controller.filteredDevices[index]; return ListTile( title: Text( device.name.isEmpty ? '未知设备' : device.name, style: const TextStyle(color: Colors.white, fontSize: 13), ), subtitle: Text( device.id, style: const TextStyle(color: Colors.grey, fontSize: 10), ), trailing: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: _getRssiColor(device.rssi), borderRadius: BorderRadius.circular(4), ), child: Text( '${device.rssi} dBm', style: const TextStyle(color: Colors.white, fontSize: 10), ), ), onTap: () => controller.connectToDevice(device), ); }, ); }); } Widget _buildMainArea( ControlPanelController controller, WebSocketService webSocketService) { return Column( children: [ _buildMenuBar(webSocketService), _buildToolBar(controller), _buildLogFilterBar(controller), Expanded( child: _buildLogArea(controller), ), ], ); } Widget _buildMenuBar(WebSocketService webSocketService) { return Container( height: 48, color: Colors.grey[100], padding: const EdgeInsets.symmetric(horizontal: 24), child: Row( children: [ const Text( '指令和日志', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), ), const Spacer(), Obx(() => Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: webSocketService.isConnected.value ? Colors.green.withOpacity(0.2) : Colors.red.withOpacity(0.2), borderRadius: BorderRadius.circular(16), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 8, height: 8, decoration: BoxDecoration( shape: BoxShape.circle, color: webSocketService.isConnected.value ? Colors.green : Colors.red, ), ), const SizedBox(width: 8), Text( webSocketService.connectionStatus.value, style: TextStyle( color: webSocketService.isConnected.value ? Colors.green : Colors.red, fontSize: 12, ), ), ], ), )), ], ), ); } Widget _buildToolBar(ControlPanelController controller) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( border: Border(bottom: BorderSide(color: Colors.grey[300]!)), ), child: Row( children: [ // 扫描按钮 ElevatedButton.icon( onPressed: () { if (controller.selectedUser.value == null) { controller.addLog('请先选择设备', LogType.warning); return; } controller.sendScanCommand(); }, icon: const Icon(Icons.bluetooth_searching, size: 18), label: const Text('扫描'), style: ElevatedButton.styleFrom( backgroundColor: Colors.blue, foregroundColor: Colors.white, ), ), const SizedBox(width: 8), // 停止扫描按钮 OutlinedButton.icon( onPressed: () { if (controller.selectedUser.value == null) { controller.addLog('请先选择设备', LogType.warning); return; } controller.sendStopScanCommand(); }, icon: const Icon(Icons.stop, size: 18), label: const Text('停止扫描'), ), const SizedBox(width: 8), // 断开设备按钮 OutlinedButton.icon( onPressed: () { if (controller.selectedUser.value == null) { controller.addLog('请先选择设备', LogType.warning); return; } controller.disconnectDevice(); }, icon: const Icon(Icons.bluetooth_disabled, size: 18), label: const Text('断开设备'), style: OutlinedButton.styleFrom( foregroundColor: Colors.red, ), ), const Spacer(), // 命令输入框和执行按钮 SizedBox( width: 300, child: TextField( controller: controller.commandController, decoration: InputDecoration( hintText: '输入命令或数据...', border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), suffixIcon: IconButton( icon: const Icon(Icons.send), onPressed: () { if (controller.selectedUser.value == null) { controller.addLog('请先选择设备', LogType.warning); return; } controller.sendCustomData(); }, tooltip: '执行', ), ), onSubmitted: (_) { if (controller.selectedUser.value == null) { controller.addLog('请先选择设备', LogType.warning); return; } controller.sendCustomData(); }, ), ), const SizedBox(width: 16), // 自动滚动开关 Row( children: [ const Text('自动滚动'), const SizedBox(width: 8), Obx(() => Switch( value: controller.autoScroll.value, onChanged: controller.toggleAutoScroll, )), ], ), const SizedBox(width: 16), // 清空日志按钮 ElevatedButton( onPressed: controller.clearLogs, style: ElevatedButton.styleFrom( backgroundColor: Colors.red, foregroundColor: Colors.white, ), child: const Text('清空日志'), ), ], ), ); } Widget _buildLogFilterBar(ControlPanelController controller) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: Colors.grey[100], border: Border(bottom: BorderSide(color: Colors.grey[300]!)), ), child: Row( children: [ const Text('日志过滤器:', style: TextStyle(fontSize: 12)), const SizedBox(width: 16), Obx(() => FilterChip( label: const Text('全部'), selected: controller.showAllLogs.value, onSelected: controller.toggleShowAllLogs, backgroundColor: Colors.white, selectedColor: Colors.blue.shade100, )), const SizedBox(width: 8), Obx(() => FilterChip( label: const Text('连接日志'), selected: !controller.showAllLogs.value && controller.showConnectionLogs.value, onSelected: (selected) { if (!controller.showAllLogs.value) { controller.toggleShowConnectionLogs(selected); } }, backgroundColor: Colors.white, selectedColor: Colors.green.shade100, )), const SizedBox(width: 8), Obx(() => FilterChip( label: const Text('设备日志'), selected: !controller.showAllLogs.value && controller.showDeviceLogs.value, onSelected: (selected) { if (!controller.showAllLogs.value) { controller.toggleShowDeviceLogs(selected); } }, backgroundColor: Colors.white, selectedColor: Colors.orange.shade100, )), ], ), ); } Widget _buildLogArea(ControlPanelController controller) { return Container( color: Colors.grey[50], child: Obx(() { final logs = controller.displayLogs; if (logs.isEmpty) { return const Center( child: Text('暂无日志'), ); } return ListView.builder( controller: controller.logScrollController, padding: const EdgeInsets.all(16), itemCount: logs.length, itemBuilder: (context, index) { final log = logs[index]; return _buildLogItem(log); }, ); }), ); } Widget _buildLogItem(LogEntry log) { Color textColor; String prefix; IconData? icon; switch (log.type) { case LogType.success: textColor = Colors.green; prefix = '[SUCCESS]'; icon = Icons.check_circle_outline; break; case LogType.error: textColor = Colors.red; prefix = '[ERROR]'; icon = Icons.error_outline; break; case LogType.warning: textColor = Colors.orange; prefix = '[WARNING]'; icon = Icons.warning_amber_outlined; break; case LogType.device: textColor = Colors.purple; prefix = '[DEVICE]'; icon = Icons.devices; break; case LogType.connection: textColor = Colors.blue; prefix = '[CONN]'; icon = Icons.wifi; break; default: textColor = Colors.black87; prefix = '[INFO]'; icon = Icons.info_outline; } final timeStr = '${log.time.hour.toString().padLeft(2, '0')}:${log.time.minute.toString().padLeft(2, '0')}:${log.time.second.toString().padLeft(2, '0')}'; return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (icon != null) ...[ Icon(icon, size: 14, color: textColor), const SizedBox(width: 8), ], SizedBox( width: 80, child: Text( timeStr, style: const TextStyle( color: Colors.grey, fontSize: 12, fontFamily: 'monospace', ), ), ), const SizedBox(width: 12), SizedBox( width: 80, child: Text( prefix, style: TextStyle( color: textColor, fontSize: 12, fontWeight: FontWeight.bold, ), ), ), const SizedBox(width: 12), Expanded( child: Text( log.message, style: TextStyle( color: textColor, fontSize: 12, fontFamily: 'monospace', ), ), ), ], ), ); } void _showCustomUserMenu( ControlPanelController controller, BuildContext context) { // 获取按钮的位置和大小 final RenderBox renderBox = _dropdownButtonKey.currentContext!.findRenderObject() as RenderBox; final Offset offset = renderBox.localToGlobal(Offset.zero); final Size buttonSize = renderBox.size; // 搜索控制器 final searchController = TextEditingController(); showGeneralDialog( context: context, barrierDismissible: true, barrierLabel: "Dismiss", barrierColor: Colors.black.withOpacity(0.5), transitionDuration: const Duration(milliseconds: 200), pageBuilder: (context, animation, secondaryAnimation) { // 使用 StatefulBuilder 来管理弹窗内部状态 return StatefulBuilder( builder: (dialogContext, setDialogState) { // 初始化过滤用户列表 List filteredUsers = controller.users.toList(); void updateFilter(String value) { setDialogState(() { if (value.isEmpty) { filteredUsers = controller.users.toList(); } else { filteredUsers = controller.users .where((user) => user.deviceModel .toLowerCase() .contains(value.toLowerCase()) || user.uuid.toLowerCase().contains(value.toLowerCase())) .toList(); } }); } return Stack( children: [ // 点击背景关闭 Positioned.fill( child: GestureDetector( onTap: () => Navigator.of(dialogContext).pop(), child: Container( color: Colors.transparent, ), ), ), // 菜单内容 - 宽度与按钮相同 Positioned( left: offset.dx, top: offset.dy + buttonSize.height + 2, width: buttonSize.width, child: Material( elevation: 8, borderRadius: BorderRadius.circular(8), color: Colors.grey[850], child: Column( mainAxisSize: MainAxisSize.min, children: [ // 搜索框 Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( border: Border( bottom: BorderSide( color: Colors.grey[700]!, width: 1, ), ), ), child: TextField( controller: searchController, autofocus: true, style: const TextStyle( color: Colors.white, fontSize: 12, ), decoration: InputDecoration( hintText: '搜索设备名称或UUID...', hintStyle: const TextStyle( color: Colors.grey, fontSize: 12, ), prefixIcon: const Icon( Icons.search, size: 18, color: Colors.grey, ), filled: true, fillColor: Colors.grey[800], border: OutlineInputBorder( borderRadius: BorderRadius.circular(6), borderSide: BorderSide.none, ), contentPadding: const EdgeInsets.symmetric( horizontal: 8, vertical: 8), suffixIcon: searchController.text.isNotEmpty ? IconButton( icon: const Icon(Icons.clear, size: 16), color: Colors.grey, onPressed: () { searchController.clear(); updateFilter(''); }, ) : null, ), onChanged: (value) { updateFilter(value); }, ), ), // 用户列表 Flexible( child: ConstrainedBox( constraints: BoxConstraints( maxHeight: 300, minHeight: 100, ), child: Builder( builder: (context) { // 重新获取过滤后的列表 List currentFilteredUsers; if (searchController.text.isEmpty) { currentFilteredUsers = controller.users.toList(); } else { currentFilteredUsers = controller.users .where((user) => user.deviceModel .toLowerCase() .contains(searchController.text .toLowerCase()) || user.uuid.toLowerCase().contains( searchController.text .toLowerCase())) .toList(); } if (currentFilteredUsers.isEmpty) { return const Center( child: Padding( padding: EdgeInsets.all(16), child: Text( '未找到匹配的设备', style: TextStyle( color: Colors.grey, fontSize: 12, ), ), ), ); } return ListView.builder( shrinkWrap: true, itemCount: currentFilteredUsers.length, itemBuilder: (context, index) { final user = currentFilteredUsers[index]; final isSelected = controller.selectedUser.value?.uuid == user.uuid; return InkWell( onTap: () { controller.selectUser(user); Navigator.of(dialogContext).pop(); }, child: Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10), decoration: BoxDecoration( color: isSelected ? Colors.grey[800] : Colors.transparent, border: isSelected ? null : Border( bottom: BorderSide( color: Colors.grey[800]!, width: 0.5, ), ), ), child: Row( children: [ Icon( Icons.devices, size: 16, color: isSelected ? Colors.green : Colors.grey[500], ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( user.deviceModel, style: TextStyle( color: isSelected ? Colors.green : Colors.white, fontSize: 12, ), ), Text( user.uuid, style: TextStyle( color: Colors.grey[500], fontSize: 10, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), if (isSelected) const Icon( Icons.check_circle, size: 16, color: Colors.green, ), ], ), ), ); }, ); }, ), ), ), ], ), ), ), ], ); }, ); }, ); } }