feat: 自动更新、系统设置、安全修复
后端: - 新增 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>
This commit is contained in:
@@ -4,6 +4,7 @@ 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});
|
||||
@@ -17,6 +18,26 @@ 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() {
|
||||
@@ -30,12 +51,30 @@ class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
||||
.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>>(
|
||||
@@ -45,25 +84,84 @@ class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
||||
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: [
|
||||
Text('加载失败:${snap.error}',
|
||||
style: const TextStyle(color: AppTheme.danger)),
|
||||
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _refetch, child: const Text('重试')),
|
||||
const Text('暂无数据,网络不可用', style: TextStyle(color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(onPressed: _refetch, child: const Text('重试')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return _buildTable(snap.data ?? []);
|
||||
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,
|
||||
@@ -71,141 +169,158 @@ class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
||||
_page = p;
|
||||
_fetch();
|
||||
}),
|
||||
toolbar: Row(
|
||||
toolbar: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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: '刷新',
|
||||
// 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: const [
|
||||
DataColumn(label: Text('商品')),
|
||||
DataColumn(label: Text('规格')),
|
||||
DataColumn(label: Text('批次号')),
|
||||
DataColumn(label: Text('入库单号')),
|
||||
DataColumn(label: Text('供应商')),
|
||||
DataColumn(label: Text('仓库')),
|
||||
DataColumn(label: Text('入库日期')),
|
||||
DataColumn(label: Text('数量'), numeric: true),
|
||||
DataColumn(label: Text('单价'), numeric: true),
|
||||
DataColumn(label: Text('状态')),
|
||||
DataColumn(label: Text('买家/时间')),
|
||||
],
|
||||
rows: records.isEmpty
|
||||
? [
|
||||
const DataRow(cells: [
|
||||
DataCell(SizedBox()),
|
||||
DataCell(Text('暂无记录',
|
||||
style: TextStyle(color: AppTheme.textSecondary))),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
])
|
||||
]
|
||||
: records.map((r) {
|
||||
final batchText =
|
||||
(r.batchNo != null && r.batchNo!.isNotEmpty)
|
||||
? r.batchNo!
|
||||
: null;
|
||||
return DataRow(cells: [
|
||||
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')),
|
||||
],
|
||||
)),
|
||||
DataCell(Text(r.productSpec ?? '-',
|
||||
style: const TextStyle(fontSize: 12))),
|
||||
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))),
|
||||
DataCell(Text(r.orderNo ?? '-',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppTheme.primary,
|
||||
fontFamily: 'monospace'))),
|
||||
DataCell(Text(r.supplierName ?? '-',
|
||||
style: const TextStyle(fontSize: 12))),
|
||||
DataCell(Text(r.warehouseName ?? '-',
|
||||
style: const TextStyle(fontSize: 12))),
|
||||
DataCell(Text(
|
||||
r.orderDate?.substring(0, 10) ?? '-',
|
||||
style: const TextStyle(fontSize: 12))),
|
||||
DataCell(Text(
|
||||
'${r.quantity.toStringAsFixed(0)} ${r.productUnit ?? ''}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500))),
|
||||
DataCell(Text(
|
||||
'¥${r.unitPrice.toStringAsFixed(2)}',
|
||||
style: const TextStyle(fontSize: 13))),
|
||||
DataCell(_StatusBadge(r.isSoldOut)),
|
||||
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))),
|
||||
]);
|
||||
}).toList(),
|
||||
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 {
|
||||
@@ -233,3 +348,34 @@ class _StatusBadge extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user