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,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}'),
),
);
},
),
);
}
}