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