442 lines
15 KiB
Dart
442 lines
15 KiB
Dart
|
|
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),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|