ops-console v1.0.2 (versionCode 8) 源码入库

This commit is contained in:
txh
2026-09-03 18:01:05 +08:00
commit 685fff27c8
170 changed files with 53049 additions and 0 deletions

View File

@@ -0,0 +1,618 @@
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),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,492 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:go_router/go_router.dart';
import '../../models/device.dart';
import '../../models/org_node.dart';
import '../../models/store_info.dart';
import '../../providers/devices_provider.dart';
import '../../services/api_client.dart';
import '../../services/org_service.dart';
import '../../utils/i10n.dart';
import '../../utils/session_org_scope.dart';
import '../../widgets/device_list_tile.dart';
import '../../widgets/org_tree_filter.dart';
/// 设备列表页(底部导航第 2 个 tab),支持关键字搜索与组织树筛选。
class DeviceListPage extends ConsumerStatefulWidget {
const DeviceListPage({super.key});
@override
ConsumerState<DeviceListPage> createState() => _DeviceListPageState();
}
class _DeviceListPageState extends ConsumerState<DeviceListPage>
with WidgetsBindingObserver {
final _searchController = TextEditingController();
String _keyword = '';
final _modelController = TextEditingController();
String _modelKeyword = '';
/// 门店名称搜索关键字:按所属门店名称过滤设备,与门店管理页的「搜索门店名称」对齐。
final _storeNameController = TextEditingController();
String _storeNameKeyword = '';
/// 可选门店列表(异步加载后填充,用于受限账号默认范围及门店名称反查)。
List<StoreInfo> _stores = [];
/// 设备列表(页面态,替代 devicesByStoreProvider 的 watch)。
List<Device> _devices = [];
bool _loadingDevices = false;
Object? _deviceError;
/// 组织树筛选选中的节点 ID 集合(空集表示不过滤)。
Set<String> _selectedOrgIds = {};
/// 组织树(异步加载,失败则置空,树筛选不可用)。
List<OrgNode>? _orgTree;
/// 已发起删除、等待后端确认期间临时隐藏的设备 ID,避免行先回弹再消失。
final Set<String> _pendingDeleteIds = {};
/// 用于跳过首次启动时的 resumed 事件,避免与 initState 初始加载重复请求。
bool _initialResumeHandled = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_initData();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_searchController.dispose();
_modelController.dispose();
_storeNameController.dispose();
super.dispose();
}
/// 初始化:加载门店列表(受限账号默认首店)、组织树,随后拉取设备。
Future<void> _initData() async {
await _loadStores();
if (!mounted) return;
await _loadOrgTree();
if (!mounted) return;
await _loadDevices();
}
/// 应用从后台切回前台时刷新设备/门店列表,以同步 Web 端等外部变更。
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state != AppLifecycleState.resumed) return;
if (!_initialResumeHandled) {
_initialResumeHandled = true;
return;
}
_loadStores();
_loadDevices();
}
Future<void> _loadStores() async {
// 静默加载门店列表,失败不阻断设备列表。
try {
_stores = await OrgService().getMyStores();
} catch (e) {
_stores = [];
} finally {
if (mounted) setState(() {});
}
}
/// 异步加载组织树(静默失败:仅影响组织树筛选,不影响设备列表本身)。
Future<void> _loadOrgTree() async {
try {
_orgTree = await OrgService().getOrgTree();
} catch (_) {
_orgTree = null;
}
}
/// 按组织树筛选 / 受限账号 / 全部门店拉取设备,并叠加本地型号/关键词过滤。
Future<void> _loadDevices() async {
setState(() {
_loadingDevices = true;
_deviceError = null;
});
try {
final deviceService = ref.read(deviceServiceProvider);
List<Device> devices;
if (_selectedOrgIds.isEmpty) {
// 未选组织树:
if (SessionOrgScope.instance.enabled) {
// 受限账号:不能有「全部门店」视图(否则会拉取跨组织设备)。
// 改为拉取其组织范围内【全部门店】的设备(而非仅第一家),
// 既见全本组织设备、又不越权。门店列表本就按范围收敛,取其 orgId 集合即可。
final storeIds = _stores
.map((s) => s.orgId)
.where((id) => id.isNotEmpty)
.toSet();
if (storeIds.isEmpty) {
// 异常兜底:连一家门店都没有时,不调用全量接口,避免越权拉取跨组织设备。
devices = const [];
} else {
devices = await deviceService.fetchDevicesByOrgSet(storeIds);
}
} else {
// 管理员:全量设备。
devices = await deviceService.fetchDevices();
}
} else {
// 组织树筛选:仅收集每个选中节点的【门店(type=3)后代】orgId。
final storeIds = <String>{};
for (final id in _selectedOrgIds) {
storeIds.addAll(
OrgService.collectDescendantStoreIds(_orgTree ?? [], id),
);
}
if (storeIds.isEmpty) {
// 选中的节点没有任何门店后代(如设备分组 / 无下属门店的空代理):
// 直接返回空结果——绝不回退为「用节点自身 id 充当 storeOrgId」,
// 也绝不退化为「全量设备」(fetchDevicesByOrgSet 空集会退化为跨组织全量)。
devices = const [];
} else {
devices = await deviceService.fetchDevicesByOrgSet(storeIds);
}
}
if (!mounted) return;
_devices = devices;
setState(() => _loadingDevices = false);
} catch (e) {
if (!mounted) return;
_deviceError = e;
setState(() => _loadingDevices = false);
}
}
/// 打开组织树筛选抽屉,回填选中集合并重新拉取设备。
///
/// 返回 null(取消)时保持原筛选不变;返回非空集合(含空集合)时覆盖 [_selectedOrgIds]。
Future<void> _openOrgFilter() async {
final result = await showOrgTreeFilterSheet(
context,
initialSelected: _selectedOrgIds,
);
if (result == null) return;
setState(() => _selectedOrgIds = result);
await _loadDevices();
}
/// 按门店名称搜索:保存关键字到 [_storeNameKeyword] 并触发本地过滤。
/// 搜索结果由 [_filter] 在构建时实时应用,并在下拉刷新/生命周期刷新后保持。
void _onStoreSearch() {
setState(() {
_storeNameKeyword = _storeNameController.text.trim();
});
}
/// 取设备所属门店的展示名称(优先 [Device.storeName],其次按 storeId 在 [_stores] 中反查)。
String _deviceStoreName(Device device) {
if (device.storeName.isNotEmpty) return device.storeName;
for (final s in _stores) {
if (s.orgId == device.storeId) return s.name;
}
return '';
}
/// 组织树筛选按钮文案:空选显示「全部门店/全部组织」;
/// 有选显示首个选中节点名 + 超量「(+N)」。
String _orgFilterLabel() {
if (_selectedOrgIds.isEmpty) return '全部门店/全部组织';
if (_orgTree == null) return '已选 ${_selectedOrgIds.length} 项';
final byId = <String, OrgNode>{};
void index(List<OrgNode> ns) {
for (final n in ns) {
byId[n.id] = n;
index(n.children);
}
}
index(_orgTree!);
final names = <String>[];
for (final id in _selectedOrgIds) {
final n = byId[id];
if (n != null) names.add(n.name);
}
if (names.isEmpty) return '已选 ${_selectedOrgIds.length} 项';
if (names.length == 1) return names.first;
return '${names.first} (+${names.length - 1})';
}
List<Device> _filter(List<Device> devices) {
final keyword = _keyword.trim().toLowerCase();
final model = _modelKeyword.trim().toLowerCase();
final storeName = _storeNameKeyword.trim().toLowerCase();
// 过滤掉已发起删除、正在等待后端确认的设备(用与行 key 一致的唯一标识)。
final visible = devices.where((d) => !_pendingDeleteIds.contains(_rowKey(d)));
if (keyword.isEmpty && model.isEmpty && storeName.isEmpty) {
return visible.toList();
}
return visible.where((device) {
final matchesKeyword = keyword.isEmpty ||
device.mac.toLowerCase().contains(keyword) ||
device.name.toLowerCase().contains(keyword) ||
device.roomNo.toLowerCase().contains(keyword) ||
device.ip.contains(keyword);
final matchesModel = model.isEmpty ||
device.model.toLowerCase().contains(model) ||
device.deviceSn.toLowerCase().contains(model);
final matchesStoreName = storeName.isEmpty ||
_deviceStoreName(device).toLowerCase().contains(storeName);
return matchesKeyword && matchesModel && matchesStoreName;
}).toList();
}
/// 行唯一键:优先用设备 id,缺失时回退 mac,避免空 id 导致 ValueKey('') 重复触发断言。
String _rowKey(Device d) => '${d.id.isNotEmpty ? d.id : ''}__${d.mac}';
/// 滑动后的删除确认:弹出对话框,确认才调用后端删除,取消保留。
Future<bool> _confirmDeleteDevice(Device device) async {
final t = AppLocalizations.of(context);
final name = device.macRoomLabel;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t.confirmDelete),
content: Text(t.deleteConfirmContent(name)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(t.cancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(t.delete),
),
],
),
);
if (confirmed != true) return false;
// 先本地隐藏,避免删除过程中行回弹再消失造成闪烁。
final key = _rowKey(device);
_pendingDeleteIds.add(key);
if (mounted) setState(() {});
try {
await ref.read(deviceServiceProvider).deleteDevice(device.id);
if (!mounted) return true;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.deleted)),
);
await _loadDevices();
_pendingDeleteIds.remove(key);
if (mounted) setState(() {});
return true;
} catch (e) {
_pendingDeleteIds.remove(key);
if (!mounted) return false;
setState(() {});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${t.deleteFailed}:${formatApiError(e)}')),
);
return false;
}
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: Text(t.deviceList),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => context.push('/device/scan?mode=bind&activate=1'),
icon: const Icon(Icons.add_a_photo),
label: Text(t.scanActivateDevice),
),
body: Column(
children: [
// 顶部筛选区:与门店管理页保持一致。
// 第一行:门店名称搜索 + 独立组织筛选图标;第二行:设备关键字/型号搜索。
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Column(
children: [
// 门店名称搜索 + 组织筛选图标(独立,不与其它筛选项混排)。
Row(
children: [
Expanded(
child: TextField(
controller: _storeNameController,
decoration: InputDecoration(
hintText: t.searchStoreHint,
prefixIcon: const Icon(Icons.search_outlined),
suffixIcon: _storeNameController.text.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear_outlined),
onPressed: () {
_storeNameController.clear();
setState(() => _storeNameKeyword = '');
},
),
),
onSubmitted: (_) => _onStoreSearch(),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.account_tree_outlined),
tooltip: '组织筛选',
onPressed: _openOrgFilter,
),
],
),
const SizedBox(height: 8),
// 设备关键字 / 型号搜索。
Row(
children: [
Expanded(
flex: 3,
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: t.searchDeviceHint,
prefixIcon: const Icon(Icons.search),
suffixIcon: _keyword.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() => _keyword = '');
},
),
),
onChanged: (value) => setState(() => _keyword = value),
),
),
const SizedBox(width: 8),
Expanded(
flex: 2,
child: TextField(
controller: _modelController,
decoration: InputDecoration(
hintText: t.searchModelHint,
prefixIcon: const Icon(Icons.memory),
suffixIcon: _modelKeyword.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_modelController.clear();
setState(() => _modelKeyword = '');
},
),
),
onChanged: (value) =>
setState(() => _modelKeyword = value),
),
),
],
),
if (_selectedOrgIds.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Wrap(
children: [
Chip(
label: Text(_orgFilterLabel()),
deleteIcon: const Icon(Icons.close, size: 18),
onDeleted: () {
setState(() {
_selectedOrgIds = {};
});
_loadDevices();
},
),
],
),
),
],
),
),
const SizedBox(height: 8),
Expanded(child: _buildDeviceList()),
],
),
);
}
/// 根据页面态([_devices]/[_loadingDevices]/[_deviceError])渲染设备列表。
Widget _buildDeviceList() {
final t = AppLocalizations.of(context);
if (_loadingDevices) {
return const Center(child: CircularProgressIndicator());
}
if (_deviceError != null) {
// 登录态失效:拦截器已清空令牌并触发路由重定向到登录页,
// 此处不再展示底层错误,直接显示加载占位(路由会接管跳转)。
if (isUnauthorizedError(_deviceError!)) {
return const Center(child: CircularProgressIndicator());
}
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(t.deviceListLoadFailed(formatApiError(_deviceError!))),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _loadDevices,
child: Text(t.retry),
),
],
),
);
}
final filtered = _filter(_devices);
if (filtered.isEmpty) {
return Center(child: Text(t.noMatchedDevices));
}
return RefreshIndicator(
onRefresh: _loadDevices,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: filtered.length,
itemBuilder: (context, index) {
final device = filtered[index];
// 向左滑动(end side)揭示右侧红色「删除」按钮;点击按钮才弹确认框。
// 不用 Dismissible:后者松手过阈值即自动弹窗,无法满足「先露按钮再点击」的交互。
return Slidable(
key: ValueKey(_rowKey(device)),
endActionPane: ActionPane(
motion: const ScrollMotion(),
// 不自动 dismiss,删除以「确认弹窗 + 后端成功」为准。
dismissible: null,
children: [
SlidableAction(
onPressed: (_) => _confirmDeleteDevice(device),
backgroundColor: Colors.red,
foregroundColor: Colors.white,
icon: Icons.delete_outline,
label: t.delete,
),
],
),
child: DeviceListTile(
device: device,
onTap: () => context.push('/device/${device.id}'),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,261 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../models/device.dart';
import '../../providers/dashboard_provider.dart';
import '../../providers/devices_provider.dart';
import '../../app/theme.dart';
import '../../utils/i10n.dart';
import '../../utils/responsive.dart';
import '../../providers/auth_provider.dart';
import '../../utils/app_update.dart';
import '../../utils/permissions.dart';
import '../../widgets/cloud_card.dart';
/// 首页:仪表盘概览(底部导航第 1 个 tab)。
///
/// 展示设备统计卡片与各模块快捷入口。
class HomePage extends ConsumerStatefulWidget {
const HomePage({super.key});
@override
ConsumerState<HomePage> createState() => _HomePageState();
}
class _HomePageState extends ConsumerState<HomePage> {
@override
void initState() {
super.initState();
// 加载仪表盘统计(设备/门店总数)。
Future.microtask(
() => ref.read(dashboardStatsProvider.notifier).load(),
);
// 启动后静默检测新版本(无更新/失败均不打扰,仅会话内首次有更新时弹窗)。
final orgId = ref.read(authProvider).user?.orgId;
final ctx = context;
Future.microtask(() {
if (!ctx.mounted) return;
checkAndPromptUpdate(
ctx,
silentIfNoUpdate: true,
orgId: orgId,
);
});
}
@override
Widget build(BuildContext context) {
final devicesAsync = ref.watch(devicesProvider);
final t = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(title: Text(t.appTitle)),
body: devicesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => _buildDashboard(context, null),
data: (devices) => _buildDashboard(context, devices),
),
);
}
Widget _buildDashboard(BuildContext context, List<Device>? devices) {
final stats = ref.watch(dashboardStatsProvider);
final totalCount = stats.deviceTotal;
final storeTotal = stats.storeTotal;
// 在线/离线统计改为后端真实聚合值(按 status 拉 totalCount),
// 不再用首屏列表客户端统计,避免与设备总数(12 万级)严重不符。
final onlineCount = stats.onlineCount;
final offlineCount = stats.offlineCount;
final t = AppLocalizations.of(context);
// 统计卡配色纳入橙白语义体系(仅改色,图标图形与 size 冻结不动)。
final scheme = Theme.of(context).colorScheme;
final semantic = Theme.of(context).extension<AppSemanticColors>();
return RefreshIndicator(
onRefresh: () async {
final _ = await ref.refresh(devicesProvider.future);
await ref.read(dashboardStatsProvider.notifier).load();
},
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
children: [
_buildBrandBanner(context),
const SizedBox(height: 16),
// 统计卡片
GridView.count(
crossAxisCount: context.responsive.gridCount(minTileWidth: 300),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.4,
children: [
_StatCard(
title: t.totalDevices,
value: '$totalCount',
icon: Icons.devices,
color: scheme.primary,
),
_StatCard(
title: t.totalStores,
value: '$storeTotal',
icon: Icons.store,
color: scheme.tertiary,
),
_StatCard(
title: t.onlineDevices,
value: '$onlineCount',
icon: Icons.check_circle_outline,
color: semantic?.success ?? scheme.tertiary,
),
_StatCard(
title: t.offlineDevices,
value: '$offlineCount',
icon: Icons.highlight_off,
color: scheme.onSurfaceVariant,
),
],
),
const SizedBox(height: 24),
// 快捷操作
Text(
t.quickActions,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _QuickAction(
label: t.deviceManagement,
icon: Icons.devices,
onTap: () => context.go('/device'),
),
),
Expanded(
child: _QuickAction(
label: t.storeManagement,
icon: Icons.store,
onTap: () => context.go('/store'),
),
),
if (SessionPermissions.canViewUserManagement(
ref.read(authProvider).user))
Expanded(
child: _QuickAction(
label: t.userManagement,
icon: Icons.people,
onTap: () => context.push('/admin/sys/user'),
),
),
],
),
],
),
);
}
/// 品牌云卡横幅:首页招牌区块(橙→金渐变)。
Widget _buildBrandBanner(BuildContext context) {
final t = AppLocalizations.of(context);
return CloudCard(
child: Row(
children: [
const Icon(Icons.cloud, size: 36),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.appHeroTitle,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
const Text(
'终端管控·内容运营·门店管理',
style: TextStyle(fontSize: 13),
),
],
),
),
],
),
);
}
}
class _StatCard extends StatelessWidget {
const _StatCard({
required this.title,
required this.value,
required this.icon,
required this.color,
});
final String title;
final String value;
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color, size: 28),
const SizedBox(height: 8),
Text(
value,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(title, style: Theme.of(context).textTheme.bodySmall),
],
),
),
);
}
}
class _QuickAction extends StatelessWidget {
const _QuickAction({
required this.label,
required this.icon,
required this.onTap,
});
final String label;
final IconData icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Icon(icon, color: Theme.of(context).colorScheme.primary),
),
const SizedBox(height: 4),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
),
);
}
}