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

619 lines
20 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 '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),
),
),
],
),
),
),
);
}
}