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

493 lines
18 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: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}'),
),
);
},
),
);
}
}