ops-console v1.0.2 (versionCode 8) 源码入库
This commit is contained in:
492
lib/screens/home/device_list_page.dart
Normal file
492
lib/screens/home/device_list_page.dart
Normal 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}'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user