Files
CloudHome-Ops/lib/widgets/device_command_sheet.dart

442 lines
15 KiB
Dart
Raw Permalink Normal View History

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<DeviceCommandSheet> createState() => _DeviceCommandSheetState();
}
class _DeviceCommandSheetState extends State<DeviceCommandSheet> {
final List<TextEditingController> _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<void> _send(_CommandItem item) async {
final t = AppLocalizations.of(context);
final data = <String, dynamic>{};
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<bool> _confirmIfOffline() async {
final device = widget.device;
final t = AppLocalizations.of(context);
if (device == null || !_isLikelyOffline(device)) return true;
final ok = await showDialog<bool>(
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<void>(
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<void>(
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),
),
],
),
);
}
}