Files
jiu/client/lib/screens/inventory/batch_tracking_screen.dart
T
wangjia 393e227de5 feat: 商品详情页、XLS导入修复、分页选择器、导出功能
后端:
- 新增 product_images 表,支持每商品最多5张图(服务端压缩至1200px/JPEG85%)
- products 表新增 public_id(UUID)、description 字段
- 新增商品详情接口、二维码接口、公开商品接口(无鉴权)
- 修复 XLS 导入:OLE2 magic bytes 检测 + 临时文件解析,兼容 extrame/xls
- 修复商品/名称/系列/规格三张表导入数据为0(LastCol()=0 bug)
- 所有导入接口返回 total/imported/skipped 统计
- config 新增 StorageConfig,支持 STORAGE_* 环境变量覆盖
- 种子数据修复:products 补 public_id、新增 product_images TRUNCATE、schema.sql 表名修正

前端:
- 商品详情页:图片上传/删除、描述内联编辑、二维码弹窗、公开链接复制
- 公开商品页:无鉴权路由 /product/:public_id,Flutter Web SPA
- 商品详情列表(批次追踪)商品名超链接跳转详情页
- 导航「商品管理」改名「商品详情」
- 所有列表表格新增每页条数选择(10/20/50/100)
- 表格列头内嵌筛选(FilterableColumnHeader)
- 导出 Excel 功能(入库/出库/库存/财务/批次/往来单位)
- 网络恢复自动刷新 + 离线缓存展示

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:29:51 +08:00

412 lines
14 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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';
import '../../core/utils/export_util.dart';
class BatchTrackingScreen extends ConsumerStatefulWidget {
const BatchTrackingScreen({super.key});
@override
ConsumerState<BatchTrackingScreen> createState() =>
_BatchTrackingScreenState();
}
class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
int _page = 1;
int _pageSize = 20;
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: _pageSize)
.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,
pageSize: _pageSize,
onPageChanged: (p) => setState(() {
_page = p;
_fetch();
}),
onPageSizeChanged: (s) => setState(() {
_pageSize = s;
_page = 1;
_fetch();
}),
toolbar: Row(
children: [
const Text('已审核入库商品(含库存与销售状态)',
style: TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
const Spacer(),
OutlinedButton.icon(
onPressed: () => exportExcel(
filename: '商品管理',
headers: ['商品名称', '商品编码', '规格', '批次号', '入库单号', '供应商', '仓库', '入库日期', '数量', '单价', '状态', '买家'],
rows: records.map((r) => [
r.productName ?? '',
r.productCode ?? '',
r.productSpec ?? '',
r.batchNo ?? '',
r.orderNo ?? '',
r.supplierName ?? '',
r.warehouseName ?? '',
r.orderDate?.substring(0, 10) ?? '',
r.quantity.toInt(),
r.unitPrice,
r.isSoldOut ? '已卖出' : '在售',
r.buyerName ?? '',
]).toList(),
),
icon: const Icon(Icons.download, size: 16),
label: const Text('导出'),
),
const SizedBox(width: 4),
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(
GestureDetector(
onTap: () => context.push('/products/${r.productId}'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(r.productName ?? '-',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppTheme.primary,
decoration: TextDecoration.underline,
decorationColor: AppTheme.primary)),
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)),
),
],
),
);
}
}