Files
CloudHome-Ops/lib/screens/home/device_detail_page.dart

619 lines
20 KiB
Dart
Raw Normal View History

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../models/device.dart';
import '../../models/org_node.dart';
import '../../providers/devices_provider.dart';
import '../../providers/metrics_visibility_provider.dart';
import '../../services/api_client.dart';
import '../../services/device_service.dart';
import '../../services/org_service.dart';
import '../../utils/formatters.dart';
import '../../utils/i10n.dart';
import '../../app/theme.dart';
import '../../utils/responsive.dart';
import '../../widgets/device_command_sheet.dart';
import '../../widgets/metric_card.dart';
import '../../widgets/status_chip.dart';
/// 设备详情页:指标卡(CPU/内存/磁盘/网络)、状态与运行时长。
class DeviceDetailPage extends ConsumerWidget {
const DeviceDetailPage({super.key, required this.deviceId});
/// 设备 ID。
final String deviceId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final deviceAsync = ref.watch(deviceDetailProvider(deviceId));
final showMetrics = ref.watch(showRuntimeMetricsProvider);
final t = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false, title: Text(t.deviceDetails)),
body: deviceAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('$error'),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: () =>
ref.refresh(deviceDetailProvider(deviceId)),
child: Text(t.retry),
),
],
),
),
data: (device) => Column(
children: [
Expanded(
child: RefreshIndicator(
onRefresh: () async =>
ref.refresh(deviceDetailProvider(deviceId).future),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
children: [
_buildHeader(context, ref, device),
const SizedBox(height: 16),
_buildBasicInfo(context, device),
const SizedBox(height: 16),
_buildConfigParams(context, device),
const SizedBox(height: 16),
_buildNetworkInfo(context, device),
const SizedBox(height: 16),
_buildRuntimeData(context, device),
if (showMetrics) ...[
const SizedBox(height: 16),
_buildMetricsGrid(context, device),
],
],
),
),
),
_buildStickyActions(context, ref, device),
],
),
),
);
}
Widget _buildHeader(BuildContext context, WidgetRef ref, Device device) {
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
device.macLabel,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
if (device.model.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'${AppLocalizations.of(context).modelLabel}:${device.model}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
],
),
),
StatusChip(status: device.status),
],
);
}
/// 底部常驻操作栏(发送指令 / 编辑分组),滚动时保持可见。
Widget _buildStickyActions(
BuildContext context,
WidgetRef ref,
Device device,
) {
final t = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: scheme.surface,
border: Border(top: BorderSide(color: scheme.outlineVariant)),
boxShadow: AppShadows.sm,
),
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
child: SafeArea(
top: false,
child: Row(
children: [
Expanded(
child: FilledButton.tonal(
onPressed: () => _showCommandSheet(context, device),
child: Text(t.sendCommand),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.tonal(
onPressed: () => _showEditSheet(context, ref, device),
child: Text(t.editGroup),
),
),
],
),
),
);
}
/// 信息行标签宽度随窗口尺寸自适应(窄屏 96 / 中屏 128 / 宽屏 160)。
double _labelWidth(BuildContext context) {
final w = context.responsive.windowSize;
return w == AppWindowSize.compact
? 96
: w == AppWindowSize.medium
? 128
: 160;
}
/// 修改设备分组 / 房号。
void _showEditSheet(BuildContext context, WidgetRef ref, Device device) {
showModalBottomSheet(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (ctx) => DeviceGroupRoomSheet(
device: device,
onSaved: (groupId, roomNo) async {
Navigator.pop(ctx);
try {
await DeviceService().updateDevice({
'id': device.id,
// 后端 /device/info/update 要求 mac 非空,修改分组/房号时须回传原 MAC。
'mac': device.mac,
'groupId': groupId,
'roomId': roomNo,
});
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).saveSuccess)),
);
ref.refresh(deviceDetailProvider(deviceId));
}
} on ApiException catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${AppLocalizations.of(context).saveFailed}:${formatApiError(e)}')),
);
}
}
},
),
);
}
void _showCommandSheet(BuildContext context, Device device) { showModalBottomSheet(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (ctx) => DeviceCommandSheet(
device: device,
deviceService: DeviceService(),
),
);
}
/// 带标题的分区块卡片。
Widget _sectionCard(
BuildContext context,
String title,
List<Widget> rows,
) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Divider(height: 1),
const SizedBox(height: 8),
...rows,
],
),
),
);
}
/// 基本信息:设备名称 / 设备 ID / 型号 / 状态。
Widget _buildBasicInfo(BuildContext context, Device device) {
final t = AppLocalizations.of(context);
final theme = Theme.of(context);
return _sectionCard(context, t.basicInfo, [
_infoRow(context, theme, t.deviceName,
device.macLabel.isEmpty ? t.none : device.macLabel),
_infoRow(context, theme, t.deviceId,
device.id.isEmpty ? t.none : device.id),
_infoRow(context, theme, t.modelLabel,
device.model.isEmpty ? t.none : device.model),
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: _labelWidth(context),
child: Text(
t.status,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(child: StatusChip(status: device.status)),
],
),
),
]);
}
/// 配置参数:所属门店 / 分组 / 房号 / 序列号 / UID / 创建时间。
Widget _buildConfigParams(BuildContext context, Device device) {
final t = AppLocalizations.of(context);
final theme = Theme.of(context);
return _sectionCard(context, t.configParams, [
_infoRow(context, theme, t.belongStore,
device.storeName.isEmpty ? t.none : device.storeName),
_infoRow(context, theme, t.belongGroup,
device.groupId.isEmpty ? t.notGrouped : device.groupId),
_infoRow(context, theme, t.roomNo,
device.roomNo.isEmpty ? t.none : device.roomNo),
_infoRow(context, theme, t.deviceSnLabel,
device.deviceSn.isEmpty ? t.none : device.deviceSn),
_infoRow(context, theme, t.deviceUidLabel,
device.deviceUid.isEmpty ? t.none : device.deviceUid),
_infoRow(context, theme, t.createTime,
device.createTime != null
? Formatters.formatDateTime(device.createTime!)
: t.none),
]);
}
/// 网络信息:IP 地址 / MAC 地址。
Widget _buildNetworkInfo(BuildContext context, Device device) {
final t = AppLocalizations.of(context);
final theme = Theme.of(context);
return _sectionCard(context, t.networkInfo, [
_infoRow(context, theme, t.ipAddress,
device.ip.isEmpty ? t.none : device.ip),
_infoRow(context, theme, t.macAddress,
device.mac.isEmpty ? t.none : device.mac),
]);
}
/// 运行时数据:在线时长 / 最后活跃时间。
Widget _buildRuntimeData(BuildContext context, Device device) {
final t = AppLocalizations.of(context);
final theme = Theme.of(context);
return _sectionCard(context, t.runtimeData, [
_infoRow(context, theme, t.uptime,
device.uptimeSeconds > 0
? t.formatUptime(device.uptimeSeconds)
: t.none),
_infoRow(context, theme, t.lastActiveTime,
device.lastSyncTime != null
? Formatters.formatDateTime(device.lastSyncTime!)
: t.none),
]);
}
Widget _infoRow(BuildContext context, ThemeData theme, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: _labelWidth(context),
child: Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Text(
value,
style: theme.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w600),
),
),
],
),
);
}
Widget _buildMetricsGrid(BuildContext context, Device device) {
final t = AppLocalizations.of(context);
final metrics = device.metrics;
// 真实后端不提供运行指标:无指标数据时显示 “—”(仅 mock 模式有值)。
final hasMetrics = device.hasMetrics;
final none = t.none;
return GridView.count(
crossAxisCount: context.responsive.gridCount(minTileWidth: 300),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 1.15,
children: [
MetricCard(
icon: Icons.memory,
title: t.cpuUsage,
value: hasMetrics ? Formatters.formatPercent(metrics.cpuUsage) : none,
progress: hasMetrics ? metrics.cpuUsage : null,
accentColor:
hasMetrics && metrics.cpuUsage > 0.8 ? Colors.red : null,
),
MetricCard(
icon: Icons.storage,
title: t.memoryUsage,
value:
hasMetrics ? Formatters.formatPercent(metrics.memoryUsage) : none,
progress: hasMetrics ? metrics.memoryUsage : null,
accentColor:
hasMetrics && metrics.memoryUsage > 0.85 ? Colors.red : null,
),
MetricCard(
icon: Icons.sd_storage,
title: t.diskUsage,
value:
hasMetrics ? Formatters.formatPercent(metrics.diskUsage) : none,
progress: hasMetrics ? metrics.diskUsage : null,
accentColor:
hasMetrics && metrics.diskUsage > 0.8 ? Colors.orange : null,
),
MetricCard(
icon: Icons.network_check,
title: t.networkRxTx,
value: hasMetrics
? '${metrics.networkRxKbps.toStringAsFixed(1)} / '
'${metrics.networkTxKbps.toStringAsFixed(1)} KB/s'
: none,
),
],
);
}
}
/// 修改设备分组 / 房号底部表单。
///
/// 分组改为「分组名称」下拉选择(与 Web 端一致):进入时按设备所属门店调用
/// `/sys/store/deviceGroups/{storeId}` 拉取该门店下专属分组,下拉显示 [OrgNode.name],
/// 提交 [OrgNode.id]。房号仍保留文本输入。
class DeviceGroupRoomSheet extends StatefulWidget {
const DeviceGroupRoomSheet({
super.key,
required this.device,
required this.onSaved,
});
final Device device;
final Future<void> Function(String groupId, String roomNo) onSaved;
@override
State<DeviceGroupRoomSheet> createState() => _DeviceGroupRoomSheetState();
}
class _DeviceGroupRoomSheetState extends State<DeviceGroupRoomSheet> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _roomNoCtrl;
bool _saving = false;
/// 当前门店下的设备分组列表。
List<OrgNode> _groups = const [];
bool _groupsLoading = false;
String? _groupsError;
/// 选中的分组 ID(null 表示「未分组」)。
String? _selectedGroupId;
@override
void initState() {
super.initState();
_roomNoCtrl = TextEditingController(text: widget.device.roomNo);
_selectedGroupId =
widget.device.groupId.isEmpty ? null : widget.device.groupId;
_loadGroups();
}
@override
void dispose() {
_roomNoCtrl.dispose();
super.dispose();
}
/// 拉取当前门店下的设备分组列表。
///
/// 优先使用 `/device/group/list?storeId=xxx`,该接口支持按门店筛选且已在
/// [DeviceService] 中实现;若调用失败则降级为读取「当前分组」一项,避免阻塞
/// 用户修改房号或保存现有分组。
Future<void> _loadGroups() async {
if (widget.device.storeId.isEmpty) {
if (mounted) setState(() => _groupsLoading = false);
return;
}
if (mounted) setState(() => _groupsLoading = true);
try {
_groups = await DeviceService().getDeviceGroupList(
storeId: widget.device.storeId,
);
_groupsError = null;
} on ApiException catch (e) {
_groupsError = e.message;
_groups = const [];
} catch (e) {
_groupsError = formatApiError(e, fallback: '分组加载失败,请检查网络');
_groups = const [];
} finally {
if (mounted) setState(() => _groupsLoading = false);
}
}
/// 构造下拉项:首项「未分组」,随后各分组(显示名称,值为 ID)。
/// 若当前分组不在列表中(如后端未返回或跨门店),追加一项以便正确回显。
List<DropdownMenuItem<String>> _buildGroupItems(AppLocalizations t) {
final items = <DropdownMenuItem<String>>[
DropdownMenuItem<String>(
value: '',
child: Text(t.notGrouped),
),
];
final seen = <String>{''};
for (final group in _groups) {
if (seen.contains(group.id)) continue;
seen.add(group.id);
items.add(
DropdownMenuItem<String>(
value: group.id,
child: Text(group.name.isNotEmpty ? group.name : group.id),
),
);
}
final current = widget.device.groupId;
if (current.isNotEmpty && !seen.contains(current)) {
items.add(
DropdownMenuItem<String>(
value: current,
child: Text(current),
),
);
}
return items;
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
try {
await widget.onSaved(
_selectedGroupId ?? '',
_roomNoCtrl.text.trim(),
);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return SingleChildScrollView(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Container(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${t.editGroup} / ${t.roomNo}',
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// 所属分组:名称下拉选择(提交分组 ID)。
DropdownButtonFormField<String>(
value: _selectedGroupId ?? '',
isExpanded: true,
decoration: InputDecoration(
labelText: t.belongGroup,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.dvr),
),
items: _buildGroupItems(t),
onChanged: _groupsLoading
? null
: (value) => setState(() => _selectedGroupId = value),
),
const SizedBox(height: 8),
if (_groupsLoading)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Row(
children: [
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 8),
Text(t.groupListLoading),
],
),
)
else if (_groupsError != null)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'${t.loadFailed}:$_groupsError',
style: const TextStyle(color: Colors.redAccent, fontSize: 12),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _roomNoCtrl,
decoration: InputDecoration(
labelText: t.roomNo,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.meeting_room),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _saving ? null : _submit,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(t.save),
),
),
],
),
),
),
);
}
}