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:
wangjia
2026-04-13 00:28:08 +08:00
parent 53a60d230f
commit bb4f17cf7a
44 changed files with 3384 additions and 710 deletions
@@ -9,9 +9,11 @@ import '../../providers/partner_provider.dart';
import '../../providers/product_provider.dart';
import '../../providers/stock_out_provider.dart';
import '../../providers/warehouse_provider.dart';
import '../../repositories/stock_out_repository.dart';
class StockOutFormScreen extends ConsumerStatefulWidget {
const StockOutFormScreen({super.key});
final int? editOrderId;
const StockOutFormScreen({super.key, this.editOrderId});
@override
ConsumerState<StockOutFormScreen> createState() => _StockOutFormScreenState();
@@ -24,15 +26,57 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
int? _partnerId;
DateTime _orderDate = DateTime.now();
bool _submitting = false;
bool _loadingEdit = false;
// productId → available quantity in selected warehouse
Map<int, double> _inventoryMap = {};
final List<_ItemRow> _items = [];
bool get _isEdit => widget.editOrderId != null;
@override
void initState() {
super.initState();
_items.add(_ItemRow());
if (_isEdit) {
_loadEditOrder();
} else {
_items.add(_ItemRow());
}
}
Future<void> _loadEditOrder() async {
setState(() => _loadingEdit = true);
try {
final order = await ref
.read(stockOutRepositoryProvider)
.get(widget.editOrderId!);
setState(() {
_warehouseId = order.warehouseId;
_partnerId = order.partnerId;
if (order.orderDate != null) {
_orderDate = DateTime.tryParse(order.orderDate!) ?? DateTime.now();
}
_remarkCtrl.text = order.remark ?? '';
_items.clear();
for (final item in order.items ?? []) {
final row = _ItemRow();
row.productId = item.productId;
row.qtyCtrl.text = item.quantity.toStringAsFixed(0);
row.priceCtrl.text = item.unitPrice.toStringAsFixed(2);
_items.add(row);
}
if (_items.isEmpty) _items.add(_ItemRow());
});
if (_warehouseId != null) await _loadInventory(_warehouseId!);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('加载失败:$e'), backgroundColor: AppTheme.danger),
);
}
} finally {
if (mounted) setState(() => _loadingEdit = false);
}
}
@override
@@ -117,7 +161,14 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
};
try {
await ref.read(stockOutListProvider.notifier).createOrder(data);
if (_isEdit) {
await ref
.read(stockOutRepositoryProvider)
.update(widget.editOrderId!, data);
ref.read(stockOutListProvider.notifier).reload();
} else {
await ref.read(stockOutListProvider.notifier).createOrder(data);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -160,8 +211,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
tooltip: '返回',
),
const SizedBox(width: 8),
const Text('新建出库单',
style: TextStyle(
Text(_isEdit ? '修改出库单' : '新建出库单',
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w600)),
const Spacer(),
OutlinedButton(
@@ -190,6 +241,9 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
),
),
const Divider(height: 1),
if (_loadingEdit)
const Expanded(child: Center(child: CircularProgressIndicator())),
if (!_loadingEdit)
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
@@ -6,6 +6,7 @@ import '../../models/stock_out.dart';
import '../../providers/stock_out_provider.dart';
import '../../repositories/stock_out_repository.dart';
import '../../widgets/data_table_card.dart';
import '../../widgets/multi_select_dropdown.dart';
import '../../widgets/page_scaffold.dart';
import '../../widgets/status_badge.dart';
@@ -20,6 +21,19 @@ class StockOutListScreen extends ConsumerStatefulWidget {
class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
String _statusFilter = '';
DateTimeRange? _dateRange;
Set<String> _filterWarehouse = {};
Set<String> _filterCustomer = {};
Set<String> _hiddenCols = {};
static const _colDefs = [
ColDef('order_no', '出库单号', required: true),
ColDef('customer', '客户', minWidth: 900),
ColDef('warehouse', '仓库'),
ColDef('amount', '金额', minWidth: 800),
ColDef('status', '状态'),
ColDef('date', '日期', minWidth: 900),
ColDef('actions', '操作', required: true),
];
String? get _startDate => _dateRange != null
? '${_dateRange!.start.year}-${_dateRange!.start.month.toString().padLeft(2, '0')}-${_dateRange!.start.day.toString().padLeft(2, '0')}'
@@ -65,8 +79,10 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('加载失败:$e',
style: const TextStyle(color: AppTheme.danger)),
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
const SizedBox(height: 12),
const Text('暂无数据,网络不可用',
style: const TextStyle(color: AppTheme.textSecondary)),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () =>
@@ -77,20 +93,55 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
),
),
data: (result) {
final List<StockOutOrder> orders;
final allOrders = result.data;
final List<StockOutOrder> statusFiltered;
if (filterStatus == 'pending') {
orders = result.data.where((o) => o.status == 'pending').toList();
statusFiltered = allOrders
.where((o) => o.status == 'draft' || o.status == 'pending')
.toList();
} else if (filterStatus == 'exclude_pending') {
orders = result.data.where((o) => o.status != 'pending').toList();
statusFiltered = allOrders
.where((o) => o.status != 'draft' && o.status != 'pending')
.toList();
} else {
orders = result.data;
statusFiltered = allOrders;
}
// Derive filter options from all loaded orders
final warehouseOptions = allOrders
.map((o) => o.warehouseName ?? '')
.where((s) => s.isNotEmpty)
.toSet()
.toList()
..sort();
final customerOptions = allOrders
.map((o) => o.partnerName ?? '')
.where((s) => s.isNotEmpty)
.toSet()
.toList()
..sort();
// Apply multi-select filters
var orders = statusFiltered;
if (_filterWarehouse.isNotEmpty) {
orders = orders
.where((o) => _filterWarehouse.contains(o.warehouseName ?? ''))
.toList();
}
if (_filterCustomer.isNotEmpty) {
orders = orders
.where((o) => _filterCustomer.contains(o.partnerName ?? ''))
.toList();
}
return _buildOrderTable(
orders: orders,
totalCount: orders.length,
page: result.page,
showStatusFilter: filterStatus == 'exclude_pending',
showNewButton: showNewButton,
warehouseOptions: warehouseOptions,
customerOptions: customerOptions,
);
},
);
@@ -102,139 +153,205 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
required int page,
required bool showStatusFilter,
required bool showNewButton,
required List<String> warehouseOptions,
required List<String> customerOptions,
}) {
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 == 'amount',
))
.toList();
DataCell buildOrderCell(String key, StockOutOrder o) {
switch (key) {
case 'order_no':
return DataCell(GestureDetector(
onTap: () => _showDetail(context, o.id),
child: Text(o.orderNo,
style: const TextStyle(
color: AppTheme.primary,
fontFamily: 'monospace',
fontSize: 12,
decoration: TextDecoration.underline)),
));
case 'customer':
return DataCell(Text(o.partnerName ?? '-'));
case 'warehouse':
return DataCell(Text(o.warehouseName ?? '-'));
case 'amount':
return DataCell(Text(o.totalAmount != null
? '¥${o.totalAmount!.toStringAsFixed(2)}'
: '-'));
case 'status':
return DataCell(StatusBadge(_apiStatusToEnum(o.status)));
case 'date':
return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-'));
case 'actions':
return DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: () => _showDetail(context, o.id),
child: const Text('详情',
style:
TextStyle(fontSize: 12, color: AppTheme.primary)),
),
if (o.status == 'draft') ...[
TextButton(
onPressed: () => context.go('/stock-out/edit/${o.id}'),
child: const Text('修改',
style: TextStyle(
fontSize: 12, color: AppTheme.primary)),
),
TextButton(
onPressed: () => _confirmDelete(context, o),
child: const Text('删除',
style: TextStyle(
fontSize: 12, color: AppTheme.danger)),
),
TextButton(
onPressed: () => _confirmSubmit(context, o),
child: const Text('提交',
style: TextStyle(
fontSize: 12, color: AppTheme.primary)),
),
],
if (o.status == 'pending') ...[
TextButton(
key: Key('btn_approve_${o.id}'),
onPressed: () => _confirmApprove(context, o),
child: const Text('通过',
style: TextStyle(
fontSize: 12, color: AppTheme.success)),
),
TextButton(
key: Key('btn_reject_${o.id}'),
onPressed: () => _confirmReject(context, o),
child: const Text('拒绝',
style: TextStyle(
fontSize: 12, color: AppTheme.danger)),
),
],
],
));
default:
return const DataCell(SizedBox());
}
}
final rows = orders.isEmpty
? [
DataRow(
cells: List.generate(
visibleCols.length,
(i) => i == 0
? const DataCell(Text('暂无出库单',
style: TextStyle(color: AppTheme.textSecondary)))
: const DataCell(SizedBox()),
),
),
]
: orders
.map((o) => DataRow(
cells: visibleCols
.map((c) => buildOrderCell(c.key, o))
.toList(),
))
.toList();
return DataTableCard(
totalCount: totalCount,
page: page,
onPageChanged: (p) =>
ref.read(stockOutListProvider.notifier).setPage(p),
toolbar: Row(
toolbar: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showNewButton)
ElevatedButton.icon(
onPressed: () => context.go('/stock-out/new'),
icon: const Icon(Icons.add, size: 16),
label: const Text('新建出库审核单'),
),
const Spacer(),
if (showStatusFilter) ...[
_StatusFilterDropdown(
value: _statusFilter,
onChanged: (v) {
setState(() => _statusFilter = v ?? '');
ref
.read(stockOutListProvider.notifier)
.setStatus(v ?? '');
},
),
const SizedBox(width: 8),
],
OutlinedButton.icon(
onPressed: _pickDateRange,
icon: const Icon(Icons.date_range, size: 16),
label: Text(
_dateRange == null
? '选择日期'
: '$_startDate ~ $_endDate',
style: const TextStyle(fontSize: 13),
),
// Row 1: new button + status filter + date picker
Row(
children: [
if (showNewButton)
ElevatedButton.icon(
onPressed: () => context.go('/stock-out/new'),
icon: const Icon(Icons.add, size: 16),
label: const Text('新建出库审核单'),
),
const Spacer(),
if (showStatusFilter) ...[
_StatusFilterDropdown(
value: _statusFilter,
onChanged: (v) {
setState(() => _statusFilter = v ?? '');
ref
.read(stockOutListProvider.notifier)
.setStatus(v ?? '');
},
),
const SizedBox(width: 8),
],
OutlinedButton.icon(
onPressed: _pickDateRange,
icon: const Icon(Icons.date_range, size: 16),
label: Text(
_dateRange == null
? '选择日期'
: '$_startDate ~ $_endDate',
style: const TextStyle(fontSize: 13),
),
),
if (_dateRange != null) ...[
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.clear, size: 16),
onPressed: () {
setState(() => _dateRange = null);
ref
.read(stockOutListProvider.notifier)
.setDateRange(null, null);
},
),
],
],
),
// Row 2: multi-select filters + column toggle
const SizedBox(height: 6),
Row(
children: [
if (customerOptions.length > 1)
MultiSelectDropdown(
label: '客户',
options: customerOptions,
selected: _filterCustomer,
onChanged: (v) => setState(() => _filterCustomer = v),
),
if (customerOptions.length > 1) const SizedBox(width: 8),
if (warehouseOptions.length > 1)
MultiSelectDropdown(
label: '仓库',
options: warehouseOptions,
selected: _filterWarehouse,
onChanged: (v) => setState(() => _filterWarehouse = v),
),
const Spacer(),
ColumnToggleButton(
columns: _colDefs,
hidden: _hiddenCols,
onChanged: (v) => setState(() => _hiddenCols = v),
),
],
),
if (_dateRange != null) ...[
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.clear, size: 16),
onPressed: () {
setState(() => _dateRange = null);
ref
.read(stockOutListProvider.notifier)
.setDateRange(null, null);
},
),
],
],
),
columns: const [
DataColumn(label: Text('出库单号')),
DataColumn(label: Text('客户/往来单位')),
DataColumn(label: Text('仓库')),
DataColumn(label: Text('金额'), numeric: true),
DataColumn(label: Text('状态')),
DataColumn(label: Text('日期')),
DataColumn(label: Text('操作')),
],
rows: orders.isEmpty
? [
const DataRow(cells: [
DataCell(SizedBox()),
DataCell(Text('暂无出库单',
style: TextStyle(color: AppTheme.textSecondary))),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
])
]
: orders
.map((o) => DataRow(
cells: [
DataCell(Text(o.orderNo,
style: const TextStyle(
color: AppTheme.primary,
fontFamily: 'monospace',
fontSize: 12))),
DataCell(Text(o.partnerName ?? '-')),
DataCell(Text(o.warehouseName ?? '-')),
DataCell(Text(o.totalAmount != null
? '¥${o.totalAmount!.toStringAsFixed(2)}'
: '-')),
DataCell(StatusBadge(
_apiStatusToEnum(o.status))),
DataCell(Text(o.orderDate?.substring(0, 10) ?? '-')),
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: () => _showDetail(context, o.id),
child: const Text('详情',
style: TextStyle(
fontSize: 12,
color: AppTheme.primary)),
),
if (o.status == 'draft')
TextButton(
onPressed: () =>
_confirmSubmit(context, o),
child: const Text('提交',
style: TextStyle(
fontSize: 12,
color: AppTheme.primary)),
),
if (o.status == 'pending') ...[
TextButton(
key: Key('btn_approve_${o.id}'),
onPressed: () =>
_confirmApprove(context, o),
child: const Text('通过',
style: TextStyle(
fontSize: 12,
color: AppTheme.success)),
),
TextButton(
key: Key('btn_reject_${o.id}'),
onPressed: () =>
_confirmReject(context, o),
child: const Text('拒绝',
style: TextStyle(
fontSize: 12,
color: AppTheme.danger)),
),
],
],
)),
],
))
.toList(),
columns: columns,
rows: rows,
);
}
@@ -263,6 +380,43 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
}
}
Future<void> _confirmDelete(BuildContext context, StockOutOrder o) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除确认'),
content: Text('确认删除出库单「${o.orderNo}」?此操作不可恢复。'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消')),
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.danger,
foregroundColor: Colors.white),
child: const Text('删除'),
),
],
),
);
if (confirmed == true && mounted) {
try {
await ref.read(stockOutListProvider.notifier).deleteOrder(o.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('已删除'), backgroundColor: AppTheme.success));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('删除失败:$e'),
backgroundColor: AppTheme.danger));
}
}
}
}
Future<void> _confirmSubmit(
BuildContext context, StockOutOrder o) async {
final confirmed = await showDialog<bool>(
@@ -364,9 +518,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
);
if (confirmed == true && mounted) {
try {
await ref
.read(stockOutListProvider.notifier)
.rejectOrder(o.id);
await ref.read(stockOutListProvider.notifier).rejectOrder(o.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('已拒绝'), backgroundColor: AppTheme.accent));
@@ -446,9 +598,17 @@ class _StockOutDetailDialogState extends State<_StockOutDetailDialog> {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(
child: Text('加载失败:${snap.error}',
style: const TextStyle(color: AppTheme.danger)));
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
SizedBox(height: 12),
Text('暂无数据,网络不可用',
style: TextStyle(color: AppTheme.textSecondary)),
],
),
);
}
return _buildContent(snap.data!);
},