import 'package:flutter/material.dart'; import '../models/device.dart'; import '../services/api_client.dart'; import '../services/device_service.dart'; import '../utils/i10n.dart'; /// 指令参数项。 /// /// [key] **必须与 Web 后台 `src/api/device.ts` 的 `commandTypeOptions` /// 参数 key 完全一致**,否则后端取不到值,设备端会因缺少参数而忽略该指令。 /// 当前约定: /// - `passwordUpdate` → `passwd` /// - `mediaZoneVol` → `vol` /// - `defaultVol` → `vol` + `maxVol` class _CommandParam { const _CommandParam(this.key, this.label, {this.numeric = false}); /// 提交给后端的参数名。 final String key; /// 输入框标签。 final String label; /// 是否为 0~100 的整数(音量类参数)。 final bool numeric; } /// 设备指令项。 class _CommandItem { const _CommandItem(this.value, this.label, {this.params = const []}); final String value; final String label; /// 需要用户填写的参数;为空表示无需输入,直接确认后下发。 final List<_CommandParam> params; bool get needsInput => params.isNotEmpty; } /// 设备指令下发弹窗。 /// /// 两种用法: /// 1. 单设备:传 [device],指令按 MAC 下发([DeviceService.sendCommandByMac]); /// 2. 门店批量:传 [orgId](不传 [device]),指令按组织下发 /// ([DeviceService.sendCommandByOrg]),影响该门店下全部设备。 class DeviceCommandSheet extends StatefulWidget { const DeviceCommandSheet({ super.key, this.device, this.orgId, this.orgName, required this.deviceService, }) : assert(device != null || orgId != null, 'device 与 orgId 至少传其一'); /// 单设备模式下的目标设备。 final Device? device; /// 门店批量模式下的组织 ID。 final String? orgId; /// 门店批量模式下的组织名称(仅用于展示)。 final String? orgName; final DeviceService deviceService; /// 是否为门店批量模式。 bool get isOrgMode => orgId != null && orgId!.isNotEmpty; @override State createState() => _DeviceCommandSheetState(); } class _DeviceCommandSheetState extends State { final List _paramControllers = []; bool _sending = false; /// 设备心跳超过该分钟数即视为「可能离线」,下发前给出提示。 static const int _heartbeatStaleMinutes = 3; List<_CommandItem> _commands(BuildContext context) { final t = AppLocalizations.of(context); return [ _CommandItem(DeviceCommand.powerOff, t.deviceCommandLabel(DeviceCommand.powerOff)), _CommandItem(DeviceCommand.reboot, t.deviceCommandLabel(DeviceCommand.reboot)), _CommandItem(DeviceCommand.screenshot, t.deviceCommandLabel(DeviceCommand.screenshot)), _CommandItem(DeviceCommand.themeUpdate, t.deviceCommandLabel(DeviceCommand.themeUpdate)), _CommandItem(DeviceCommand.versionUpdate, t.deviceCommandLabel(DeviceCommand.versionUpdate)), _CommandItem(DeviceCommand.marqueeUpdate, t.deviceCommandLabel(DeviceCommand.marqueeUpdate)), _CommandItem(DeviceCommand.weatherUpdate, t.deviceCommandLabel(DeviceCommand.weatherUpdate)), _CommandItem( DeviceCommand.mediaZoneVol, t.deviceCommandLabel(DeviceCommand.mediaZoneVol), params: [ _CommandParam('vol', t.deviceCommandInputLabel(DeviceCommand.mediaZoneVol), numeric: true), ], ), _CommandItem( DeviceCommand.defaultVol, t.deviceCommandLabel(DeviceCommand.defaultVol), params: [ _CommandParam('vol', t.deviceCommandInputLabel(DeviceCommand.defaultVol), numeric: true), _CommandParam('maxVol', t.maxVolume, numeric: true), ], ), _CommandItem(DeviceCommand.themeClear, t.deviceCommandLabel(DeviceCommand.themeClear)), _CommandItem(DeviceCommand.cacheClear, t.deviceCommandLabel(DeviceCommand.cacheClear)), _CommandItem(DeviceCommand.storeExpire, t.deviceCommandLabel(DeviceCommand.storeExpire)), _CommandItem(DeviceCommand.storeDisable, t.deviceCommandLabel(DeviceCommand.storeDisable)), _CommandItem( DeviceCommand.passwordUpdate, t.deviceCommandLabel(DeviceCommand.passwordUpdate), params: [ _CommandParam('passwd', t.deviceCommandInputLabel(DeviceCommand.passwordUpdate)), ], ), _CommandItem(DeviceCommand.worldAd, t.deviceCommandLabel(DeviceCommand.worldAd)), ]; } /// 设备是否「可能离线」:状态为 offline,或心跳时间早于阈值。 /// /// 用于下发前提示,不改变下发行为(仍可强制下发)。 bool _isLikelyOffline(Device device) { if (device.status == DeviceStatus.offline) return true; final heartbeat = device.lastSyncTime; if (heartbeat == null) return true; return DateTime.now().difference(heartbeat).inMinutes > _heartbeatStaleMinutes; } Future _send(_CommandItem item) async { final t = AppLocalizations.of(context); final data = {}; if (item.needsInput) { for (var i = 0; i < item.params.length; i++) { final param = item.params[i]; final value = _paramControllers[i].text.trim(); if (value.isEmpty) { // maxVol 允许留空(后端对该项有默认值),其余参数必填。 if (param.key == 'maxVol') continue; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('${t.pleaseInput}${param.label}')), ); return; } if (param.numeric) { final vol = int.tryParse(value); if (vol == null || vol < 0 || vol > 100) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(t.volumeRangeHint)), ); return; } } // 与 Web 端 `data[key] = item.value.trim()` 保持一致:统一传字符串, // 避免后端按 String 解析数字时抛类型转换异常。 data[param.key] = value; } } setState(() => _sending = true); try { if (widget.isOrgMode) { await widget.deviceService.sendCommandByOrg( orgId: widget.orgId!, command: item.value, data: data.isNotEmpty ? data : null, ); } else { final mac = widget.device!.mac.trim(); if (mac.isEmpty) { if (!mounted) return; setState(() => _sending = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(t.commandMacMissing)), ); return; } await widget.deviceService.sendCommandByMac( mac: mac, command: item.value, data: data.isNotEmpty ? data : null, ); } if (!mounted) return; Navigator.pop(context); final target = widget.isOrgMode ? '${t.belongStore}「${widget.orgName ?? widget.orgId}」' : '${t.deviceDetails}「${widget.device!.macLabel}」'; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(t.commandSent(target, item.label))), ); } catch (e) { if (!mounted) return; setState(() => _sending = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('${t.commandSendFailed}:${formatApiError(e)}')), ); } } @override void dispose() { for (final c in _paramControllers) { c.dispose(); } super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final t = AppLocalizations.of(context); final commands = _commands(context); return Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), child: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.isOrgMode ? t.sendOrgCommandTitle : t.sendCommandTitle, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 4), if (widget.isOrgMode) ...[ Text( '${t.belongStore}:${widget.orgName ?? widget.orgId}', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), Text( '${t.commandType}:${t.allStores}', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ] else ...[ Text( '${t.deviceDetails}:${widget.device!.macLabel}', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), Text( '${t.macAddress}:${widget.device!.mac}', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), Text( _buildHeartbeatLine(t), style: theme.textTheme.bodySmall?.copyWith( color: widget.device != null && _isLikelyOffline(widget.device!) ? theme.colorScheme.error : theme.colorScheme.onSurfaceVariant, ), ), ], const SizedBox(height: 16), Text( t.commandType, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: commands.map((item) { return ActionChip( label: Text(item.label), onPressed: _sending ? null : () => _onCommandTap(item), ); }).toList(), ), ], ), ), ); } String _buildHeartbeatLine(AppLocalizations t) { final device = widget.device; if (device == null) return ''; final heartbeat = device.lastSyncTime; if (heartbeat == null) { return '${t.lastActiveTime}:${t.deviceSubtitleNotSet}'; } final minutes = DateTime.now().difference(heartbeat).inMinutes; return '${t.lastActiveTime}:${_format(heartbeat)}($minutes${t.minutesAgo})'; } String _format(DateTime time) { String two(int v) => v.toString().padLeft(2, '0'); return '${time.year}-${two(time.month)}-${two(time.day)} ' '${two(time.hour)}:${two(time.minute)}'; } void _onCommandTap(_CommandItem item) { if (!item.needsInput) { _showConfirm(item); return; } _showInputDialog(item); } /// 下发前校验:单设备模式下若设备离线/心跳超时,先给出警告。 Future _confirmIfOffline() async { final device = widget.device; final t = AppLocalizations.of(context); if (device == null || !_isLikelyOffline(device)) return true; final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(t.commandConfirmTitle), content: Text(t.commandOfflineWarning), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text(t.cancel), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), child: Text(t.commandOfflineConfirm), ), ], ), ) ?? false; return ok; } void _showConfirm(_CommandItem item) async { final t = AppLocalizations.of(context); final proceed = await _confirmIfOffline(); if (!proceed || !mounted) return; showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(t.commandConfirmTitle), content: Text(t.commandConfirmContent(item.label)), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: Text(t.cancel), ), FilledButton( onPressed: _sending ? null : () { Navigator.pop(ctx); _send(item); }, child: _sending ? const SizedBox( height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : Text(t.confirm), ), ], ), ); } void _showInputDialog(_CommandItem item) async { final t = AppLocalizations.of(context); final proceed = await _confirmIfOffline(); if (!proceed || !mounted) return; for (final c in _paramControllers) { c.dispose(); } _paramControllers ..clear() ..addAll(List.generate(item.params.length, (_) => TextEditingController())); showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(item.label), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: List.generate(item.params.length, (i) { final param = item.params[i]; return Padding( padding: EdgeInsets.only( bottom: i == item.params.length - 1 ? 0 : 12, ), child: TextField( controller: _paramControllers[i], decoration: InputDecoration( labelText: param.label, border: const OutlineInputBorder(), ), keyboardType: param.numeric ? TextInputType.number : TextInputType.text, obscureText: param.key == 'passwd', ), ); }), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: Text(t.cancel), ), FilledButton( onPressed: _sending ? null : () { Navigator.pop(ctx); _send(item); }, child: _sending ? const SizedBox( height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : Text(t.sendCommand), ), ], ), ); } }