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 createState() => _DeviceListPageState(); } class _DeviceListPageState extends ConsumerState with WidgetsBindingObserver { final _searchController = TextEditingController(); String _keyword = ''; final _modelController = TextEditingController(); String _modelKeyword = ''; /// 门店名称搜索关键字:按所属门店名称过滤设备,与门店管理页的「搜索门店名称」对齐。 final _storeNameController = TextEditingController(); String _storeNameKeyword = ''; /// 可选门店列表(异步加载后填充,用于受限账号默认范围及门店名称反查)。 List _stores = []; /// 设备列表(页面态,替代 devicesByStoreProvider 的 watch)。 List _devices = []; bool _loadingDevices = false; Object? _deviceError; /// 组织树筛选选中的节点 ID 集合(空集表示不过滤)。 Set _selectedOrgIds = {}; /// 组织树(异步加载,失败则置空,树筛选不可用)。 List? _orgTree; /// 已发起删除、等待后端确认期间临时隐藏的设备 ID,避免行先回弹再消失。 final Set _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 _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 _loadStores() async { // 静默加载门店列表,失败不阻断设备列表。 try { _stores = await OrgService().getMyStores(); } catch (e) { _stores = []; } finally { if (mounted) setState(() {}); } } /// 异步加载组织树(静默失败:仅影响组织树筛选,不影响设备列表本身)。 Future _loadOrgTree() async { try { _orgTree = await OrgService().getOrgTree(); } catch (_) { _orgTree = null; } } /// 按组织树筛选 / 受限账号 / 全部门店拉取设备,并叠加本地型号/关键词过滤。 Future _loadDevices() async { setState(() { _loadingDevices = true; _deviceError = null; }); try { final deviceService = ref.read(deviceServiceProvider); List 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 = {}; 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 _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 = {}; void index(List ns) { for (final n in ns) { byId[n.id] = n; index(n.children); } } index(_orgTree!); final names = []; 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 _filter(List 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 _confirmDeleteDevice(Device device) async { final t = AppLocalizations.of(context); final name = device.macRoomLabel; final confirmed = await showDialog( 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}'), ), ); }, ), ); } }