Files
jiu/client/lib/screens/inventory/batch_tracking_screen.dart
T
wangjia 5dd7c07138 feat(client): 网络恢复自动刷新 + 离线嗅探 + 连通性感知登录
网络嗅探与自动恢复:
- ConnectivityNotifier:在线时 30s 检测,离线时切换为 1min 嗅探
- 新增 networkRecoveryCountProvider:网络恢复时自增,各数据 provider
  通过 ref.watch 实现自动重新加载
- inventory/product/stock_in/stock_out/partner provider 均已接入

FutureBuilder 类屏幕:
- finance_screen + batch_tracking_screen 通过 ref.listen 监听恢复事件

修复问题 1:API 超时 10s/30s → 5s/15s,减少无网络时等待
修复问题 2:offline banner 文字由"写操作已禁用"改为实际行为描述

连通性感知:
- app 启动时(_AppBootstrap)立即 forceCheck,路由跳转前状态已就绪
- 登录页:watch connectivityProvider,离线时显示警告 banner
- 登录按钮:离线状态下直接提示,不等待超时

测试:
- login_screen_test + login_flow_test 通过 ConnectivityNotifier(skipInit: true)
  覆盖 provider,避免测试环境中 pending HTTP timer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 19:52:17 +08:00

371 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
import '../../models/inventory.dart';
import '../../providers/inventory_provider.dart';
import '../../widgets/data_table_card.dart';
import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader;
import '../../providers/connectivity_provider.dart';
class BatchTrackingScreen extends ConsumerStatefulWidget {
const BatchTrackingScreen({super.key});
@override
ConsumerState<BatchTrackingScreen> createState() =>
_BatchTrackingScreenState();
}
class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
int _page = 1;
int _total = 0;
late Future<List<ProductTrackingRecord>> _future;
List<ProductTrackingRecord> _records = [];
Set<String> _filterStatus = {};
Set<String> _filterWarehouse = {};
Set<String> _filterSupplier = {};
Set<String> _hiddenCols = {};
static const _colDefs = [
ColDef('product', '商品', required: true),
ColDef('spec', '规格', minWidth: 1100),
ColDef('batch', '批次号', minWidth: 1000),
ColDef('order_no', '入库单号', minWidth: 900),
ColDef('supplier', '供应商', minWidth: 1100),
ColDef('warehouse', '仓库'),
ColDef('date', '入库日期', minWidth: 1000),
ColDef('qty', '数量'),
ColDef('price', '单价', minWidth: 900),
ColDef('status', '状态'),
ColDef('buyer', '买家/时间'),
];
@override
void initState() {
super.initState();
_fetch();
}
void _fetch() {
_future = ref
.read(inventoryRepositoryProvider)
.listProducts(page: _page, pageSize: 20)
.then((r) {
_total = r.total;
_records = r.data;
return r.data;
});
}
void _refetch() => setState(() => _fetch());
List<ProductTrackingRecord> _applyFilters(
List<ProductTrackingRecord> all) {
return all.where((r) {
if (_filterStatus.isNotEmpty) {
final label = r.isSoldOut ? '已卖出' : '在售';
if (!_filterStatus.contains(label)) return false;
}
if (_filterWarehouse.isNotEmpty) {
if (!_filterWarehouse.contains(r.warehouseName ?? '')) return false;
}
if (_filterSupplier.isNotEmpty) {
if (!_filterSupplier.contains(r.supplierName ?? '')) return false;
}
return true;
}).toList();
}
@override
Widget build(BuildContext context) {
// 网络恢复时自动刷新
ref.listen(networkRecoveryCountProvider, (_, __) => _refetch());
return FutureBuilder<List<ProductTrackingRecord>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
// 有缓存数据时展示缓存,顶部加提示条
if (_records.isNotEmpty) {
return Column(
children: [
_OfflineBanner(onRetry: _refetch),
Expanded(child: _buildTable(_applyFilters(_records))),
],
);
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
const SizedBox(height: 12),
const Text('暂无数据,网络不可用', style: TextStyle(color: AppTheme.textSecondary)),
const SizedBox(height: 12),
ElevatedButton(onPressed: _refetch, child: const Text('重试')),
],
),
);
}
final filtered = _applyFilters(_records);
return _buildTable(filtered);
},
);
}
Widget _buildTable(List<ProductTrackingRecord> records) {
// Derive filter options from all loaded records
final warehouseOptions = _records
.map((r) => r.warehouseName ?? '')
.where((s) => s.isNotEmpty)
.toSet()
.toList()
..sort();
final supplierOptions = _records
.map((r) => r.supplierName ?? '')
.where((s) => s.isNotEmpty)
.toSet()
.toList()
..sort();
// Build visible columns (respect manual hide + responsive auto-hide)
final screenWidth = MediaQuery.of(context).size.width;
final visibleCols = _colDefs
.where((c) =>
!_hiddenCols.contains(c.key) &&
(c.minWidth == null || screenWidth >= c.minWidth!))
.toList();
final columns = visibleCols.map((c) {
final label = switch (c.key) {
'status' => FilterableColumnHeader(
text: c.label,
options: const ['在售', '已卖出'],
selected: _filterStatus,
onChanged: (v) => setState(() => _filterStatus = v),
),
'warehouse' => FilterableColumnHeader(
text: c.label,
options: warehouseOptions,
selected: _filterWarehouse,
onChanged: (v) => setState(() => _filterWarehouse = v),
),
'supplier' => FilterableColumnHeader(
text: c.label,
options: supplierOptions,
selected: _filterSupplier,
onChanged: (v) => setState(() => _filterSupplier = v),
),
_ => Text(c.label),
};
return DataColumn(
label: label,
numeric: c.key == 'qty' || c.key == 'price');
}).toList();
final rows = records.isEmpty
? [
DataRow(
cells: List.generate(
visibleCols.length,
(i) => i == 1
? const DataCell(Text('暂无记录',
style: TextStyle(color: AppTheme.textSecondary)))
: const DataCell(SizedBox()),
),
),
]
: records
.map((r) => DataRow(
cells: visibleCols
.map((c) => _buildCell(c.key, r))
.toList(),
))
.toList();
return DataTableCard(
totalCount: _total,
page: _page,
onPageChanged: (p) => setState(() {
_page = p;
_fetch();
}),
toolbar: Row(
children: [
const Text('已审核入库商品(含库存与销售状态)',
style: TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
const Spacer(),
IconButton(
icon: const Icon(Icons.refresh, size: 18),
onPressed: () => setState(() {
_page = 1;
_fetch();
}),
tooltip: '刷新',
),
const SizedBox(width: 4),
ColumnToggleButton(
columns: _colDefs,
hidden: _hiddenCols,
onChanged: (v) => setState(() => _hiddenCols = v),
),
],
),
columns: columns,
rows: rows,
);
}
DataCell _buildCell(String key, ProductTrackingRecord r) {
final batchText =
(r.batchNo != null && r.batchNo!.isNotEmpty) ? r.batchNo! : null;
switch (key) {
case 'product':
return DataCell(Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(r.productName ?? '-',
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w500)),
if (r.productCode != null)
Text(r.productCode!,
style: const TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
fontFamily: 'monospace')),
],
));
case 'spec':
return DataCell(Text(r.productSpec ?? '-',
style: const TextStyle(fontSize: 12)));
case 'batch':
return DataCell(batchText != null
? Container(
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.primary.withOpacity(0.08),
borderRadius: BorderRadius.circular(3),
),
child: Text(batchText,
style: const TextStyle(
fontSize: 12,
color: AppTheme.primary,
fontFamily: 'monospace')),
)
: const Text('无批次',
style: TextStyle(
fontSize: 12, color: AppTheme.textSecondary)));
case 'order_no':
return DataCell(Text(r.orderNo ?? '-',
style: const TextStyle(
fontSize: 11,
color: AppTheme.primary,
fontFamily: 'monospace')));
case 'supplier':
return DataCell(Text(r.supplierName ?? '-',
style: const TextStyle(fontSize: 12)));
case 'warehouse':
return DataCell(Text(r.warehouseName ?? '-',
style: const TextStyle(fontSize: 12)));
case 'date':
return DataCell(Text(r.orderDate?.substring(0, 10) ?? '-',
style: const TextStyle(fontSize: 12)));
case 'qty':
return DataCell(Text(
'${r.quantity.toStringAsFixed(0)} ${r.productUnit ?? ''}',
style: const TextStyle(fontWeight: FontWeight.w500)));
case 'price':
return DataCell(Text('¥${r.unitPrice.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 13)));
case 'status':
return DataCell(_StatusBadge(r.isSoldOut));
case 'buyer':
return DataCell(r.isSoldOut
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
r.buyerName?.isNotEmpty == true ? r.buyerName! : '未知买家',
style: const TextStyle(
fontSize: 12, fontWeight: FontWeight.w500),
),
if (r.soldAt != null)
Text(
r.soldAt!.length > 10
? r.soldAt!.substring(0, 10)
: r.soldAt!,
style: const TextStyle(
fontSize: 11, color: AppTheme.textSecondary)),
],
)
: const Text('-',
style: TextStyle(color: AppTheme.textSecondary)));
default:
return const DataCell(SizedBox());
}
}
}
class _StatusBadge extends StatelessWidget {
final bool isSoldOut;
const _StatusBadge(this.isSoldOut);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: isSoldOut
? AppTheme.textSecondary.withOpacity(0.12)
: AppTheme.success.withOpacity(0.12),
borderRadius: BorderRadius.circular(3),
),
child: Text(
isSoldOut ? '已卖出' : '在售',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isSoldOut ? AppTheme.textSecondary : AppTheme.success,
),
),
);
}
}
class _OfflineBanner extends StatelessWidget {
final VoidCallback onRetry;
const _OfflineBanner({required this.onRetry});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
color: const Color(0xFFFFF8E1),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
children: [
const Icon(Icons.cloud_off, size: 14, color: Color(0xFFF57F17)),
const SizedBox(width: 8),
const Expanded(
child: Text('网络不可用,当前显示离线缓存数据',
style: TextStyle(color: Color(0xFFF57F17), fontSize: 12)),
),
TextButton(
onPressed: onRetry,
style: TextButton.styleFrom(
foregroundColor: const Color(0xFFF57F17),
padding: const EdgeInsets.symmetric(horizontal: 8)),
child: const Text('重试', style: TextStyle(fontSize: 12)),
),
],
),
);
}
}