bb4f17cf7a
后端: - 新增 GET /version 版本检查端点(version.go + version.yaml) - 新增 GET /license/info 接口,返回门店授权信息 - 修复 GenerateOrderNo 并发重复单号:事务内加 FOR UPDATE 行锁 - 修复 ApproveStockOut 超卖竞态:预检和库存更新均加 FOR UPDATE - 修复 Product Create 并发 code 冲突:加重试逻辑,schema 加 UNIQUE KEY - 修复 Product Update 全字段覆盖:改用 selective Updates() - 挂载 ReadOnly 中间件(全局)+ AdminOnly(用户管理路由) - version.go 配置缺失时返回 500 而非静默降级 前端: - 新增自动更新检测(update_provider.dart)+ shell 更新 banner/弹窗 - 新增系统设置"关于"标签页:版本、授权、开发信息、意见反馈 - 新增离线缓存:所有 AsyncNotifierProvider 支持断网浏览历史数据 - 新增门店信息弹窗(点击左上角 logo 或右上角门店号触发) - 提取 AppConfig 统一管理 BASE_URL,支持 --dart-define 注入 - update_provider.dart 加 kIsWeb 保护,修复 Web 平台崩溃 - dev.sh 新增 stop 命令,修复 stop 误杀前端进程问题 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
382 lines
13 KiB
Dart
382 lines
13 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';
|
|
|
|
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) {
|
|
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) => DataColumn(
|
|
label: Text(c.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: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Top row: description + refresh + column toggle
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
// Filter bar row
|
|
const SizedBox(height: 6),
|
|
Row(
|
|
children: [
|
|
MultiSelectDropdown(
|
|
label: '状态',
|
|
options: const ['在售', '已卖出'],
|
|
selected: _filterStatus,
|
|
onChanged: (v) => setState(() => _filterStatus = v),
|
|
),
|
|
const SizedBox(width: 8),
|
|
if (warehouseOptions.length > 1)
|
|
MultiSelectDropdown(
|
|
label: '仓库',
|
|
options: warehouseOptions,
|
|
selected: _filterWarehouse,
|
|
onChanged: (v) => setState(() => _filterWarehouse = v),
|
|
),
|
|
if (warehouseOptions.length > 1) const SizedBox(width: 8),
|
|
if (supplierOptions.length > 1)
|
|
MultiSelectDropdown(
|
|
label: '供应商',
|
|
options: supplierOptions,
|
|
selected: _filterSupplier,
|
|
onChanged: (v) => setState(() => _filterSupplier = 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)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|