ops-console v1.0.2 (versionCode 8) 源码入库
This commit is contained in:
441
lib/widgets/device_command_sheet.dart
Normal file
441
lib/widgets/device_command_sheet.dart
Normal file
@@ -0,0 +1,441 @@
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user