import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../models/device.dart'; import '../../models/org_node.dart'; import '../../providers/devices_provider.dart'; import '../../providers/metrics_visibility_provider.dart'; import '../../services/api_client.dart'; import '../../services/device_service.dart'; import '../../services/org_service.dart'; import '../../utils/formatters.dart'; import '../../utils/i10n.dart'; import '../../app/theme.dart'; import '../../utils/responsive.dart'; import '../../widgets/device_command_sheet.dart'; import '../../widgets/metric_card.dart'; import '../../widgets/status_chip.dart'; /// 设备详情页:指标卡(CPU/内存/磁盘/网络)、状态与运行时长。 class DeviceDetailPage extends ConsumerWidget { const DeviceDetailPage({super.key, required this.deviceId}); /// 设备 ID。 final String deviceId; @override Widget build(BuildContext context, WidgetRef ref) { final deviceAsync = ref.watch(deviceDetailProvider(deviceId)); final showMetrics = ref.watch(showRuntimeMetricsProvider); final t = AppLocalizations.of(context); return Scaffold( appBar: AppBar(automaticallyImplyLeading: false, title: Text(t.deviceDetails)), body: deviceAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text('$error'), const SizedBox(height: 8), FilledButton.tonal( onPressed: () => ref.refresh(deviceDetailProvider(deviceId)), child: Text(t.retry), ), ], ), ), data: (device) => Column( children: [ Expanded( child: RefreshIndicator( onRefresh: () async => ref.refresh(deviceDetailProvider(deviceId).future), child: ListView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(16), children: [ _buildHeader(context, ref, device), const SizedBox(height: 16), _buildBasicInfo(context, device), const SizedBox(height: 16), _buildConfigParams(context, device), const SizedBox(height: 16), _buildNetworkInfo(context, device), const SizedBox(height: 16), _buildRuntimeData(context, device), if (showMetrics) ...[ const SizedBox(height: 16), _buildMetricsGrid(context, device), ], ], ), ), ), _buildStickyActions(context, ref, device), ], ), ), ); } Widget _buildHeader(BuildContext context, WidgetRef ref, Device device) { return Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( device.macLabel, style: Theme.of(context) .textTheme .titleLarge ?.copyWith(fontWeight: FontWeight.bold), ), if (device.model.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 2), child: Text( '${AppLocalizations.of(context).modelLabel}:${device.model}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), ], ), ), StatusChip(status: device.status), ], ); } /// 底部常驻操作栏(发送指令 / 编辑分组),滚动时保持可见。 Widget _buildStickyActions( BuildContext context, WidgetRef ref, Device device, ) { final t = AppLocalizations.of(context); final scheme = Theme.of(context).colorScheme; return Container( decoration: BoxDecoration( color: scheme.surface, border: Border(top: BorderSide(color: scheme.outlineVariant)), boxShadow: AppShadows.sm, ), padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), child: SafeArea( top: false, child: Row( children: [ Expanded( child: FilledButton.tonal( onPressed: () => _showCommandSheet(context, device), child: Text(t.sendCommand), ), ), const SizedBox(width: 12), Expanded( child: FilledButton.tonal( onPressed: () => _showEditSheet(context, ref, device), child: Text(t.editGroup), ), ), ], ), ), ); } /// 信息行标签宽度随窗口尺寸自适应(窄屏 96 / 中屏 128 / 宽屏 160)。 double _labelWidth(BuildContext context) { final w = context.responsive.windowSize; return w == AppWindowSize.compact ? 96 : w == AppWindowSize.medium ? 128 : 160; } /// 修改设备分组 / 房号。 void _showEditSheet(BuildContext context, WidgetRef ref, Device device) { showModalBottomSheet( useSafeArea: true, context: context, isScrollControlled: true, builder: (ctx) => DeviceGroupRoomSheet( device: device, onSaved: (groupId, roomNo) async { Navigator.pop(ctx); try { await DeviceService().updateDevice({ 'id': device.id, // 后端 /device/info/update 要求 mac 非空,修改分组/房号时须回传原 MAC。 'mac': device.mac, 'groupId': groupId, 'roomId': roomNo, }); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context).saveSuccess)), ); ref.refresh(deviceDetailProvider(deviceId)); } } on ApiException catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(e.message)), ); } } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('${AppLocalizations.of(context).saveFailed}:${formatApiError(e)}')), ); } } }, ), ); } void _showCommandSheet(BuildContext context, Device device) { showModalBottomSheet( useSafeArea: true, context: context, isScrollControlled: true, builder: (ctx) => DeviceCommandSheet( device: device, deviceService: DeviceService(), ), ); } /// 带标题的分区块卡片。 Widget _sectionCard( BuildContext context, String title, List rows, ) { final theme = Theme.of(context); return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 8), const Divider(height: 1), const SizedBox(height: 8), ...rows, ], ), ), ); } /// 基本信息:设备名称 / 设备 ID / 型号 / 状态。 Widget _buildBasicInfo(BuildContext context, Device device) { final t = AppLocalizations.of(context); final theme = Theme.of(context); return _sectionCard(context, t.basicInfo, [ _infoRow(context, theme, t.deviceName, device.macLabel.isEmpty ? t.none : device.macLabel), _infoRow(context, theme, t.deviceId, device.id.isEmpty ? t.none : device.id), _infoRow(context, theme, t.modelLabel, device.model.isEmpty ? t.none : device.model), Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: _labelWidth(context), child: Text( t.status, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), Expanded(child: StatusChip(status: device.status)), ], ), ), ]); } /// 配置参数:所属门店 / 分组 / 房号 / 序列号 / UID / 创建时间。 Widget _buildConfigParams(BuildContext context, Device device) { final t = AppLocalizations.of(context); final theme = Theme.of(context); return _sectionCard(context, t.configParams, [ _infoRow(context, theme, t.belongStore, device.storeName.isEmpty ? t.none : device.storeName), _infoRow(context, theme, t.belongGroup, device.groupId.isEmpty ? t.notGrouped : device.groupId), _infoRow(context, theme, t.roomNo, device.roomNo.isEmpty ? t.none : device.roomNo), _infoRow(context, theme, t.deviceSnLabel, device.deviceSn.isEmpty ? t.none : device.deviceSn), _infoRow(context, theme, t.deviceUidLabel, device.deviceUid.isEmpty ? t.none : device.deviceUid), _infoRow(context, theme, t.createTime, device.createTime != null ? Formatters.formatDateTime(device.createTime!) : t.none), ]); } /// 网络信息:IP 地址 / MAC 地址。 Widget _buildNetworkInfo(BuildContext context, Device device) { final t = AppLocalizations.of(context); final theme = Theme.of(context); return _sectionCard(context, t.networkInfo, [ _infoRow(context, theme, t.ipAddress, device.ip.isEmpty ? t.none : device.ip), _infoRow(context, theme, t.macAddress, device.mac.isEmpty ? t.none : device.mac), ]); } /// 运行时数据:在线时长 / 最后活跃时间。 Widget _buildRuntimeData(BuildContext context, Device device) { final t = AppLocalizations.of(context); final theme = Theme.of(context); return _sectionCard(context, t.runtimeData, [ _infoRow(context, theme, t.uptime, device.uptimeSeconds > 0 ? t.formatUptime(device.uptimeSeconds) : t.none), _infoRow(context, theme, t.lastActiveTime, device.lastSyncTime != null ? Formatters.formatDateTime(device.lastSyncTime!) : t.none), ]); } Widget _infoRow(BuildContext context, ThemeData theme, String label, String value) { return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: _labelWidth(context), child: Text( label, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), Expanded( child: Text( value, style: theme.textTheme.bodyMedium ?.copyWith(fontWeight: FontWeight.w600), ), ), ], ), ); } Widget _buildMetricsGrid(BuildContext context, Device device) { final t = AppLocalizations.of(context); final metrics = device.metrics; // 真实后端不提供运行指标:无指标数据时显示 “—”(仅 mock 模式有值)。 final hasMetrics = device.hasMetrics; final none = t.none; return GridView.count( crossAxisCount: context.responsive.gridCount(minTileWidth: 300), shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), mainAxisSpacing: 8, crossAxisSpacing: 8, childAspectRatio: 1.15, children: [ MetricCard( icon: Icons.memory, title: t.cpuUsage, value: hasMetrics ? Formatters.formatPercent(metrics.cpuUsage) : none, progress: hasMetrics ? metrics.cpuUsage : null, accentColor: hasMetrics && metrics.cpuUsage > 0.8 ? Colors.red : null, ), MetricCard( icon: Icons.storage, title: t.memoryUsage, value: hasMetrics ? Formatters.formatPercent(metrics.memoryUsage) : none, progress: hasMetrics ? metrics.memoryUsage : null, accentColor: hasMetrics && metrics.memoryUsage > 0.85 ? Colors.red : null, ), MetricCard( icon: Icons.sd_storage, title: t.diskUsage, value: hasMetrics ? Formatters.formatPercent(metrics.diskUsage) : none, progress: hasMetrics ? metrics.diskUsage : null, accentColor: hasMetrics && metrics.diskUsage > 0.8 ? Colors.orange : null, ), MetricCard( icon: Icons.network_check, title: t.networkRxTx, value: hasMetrics ? '${metrics.networkRxKbps.toStringAsFixed(1)} / ' '${metrics.networkTxKbps.toStringAsFixed(1)} KB/s' : none, ), ], ); } } /// 修改设备分组 / 房号底部表单。 /// /// 分组改为「分组名称」下拉选择(与 Web 端一致):进入时按设备所属门店调用 /// `/sys/store/deviceGroups/{storeId}` 拉取该门店下专属分组,下拉显示 [OrgNode.name], /// 提交 [OrgNode.id]。房号仍保留文本输入。 class DeviceGroupRoomSheet extends StatefulWidget { const DeviceGroupRoomSheet({ super.key, required this.device, required this.onSaved, }); final Device device; final Future Function(String groupId, String roomNo) onSaved; @override State createState() => _DeviceGroupRoomSheetState(); } class _DeviceGroupRoomSheetState extends State { final _formKey = GlobalKey(); late final TextEditingController _roomNoCtrl; bool _saving = false; /// 当前门店下的设备分组列表。 List _groups = const []; bool _groupsLoading = false; String? _groupsError; /// 选中的分组 ID(null 表示「未分组」)。 String? _selectedGroupId; @override void initState() { super.initState(); _roomNoCtrl = TextEditingController(text: widget.device.roomNo); _selectedGroupId = widget.device.groupId.isEmpty ? null : widget.device.groupId; _loadGroups(); } @override void dispose() { _roomNoCtrl.dispose(); super.dispose(); } /// 拉取当前门店下的设备分组列表。 /// /// 优先使用 `/device/group/list?storeId=xxx`,该接口支持按门店筛选且已在 /// [DeviceService] 中实现;若调用失败则降级为读取「当前分组」一项,避免阻塞 /// 用户修改房号或保存现有分组。 Future _loadGroups() async { if (widget.device.storeId.isEmpty) { if (mounted) setState(() => _groupsLoading = false); return; } if (mounted) setState(() => _groupsLoading = true); try { _groups = await DeviceService().getDeviceGroupList( storeId: widget.device.storeId, ); _groupsError = null; } on ApiException catch (e) { _groupsError = e.message; _groups = const []; } catch (e) { _groupsError = formatApiError(e, fallback: '分组加载失败,请检查网络'); _groups = const []; } finally { if (mounted) setState(() => _groupsLoading = false); } } /// 构造下拉项:首项「未分组」,随后各分组(显示名称,值为 ID)。 /// 若当前分组不在列表中(如后端未返回或跨门店),追加一项以便正确回显。 List> _buildGroupItems(AppLocalizations t) { final items = >[ DropdownMenuItem( value: '', child: Text(t.notGrouped), ), ]; final seen = {''}; for (final group in _groups) { if (seen.contains(group.id)) continue; seen.add(group.id); items.add( DropdownMenuItem( value: group.id, child: Text(group.name.isNotEmpty ? group.name : group.id), ), ); } final current = widget.device.groupId; if (current.isNotEmpty && !seen.contains(current)) { items.add( DropdownMenuItem( value: current, child: Text(current), ), ); } return items; } Future _submit() async { if (!_formKey.currentState!.validate()) return; setState(() => _saving = true); try { await widget.onSaved( _selectedGroupId ?? '', _roomNoCtrl.text.trim(), ); } finally { if (mounted) setState(() => _saving = false); } } @override Widget build(BuildContext context) { final t = AppLocalizations.of(context); return SingleChildScrollView( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), child: Container( padding: const EdgeInsets.all(20), child: Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${t.editGroup} / ${t.roomNo}', style: Theme.of(context) .textTheme .titleLarge ?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 16), // 所属分组:名称下拉选择(提交分组 ID)。 DropdownButtonFormField( value: _selectedGroupId ?? '', isExpanded: true, decoration: InputDecoration( labelText: t.belongGroup, border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.dvr), ), items: _buildGroupItems(t), onChanged: _groupsLoading ? null : (value) => setState(() => _selectedGroupId = value), ), const SizedBox(height: 8), if (_groupsLoading) Padding( padding: const EdgeInsets.only(left: 4), child: Row( children: [ const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(width: 8), Text(t.groupListLoading), ], ), ) else if (_groupsError != null) Padding( padding: const EdgeInsets.only(left: 4), child: Text( '${t.loadFailed}:$_groupsError', style: const TextStyle(color: Colors.redAccent, fontSize: 12), ), ), const SizedBox(height: 12), TextFormField( controller: _roomNoCtrl, decoration: InputDecoration( labelText: t.roomNo, border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.meeting_room), ), ), const SizedBox(height: 24), SizedBox( width: double.infinity, child: FilledButton( onPressed: _saving ? null : _submit, child: _saving ? const SizedBox( height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : Text(t.save), ), ), ], ), ), ), ); } }