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

442 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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),
),
],
),
);
}
}