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
+217 -81
View File
@@ -4,6 +4,7 @@ import '../../core/theme/app_theme.dart';
import '../../models/finance.dart';
import '../../providers/finance_provider.dart';
import '../../widgets/data_table_card.dart';
import '../../widgets/multi_select_dropdown.dart';
import '../../widgets/page_scaffold.dart';
class FinanceScreen extends ConsumerWidget {
@@ -42,6 +43,21 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
// We drive fetches by maintaining a Future locally, bypassing the global provider
late Future<List<FinanceRecord>> _future;
List<FinanceRecord> _allRecords = [];
Set<String> _filterType = {};
Set<String> _filterPartner = {};
Set<String> _hiddenCols = {};
static const _colDefs = [
ColDef('date', '日期', required: true),
ColDef('type', '类型'),
ColDef('partner', '往来单位'),
ColDef('ref', '关联单据', minWidth: 900),
ColDef('amount', '金额'),
ColDef('balance', '余额'),
ColDef('remark', '备注', minWidth: 1000),
];
@override
void initState() {
@@ -60,13 +76,29 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
page: _page,
pageSize: 50,
)
.then((r) => r.data);
.then((r) {
_allRecords = r.data;
return r.data;
});
}
void _refetch() {
setState(() => _fetch());
}
List<FinanceRecord> _applyFilters(List<FinanceRecord> all) {
return all.where((r) {
if (_filterType.isNotEmpty && !_filterType.contains(r.typeLabel)) {
return false;
}
if (_filterPartner.isNotEmpty &&
!_filterPartner.contains(r.partnerName ?? '')) {
return false;
}
return true;
}).toList();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<FinanceRecord>>(
@@ -76,20 +108,29 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
if (_allRecords.isNotEmpty) {
return Column(
children: [
_OfflineBanner(onRetry: _refetch),
Expanded(child: _buildContent(_applyFilters(_allRecords))),
],
);
}
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 _buildContent(snap.data ?? []);
final filtered = _applyFilters(_allRecords);
return _buildContent(filtered);
},
);
}
@@ -99,6 +140,106 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
final totalBalance = records.fold(0.0, (s, r) => s + r.balance);
final totalPaid = totalAmount - totalBalance;
// Derive filter options from all loaded records
final typeOptions = _allRecords
.map((r) => r.typeLabel)
.toSet()
.toList()
..sort();
final partnerOptions = _allRecords
.map((r) => r.partnerName ?? '')
.where((s) => s.isNotEmpty)
.toSet()
.toList()
..sort();
// Build visible columns (manual hide + responsive auto-hide by screen width)
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' || c.key == 'balance',
))
.toList();
DataCell buildFinanceCell(String key, FinanceRecord r) {
switch (key) {
case 'date':
return DataCell(Text(
r.recordDate?.substring(0, 10) ?? '-',
style: const TextStyle(fontSize: 12),
));
case 'type':
return DataCell(_TypeBadge(r.typeLabel));
case 'partner':
return DataCell(SizedBox(
width: 160,
child: Text(r.partnerName ?? '-',
overflow: TextOverflow.ellipsis),
));
case 'ref':
return DataCell(Text(
r.refType != null && r.refId != null
? '${r.refType!.replaceAll('_', '-')}#${r.refId}'
: '-',
style: const TextStyle(
fontSize: 11,
fontFamily: 'monospace',
color: AppTheme.primary),
));
case 'amount':
return DataCell(Text(
'¥${r.amount.toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.w500),
));
case 'balance':
return DataCell(Text(
'¥${r.balance.toStringAsFixed(2)}',
style: TextStyle(
color:
r.balance > 0 ? AppTheme.danger : AppTheme.textSecondary,
fontWeight: FontWeight.w600,
),
));
case 'remark':
return DataCell(SizedBox(
width: 160,
child: Text(r.remark ?? '-',
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12, color: AppTheme.textSecondary)),
));
default:
return const DataCell(SizedBox());
}
}
final rows = records.isEmpty
? [
DataRow(
cells: List.generate(
visibleCols.length,
(i) => i == 0
? const DataCell(Text('暂无记录',
style: TextStyle(color: AppTheme.textSecondary)))
: const DataCell(SizedBox()),
),
),
]
: records
.map((r) => DataRow(
cells: visibleCols
.map((c) => buildFinanceCell(c.key, r))
.toList(),
))
.toList();
return Column(
children: [
// Summary bar (only for type-filtered tabs)
@@ -143,85 +284,49 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
_fetch();
});
},
toolbar: Row(
toolbar: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Spacer(),
_MonthSelector(
value: _month,
onChanged: (v) {
_month = v;
_page = 1;
_refetch();
},
// Filter bar + month selector + column toggle
Row(
children: [
if (typeOptions.length > 1)
MultiSelectDropdown(
label: '类型',
options: typeOptions,
selected: _filterType,
onChanged: (v) => setState(() => _filterType = v),
),
if (typeOptions.length > 1) const SizedBox(width: 8),
if (partnerOptions.length > 1)
MultiSelectDropdown(
label: '往来单位',
options: partnerOptions,
selected: _filterPartner,
onChanged: (v) =>
setState(() => _filterPartner = v),
),
const Spacer(),
_MonthSelector(
value: _month,
onChanged: (v) {
_month = v;
_page = 1;
_refetch();
},
),
const SizedBox(width: 8),
ColumnToggleButton(
columns: _colDefs,
hidden: _hiddenCols,
onChanged: (v) => setState(() => _hiddenCols = v),
),
],
),
],
),
columns: const [
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('备注')),
],
rows: records.isEmpty
? [
DataRow(cells: [
const DataCell(SizedBox()),
const DataCell(Text('暂无记录',
style: TextStyle(color: AppTheme.textSecondary))),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
])
]
: records
.map((r) => DataRow(cells: [
DataCell(Text(
r.recordDate?.substring(0, 10) ?? '-',
style: const TextStyle(fontSize: 12),
)),
DataCell(_TypeBadge(r.typeLabel)),
DataCell(SizedBox(
width: 160,
child: Text(r.partnerName ?? '-',
overflow: TextOverflow.ellipsis),
)),
DataCell(Text(
r.refType != null && r.refId != null
? '${r.refType!.replaceAll('_', '-')}#${r.refId}'
: '-',
style: const TextStyle(
fontSize: 11,
fontFamily: 'monospace',
color: AppTheme.primary),
)),
DataCell(Text(
'¥${r.amount.toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.w500),
)),
DataCell(Text(
'¥${r.balance.toStringAsFixed(2)}',
style: TextStyle(
color: r.balance > 0
? AppTheme.danger
: AppTheme.textSecondary,
fontWeight: FontWeight.w600,
),
)),
DataCell(SizedBox(
width: 160,
child: Text(r.remark ?? '-',
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary)),
)),
]))
.toList(),
columns: columns,
rows: rows,
),
),
],
@@ -365,3 +470,34 @@ class _MonthSelector 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)),
),
],
),
);
}
}
@@ -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)),
),
],
),
);
}
}
@@ -63,8 +63,10 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
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: () =>
@@ -275,8 +277,10 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
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: () =>
@@ -379,8 +383,10 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
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: () =>
@@ -53,8 +53,10 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
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: () =>
@@ -92,8 +94,10 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
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: () =>
@@ -109,8 +109,10 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
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: () =>
+467 -24
View File
@@ -1,10 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/theme/app_theme.dart';
import '../../models/number_rule.dart';
import '../../models/user.dart';
import '../../models/warehouse.dart';
import '../../providers/license_provider.dart';
import '../../providers/number_rule_provider.dart';
import '../../providers/update_provider.dart';
import '../../providers/user_provider.dart';
import '../../providers/warehouse_provider.dart';
@@ -16,10 +21,19 @@ class SettingsScreen extends ConsumerStatefulWidget {
}
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
// System params local state (UI only, no backend yet)
String _sysName = '酒库管理系统';
String _sysCurrency = '人民币(CNY';
String _sysDateFormat = 'YYYY-MM-DD';
String _sysTimezone = 'Asia/Shanghai (UTC+8)';
bool _requireStockInApproval = true;
bool _requireStockOutApproval = true;
bool _allowOverstock = false;
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 4,
length: 5,
child: Column(
children: [
Container(
@@ -37,6 +51,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
Tab(text: '仓库管理'),
Tab(text: '编号规则'),
Tab(text: '系统参数'),
Tab(text: '关于'),
],
),
),
@@ -48,6 +63,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
_buildWarehousesTab(),
_buildNumberRulesTab(),
_buildSystemParamsTab(),
_buildAboutTab(),
],
),
),
@@ -82,8 +98,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
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: () => ref.read(userListProvider.notifier).reload(),
@@ -189,8 +207,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
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: () =>
@@ -320,8 +340,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
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: () =>
@@ -480,17 +502,55 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
const Text('基本设置',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.primaryDark)),
const Divider(height: 24),
_ParamRow(label: '系统名称', value: '酒库管理系统'),
_ParamRow(label: '货币单位', value: '人民币(CNY'),
_ParamRow(label: '日期格式', value: 'YYYY-MM-DD'),
_ParamRow(label: '时区', value: 'Asia/Shanghai (UTC+8)'),
_ParamRow(
label: '系统名称',
value: _sysName,
onEdit: () => _showEditParamDialog('系统名称', _sysName,
(v) => setState(() => _sysName = v)),
),
_ParamRow(
label: '货币单位',
value: _sysCurrency,
onEdit: () => _showEditParamDialog('货币单位', _sysCurrency,
(v) => setState(() => _sysCurrency = v)),
),
_ParamRow(
label: '日期格式',
value: _sysDateFormat,
onEdit: () => _showEditParamDialog('日期格式', _sysDateFormat,
(v) => setState(() => _sysDateFormat = v)),
),
_ParamRow(
label: '时区',
value: _sysTimezone,
onEdit: () => _showEditParamDialog('时区', _sysTimezone,
(v) => setState(() => _sysTimezone = v)),
),
const SizedBox(height: 16),
const Text('审核设置',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.primaryDark)),
const Divider(height: 24),
_ParamRow(label: '入库单需要审核', value: '', isSwitch: true),
_ParamRow(label: '库单需要审核', value: '', isSwitch: true),
_ParamRow(label: '允许超量出库', value: '', isSwitch: false),
_ParamRow(
label: '库单需要审核',
value: _requireStockInApproval ? '' : '',
switchValue: _requireStockInApproval,
onSwitchChanged: (v) =>
setState(() => _requireStockInApproval = v),
),
_ParamRow(
label: '出库单需要审核',
value: _requireStockOutApproval ? '' : '',
switchValue: _requireStockOutApproval,
onSwitchChanged: (v) =>
setState(() => _requireStockOutApproval = v),
),
_ParamRow(
label: '允许超量出库',
value: _allowOverstock ? '' : '',
switchValue: _allowOverstock,
onSwitchChanged: (v) =>
setState(() => _allowOverstock = v),
),
],
),
),
@@ -499,12 +559,24 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
Row(
children: [
ElevatedButton(
onPressed: () {},
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('设置已保存'),
backgroundColor: AppTheme.success));
},
child: const Text('保存设置'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: () {},
onPressed: () => setState(() {
_sysName = '酒库管理系统';
_sysCurrency = '人民币(CNY';
_sysDateFormat = 'YYYY-MM-DD';
_sysTimezone = 'Asia/Shanghai (UTC+8)';
_requireStockInApproval = true;
_requireStockOutApproval = true;
_allowOverstock = false;
}),
child: const Text('重置默认'),
),
],
@@ -514,6 +586,295 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
);
}
// ── 关于 Tab ─────────────────────────────────────────────
Widget _buildAboutTab() {
final appVersion = ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0';
final updateInfo = ref.watch(updateProvider).valueOrNull;
final licenseAsync = ref.watch(licenseProvider);
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── 版本信息 ──
_AboutSection(
title: '版本信息',
children: [
_AboutRow(label: '当前版本', value: appVersion),
if (updateInfo != null && updateInfo.hasUpdate)
_AboutRow(
label: '最新版本',
value: 'v${updateInfo.latestVersion}',
valueColor: AppTheme.success,
trailing: TextButton(
onPressed: () => launchUpdateUrl(updateInfo.downloadUrls),
child: const Text('立即更新'),
),
)
else
_AboutRow(
label: '最新版本',
value: updateInfo != null ? '已是最新' : '检查中…',
valueColor: AppTheme.textSecondary,
trailing: TextButton(
onPressed: () =>
ref.read(updateProvider.notifier).forceCheck(),
child: const Text('检查更新'),
),
),
],
),
const SizedBox(height: 20),
// ── 授权信息 ──
_AboutSection(
title: '授权信息',
children: [
licenseAsync.when(
loading: () => const _AboutRow(label: '授权状态', value: '加载中…'),
error: (_, __) =>
const _AboutRow(label: '授权状态', value: '暂无授权信息'),
data: (lic) {
if (lic == null) {
return const _AboutRow(label: '授权状态', value: '未激活');
}
return Column(
children: [
_AboutRow(label: '授权类型', value: lic.typeLabel),
_AboutRow(
label: '授权状态',
value: lic.isExpired
? '已过期'
: lic.isActive
? '正常'
: '已停用',
valueColor: lic.isExpired
? AppTheme.danger
: lic.isActive
? AppTheme.success
: AppTheme.textSecondary,
),
if (lic.expiresAt != null)
_AboutRow(
label: '到期时间',
value: DateFormat('yyyy-MM-dd').format(lic.expiresAt!),
trailing: lic.daysRemaining != null &&
lic.daysRemaining! <= 30
? Chip(
label: Text(
lic.isExpired
? '已过期'
: '剩余 ${lic.daysRemaining}',
style: const TextStyle(
fontSize: 11, color: Colors.white),
),
backgroundColor: lic.isExpired
? AppTheme.danger
: Colors.orange,
padding: EdgeInsets.zero,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
)
: null,
)
else
const _AboutRow(label: '到期时间', value: '永久有效'),
if (lic.activatedAt != null)
_AboutRow(
label: '激活时间',
value: DateFormat('yyyy-MM-dd')
.format(lic.activatedAt!),
),
],
);
},
),
const SizedBox(height: 8),
Row(
children: [
OutlinedButton.icon(
onPressed: () => _showRenewDialog(),
icon: const Icon(Icons.card_membership, size: 16),
label: const Text('续费 / 升级授权'),
),
],
),
],
),
const SizedBox(height: 20),
// ── 关于我们 ──
_AboutSection(
title: '关于我们',
children: [
const _AboutRow(label: '开发商', value: '酒库科技有限公司'),
const _AboutRow(label: '官方网站', value: 'https://jiu.example.com'),
const _AboutRow(label: '联系邮箱', value: 'support@jiu.example.com'),
const _AboutRow(label: '技术支持', value: '周一至周五 9:00 - 18:00'),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
OutlinedButton.icon(
onPressed: () async {
final uri = Uri.parse('mailto:support@jiu.example.com'
'?subject=酒库管理系统咨询');
if (await canLaunchUrl(uri)) launchUrl(uri);
},
icon: const Icon(Icons.email_outlined, size: 16),
label: const Text('发送邮件'),
),
],
),
],
),
const SizedBox(height: 20),
// ── 意见反馈 ──
_AboutSection(
title: '意见反馈',
children: [
const _AboutRow(
label: '问题反馈',
value: '遇到 Bug 或有功能建议,欢迎告知我们',
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
ElevatedButton.icon(
onPressed: () => _showFeedbackDialog(isBug: true),
icon: const Icon(Icons.bug_report_outlined, size: 16),
label: const Text('反馈 Bug'),
),
OutlinedButton.icon(
onPressed: () => _showFeedbackDialog(isBug: false),
icon: const Icon(Icons.lightbulb_outline, size: 16),
label: const Text('功能建议'),
),
],
),
],
),
],
),
);
}
void _showRenewDialog() {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('续费 / 升级授权'),
content: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('请联系我们获取续费报价:'),
SizedBox(height: 12),
SelectableText('📧 support@jiu.example.com',
style: TextStyle(fontSize: 13)),
SizedBox(height: 6),
SelectableText('📞 400-000-0000',
style: TextStyle(fontSize: 13)),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('关闭'),
),
ElevatedButton(
onPressed: () async {
await Clipboard.setData(
const ClipboardData(text: 'support@jiu.example.com'));
if (ctx.mounted) {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('邮箱已复制到剪贴板')),
);
}
},
child: const Text('复制邮箱'),
),
],
),
);
}
void _showFeedbackDialog({required bool isBug}) {
final ctrl = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(isBug ? '反馈 Bug' : '功能建议'),
content: SizedBox(
width: 400,
child: TextField(
controller: ctrl,
maxLines: 6,
decoration: InputDecoration(
hintText: isBug
? '请描述问题的复现步骤和预期行为…'
: '请描述您希望增加的功能…',
border: const OutlineInputBorder(),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () async {
final subject = Uri.encodeComponent(isBug ? 'Bug反馈' : '功能建议');
final body = Uri.encodeComponent(ctrl.text);
final uri = Uri.parse(
'mailto:support@jiu.example.com?subject=$subject&body=$body');
if (await canLaunchUrl(uri)) launchUrl(uri);
if (ctx.mounted) Navigator.pop(ctx);
},
child: const Text('通过邮件发送'),
),
],
),
);
}
void _showEditParamDialog(
String label, String current, ValueChanged<String> onSave) {
final ctrl = TextEditingController(text: current);
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text('修改$label'),
content: SizedBox(
width: 320,
child: TextField(
controller: ctrl,
autofocus: true,
decoration: InputDecoration(labelText: label),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消')),
ElevatedButton(
onPressed: () {
final v = ctrl.text.trim();
if (v.isNotEmpty) onSave(v);
Navigator.of(ctx).pop();
},
child: const Text('确定'),
),
],
),
);
}
void _showAddUserDialog(BuildContext context) {
showDialog(
context: context,
@@ -922,9 +1283,17 @@ class _RoleBadge extends StatelessWidget {
class _ParamRow extends StatelessWidget {
final String label;
final String value;
final bool? isSwitch;
final bool? switchValue;
final ValueChanged<bool>? onSwitchChanged;
final VoidCallback? onEdit;
const _ParamRow({required this.label, required this.value, this.isSwitch});
const _ParamRow({
required this.label,
required this.value,
this.switchValue,
this.onSwitchChanged,
this.onEdit,
});
@override
Widget build(BuildContext context) {
@@ -935,19 +1304,93 @@ class _ParamRow extends StatelessWidget {
SizedBox(
width: 180,
child: Text(label,
style: const TextStyle(fontSize: 14, color: AppTheme.textSecondary)),
style: const TextStyle(
fontSize: 14, color: AppTheme.textSecondary)),
),
if (isSwitch != null)
if (switchValue != null)
Switch(
value: isSwitch!,
onChanged: (_) {},
value: switchValue!,
onChanged: onSwitchChanged,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
)
else
Text(value,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.w500)),
const Spacer(),
TextButton(onPressed: () {}, child: const Text('修改', style: TextStyle(fontSize: 12))),
if (onEdit != null)
TextButton(
onPressed: onEdit,
child: const Text('修改', style: TextStyle(fontSize: 12)),
),
],
),
);
}
}
// ── 关于页辅助 widgets ──────────────────────────────────────
class _AboutSection extends StatelessWidget {
final String title;
final List<Widget> children;
const _AboutSection({required this.title, required this.children});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark)),
const Divider(height: 24),
...children,
],
),
),
);
}
}
class _AboutRow extends StatelessWidget {
final String label;
final String value;
final Color? valueColor;
final Widget? trailing;
const _AboutRow({
required this.label,
required this.value,
this.valueColor,
this.trailing,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
SizedBox(
width: 88,
child: Text(label,
style: const TextStyle(
fontSize: 13, color: AppTheme.textSecondary)),
),
Expanded(
child: Text(value,
style: TextStyle(
fontSize: 13,
color: valueColor ?? AppTheme.textPrimary,
fontWeight: FontWeight.w500)),
),
if (trailing != null) trailing!,
],
),
);
+343 -51
View File
@@ -5,6 +5,8 @@ import 'package:intl/intl.dart';
import 'dart:async';
import '../../core/auth/auth_state.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/connectivity_provider.dart';
import '../../providers/update_provider.dart';
class AppShell extends ConsumerStatefulWidget {
final Widget child;
@@ -16,14 +18,55 @@ class AppShell extends ConsumerStatefulWidget {
class _AppShellState extends ConsumerState<AppShell> {
bool _sidebarExpanded = true;
final String _loginTime =
DateFormat('HH:mm:ss').format(DateTime.now());
final String _loginTime = DateFormat('HH:mm:ss').format(DateTime.now());
bool _forceDialogShown = false;
void _showForceUpdateDialog(
BuildContext context, AppUpdateInfo info) {
if (_forceDialogShown) return;
_forceDialogShown = true;
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => PopScope(
canPop: false,
child: AlertDialog(
title: const Row(
children: [
Icon(Icons.system_update, color: AppTheme.primary),
SizedBox(width: 8),
Text('发现新版本'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('当前版本需要更新至 v${info.latestVersion} 才能继续使用。'),
if (info.releaseNotes.isNotEmpty) ...[
const SizedBox(height: 12),
Text(info.releaseNotes,
style: const TextStyle(
color: AppTheme.textSecondary, fontSize: 13)),
],
],
),
actions: [
ElevatedButton(
onPressed: () => launchUpdateUrl(info.downloadUrls),
child: const Text('立即更新'),
),
],
),
),
);
}
final List<_NavItem> _navItems = const [
_NavItem(icon: Icons.input, label: '入库管理', path: '/stock-in'),
_NavItem(icon: Icons.output, label: '出库管理', path: '/stock-out'),
_NavItem(icon: Icons.inventory_2, label: '库存管理', path: '/inventory'),
_NavItem(icon: Icons.track_changes, label: '商品追踪', path: '/batches'),
_NavItem(icon: Icons.track_changes, label: '商品管理', path: '/batches'),
_NavItem(
icon: Icons.account_balance_wallet,
label: '财务管理',
@@ -36,8 +79,13 @@ class _AppShellState extends ConsumerState<AppShell> {
@override
Widget build(BuildContext context) {
final user = ref.watch(authStateProvider).user;
final isOnline = ref.watch(connectivityProvider);
final location = GoRouterState.of(context).matchedLocation;
final sidebarWidth = _sidebarExpanded ? 200.0 : 56.0;
final updateNotifier = ref.watch(updateProvider.notifier);
final updateInfo = ref.watch(updateProvider).valueOrNull;
final appVersion =
ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0';
return Scaffold(
body: Column(
@@ -58,24 +106,26 @@ class _AppShellState extends ConsumerState<AppShell> {
tooltip: _sidebarExpanded ? '收起侧边栏' : '展开侧边栏',
),
const SizedBox(width: 4),
const Icon(Icons.wine_bar, color: Colors.white, size: 22),
const SizedBox(width: 8),
const Text(
'酒库管理系统',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: 0.5),
),
_ShopButton(user: user, version: appVersion),
const Spacer(),
if (user != null) ...[
const Icon(Icons.business,
color: Colors.white70, size: 14),
const SizedBox(width: 4),
Text(user.shopNo,
style: const TextStyle(
color: Colors.white70, fontSize: 13)),
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => _showShopPanel(context, user, version: appVersion),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.business,
color: Colors.white70, size: 14),
const SizedBox(width: 4),
Text(user.shopNo,
style: const TextStyle(
color: Colors.white70, fontSize: 13)),
],
),
),
),
const SizedBox(width: 20),
const Icon(Icons.person_outline,
color: Colors.white70, size: 14),
@@ -158,36 +208,152 @@ class _AppShellState extends ConsumerState<AppShell> {
Expanded(
child: Column(
children: [
// Update banner(非强制更新)
if (updateInfo != null &&
updateInfo.hasUpdate &&
!updateInfo.forceUpdate &&
!updateNotifier.isDismissed)
Container(
width: double.infinity,
color: const Color(0xFFFFF8E1),
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 6),
child: Row(
children: [
const Icon(Icons.system_update,
size: 16, color: Color(0xFFF57F17)),
const SizedBox(width: 8),
Expanded(
child: Text(
'发现新版本 v${updateInfo.latestVersion}'
'${updateInfo.releaseNotes.isNotEmpty ? " · ${updateInfo.releaseNotes}" : ""}',
style: const TextStyle(
color: Color(0xFF5D4037),
fontSize: 13),
overflow: TextOverflow.ellipsis,
),
),
TextButton(
onPressed: () => launchUpdateUrl(
updateInfo.downloadUrls),
style: TextButton.styleFrom(
foregroundColor:
const Color(0xFFF57F17)),
child: const Text('立即更新'),
),
TextButton(
onPressed: updateNotifier.dismiss,
style: TextButton.styleFrom(
foregroundColor:
const Color(0xFF9E9E9E)),
child: const Text('稍后再说'),
),
],
),
),
// 强制更新 dialog(用 postFrameCallback 避免 build 中 showDialog
if (updateInfo != null &&
updateInfo.hasUpdate &&
updateInfo.forceUpdate)
Builder(builder: (ctx) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_showForceUpdateDialog(ctx, updateInfo);
});
return const SizedBox.shrink();
}),
// Offline banner
if (!isOnline)
Container(
width: double.infinity,
color: AppTheme.danger,
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 6),
child: const Row(
children: [
Icon(Icons.wifi_off,
size: 16, color: Colors.white),
SizedBox(width: 8),
Text(
'网络连接已断开 · 当前处于只读模式,所有写操作已禁用',
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500),
),
],
),
),
Expanded(child: widget.child),
// Status bar
Container(
height: 28,
color: const Color(0xFF37474F),
padding:
const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
if (user != null) ...[
_StatusItem(
icon: Icons.store,
text: '门店编号:${user.shopNo}'),
const _StatusDivider(),
_StatusItem(
icon: Icons.person,
text: '登录用户:${user.username}'),
const _StatusDivider(),
_StatusItem(
icon: Icons.login,
text: '登录时间:$_loginTime'),
const _StatusDivider(),
],
const _ClockWidget(),
const Spacer(),
const _StatusItem(
icon: Icons.info_outline,
text: 'v1.0.0'),
],
),
LayoutBuilder(
builder: (context, constraints) {
final w = constraints.maxWidth;
// Three tiers: wide / medium / narrow
final wide = w >= 650;
final medium = w >= 190;
final iconOnly = !medium;
return Container(
height: 28,
color: isOnline
? const Color(0xFF37474F)
: AppTheme.danger,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
if (!isOnline) ...[
const Icon(Icons.wifi_off,
size: 11, color: Colors.white70),
if (!iconOnly) ...[
const SizedBox(width: 4),
const Text('离线',
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600)),
],
const _StatusDivider(),
],
if (isOnline && user != null) ...[
_StatusItem(
icon: Icons.store,
text: user.shopNo,
iconOnly: iconOnly),
const _StatusDivider(),
_StatusItem(
icon: Icons.person,
text: user.username,
iconOnly: iconOnly),
if (wide) ...[
const _StatusDivider(),
_StatusItem(
icon: Icons.login,
text: '登录时间:$_loginTime'),
const _StatusDivider(),
const _ClockWidget(),
] else
const _StatusDivider(),
],
const Spacer(),
_StatusItem(
icon: isOnline
? Icons.cloud_done_outlined
: Icons.cloud_off_outlined,
text: isOnline ? '已连接' : '连接已断开',
iconOnly: iconOnly,
),
const _StatusDivider(),
_StatusItem(
icon: Icons.info_outline,
text: ref
.watch(appVersionProvider)
.valueOrNull ??
'v1.0.0',
iconOnly: iconOnly),
],
),
);
},
),
],
),
@@ -284,7 +450,9 @@ class _SidebarItem extends StatelessWidget {
class _StatusItem extends StatelessWidget {
final IconData icon;
final String text;
const _StatusItem({required this.icon, required this.text});
final bool iconOnly;
const _StatusItem(
{required this.icon, required this.text, this.iconOnly = false});
@override
Widget build(BuildContext context) {
@@ -292,9 +460,11 @@ class _StatusItem extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 11, color: Colors.white54),
const SizedBox(width: 4),
Text(text,
style: const TextStyle(color: Colors.white54, fontSize: 11)),
if (!iconOnly) ...[
const SizedBox(width: 4),
Text(text,
style: const TextStyle(color: Colors.white54, fontSize: 11)),
],
],
);
}
@@ -344,6 +514,128 @@ class _ClockWidgetState extends State<_ClockWidget> {
}
}
void _showShopPanel(BuildContext context, AuthUser u, {String version = 'v1.0.0'}) {
showDialog(
context: context,
builder: (ctx) => Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
child: SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: const BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.vertical(top: Radius.circular(10)),
),
child: Row(
children: [
const Icon(Icons.store, color: Colors.white, size: 20),
const SizedBox(width: 10),
const Text('门店信息',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600)),
const Spacer(),
IconButton(
onPressed: () => Navigator.pop(ctx),
icon: const Icon(Icons.close, color: Colors.white70, size: 18),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
),
// Info rows
Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
_InfoRow(icon: Icons.tag, label: '门店编号', value: u.shopNo),
const SizedBox(height: 14),
_InfoRow(icon: Icons.person, label: '登录账号', value: u.username),
const SizedBox(height: 14),
_InfoRow(icon: Icons.badge_outlined, label: '姓名', value: u.realName),
const SizedBox(height: 14),
_InfoRow(icon: Icons.info_outline, label: '系统版本', value: version),
],
),
),
],
),
),
),
);
}
class _ShopButton extends StatelessWidget {
final AuthUser? user;
final String version;
const _ShopButton({this.user, this.version = 'v1.0.0'});
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () {
if (user != null) _showShopPanel(context, user!, version: version);
},
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.wine_bar, color: Colors.white, size: 22),
SizedBox(width: 8),
Text(
'酒库管理系统',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: 0.5),
),
],
),
),
);
}
}
class _InfoRow extends StatelessWidget {
final IconData icon;
final String label;
final String value;
const _InfoRow({required this.icon, required this.label, required this.value});
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 16, color: AppTheme.textSecondary),
const SizedBox(width: 10),
SizedBox(
width: 72,
child: Text(label,
style: const TextStyle(
fontSize: 13, color: AppTheme.textSecondary)),
),
Expanded(
child: Text(value,
style: const TextStyle(
fontSize: 13,
color: AppTheme.textPrimary,
fontWeight: FontWeight.w500)),
),
],
);
}
}
class _HoverMenuItem extends StatefulWidget {
final IconData icon;
final String label;
@@ -8,9 +8,11 @@ import '../../providers/partner_provider.dart';
import '../../providers/product_provider.dart';
import '../../providers/stock_in_provider.dart';
import '../../providers/warehouse_provider.dart';
import '../../repositories/stock_in_repository.dart';
class StockInFormScreen extends ConsumerStatefulWidget {
const StockInFormScreen({super.key});
final int? editOrderId;
const StockInFormScreen({super.key, this.editOrderId});
@override
ConsumerState<StockInFormScreen> createState() => _StockInFormScreenState();
@@ -23,13 +25,54 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
int? _partnerId;
DateTime _orderDate = DateTime.now();
bool _submitting = false;
bool _loadingEdit = false;
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(stockInRepositoryProvider)
.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());
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('加载失败:$e'), backgroundColor: AppTheme.danger),
);
}
} finally {
if (mounted) setState(() => _loadingEdit = false);
}
}
@override
@@ -101,7 +144,14 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
};
try {
await ref.read(stockInListProvider.notifier).createOrder(data);
if (_isEdit) {
await ref
.read(stockInRepositoryProvider)
.update(widget.editOrderId!, data);
ref.read(stockInListProvider.notifier).reload();
} else {
await ref.read(stockInListProvider.notifier).createOrder(data);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -144,8 +194,8 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
tooltip: '返回',
),
const SizedBox(width: 8),
const Text('新建入库单',
style: TextStyle(
Text(_isEdit ? '修改入库单' : '新建入库单',
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w600)),
const Spacer(),
OutlinedButton(
@@ -174,6 +224,9 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
),
),
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_in.dart';
import '../../providers/stock_in_provider.dart';
import '../../repositories/stock_in_repository.dart';
import '../../widgets/data_table_card.dart';
import '../../widgets/multi_select_dropdown.dart';
import '../../widgets/page_scaffold.dart';
import '../../widgets/status_badge.dart';
@@ -19,6 +20,19 @@ class StockInListScreen extends ConsumerStatefulWidget {
class _StockInListScreenState extends ConsumerState<StockInListScreen> {
String _statusFilter = '';
DateTimeRange? _dateRange;
Set<String> _filterWarehouse = {};
Set<String> _filterSupplier = {};
Set<String> _hiddenCols = {};
static const _colDefs = [
ColDef('order_no', '入库单号', required: true),
ColDef('supplier', '供应商', 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')}'
@@ -64,8 +78,10 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
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: () =>
@@ -76,20 +92,55 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
),
),
data: (result) {
final List<StockInOrder> orders;
final allOrders = result.data;
final List<StockInOrder> 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 supplierOptions = 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 (_filterSupplier.isNotEmpty) {
orders = orders
.where((o) => _filterSupplier.contains(o.partnerName ?? ''))
.toList();
}
return _buildOrderTable(
orders: orders,
totalCount: orders.length,
page: result.page,
showStatusFilter: filterStatus == 'exclude_pending',
showNewButton: showNewButton,
warehouseOptions: warehouseOptions,
supplierOptions: supplierOptions,
);
},
);
@@ -101,139 +152,205 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
required int page,
required bool showStatusFilter,
required bool showNewButton,
required List<String> warehouseOptions,
required List<String> supplierOptions,
}) {
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, StockInOrder 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 'supplier':
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-in/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(stockInListProvider.notifier).setPage(p),
toolbar: Row(
toolbar: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showNewButton)
ElevatedButton.icon(
onPressed: () => context.go('/stock-in/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(stockInListProvider.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-in/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(stockInListProvider.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(stockInListProvider.notifier)
.setDateRange(null, null);
},
),
],
],
),
// Row 2: multi-select filters + column toggle
const SizedBox(height: 6),
Row(
children: [
if (supplierOptions.length > 1)
MultiSelectDropdown(
label: '供应商',
options: supplierOptions,
selected: _filterSupplier,
onChanged: (v) => setState(() => _filterSupplier = v),
),
if (supplierOptions.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(stockInListProvider.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,
);
}
@@ -262,6 +379,43 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
}
}
Future<void> _confirmDelete(BuildContext context, StockInOrder 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(stockInListProvider.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, StockInOrder o) async {
final confirmed = await showDialog<bool>(
context: context,
@@ -359,9 +513,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
);
if (confirmed == true && mounted) {
try {
await ref
.read(stockInListProvider.notifier)
.rejectOrder(o.id);
await ref.read(stockInListProvider.notifier).rejectOrder(o.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('已拒绝'), backgroundColor: AppTheme.accent));
@@ -441,9 +593,17 @@ class _StockInDetailDialogState extends State<_StockInDetailDialog> {
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!);
},
@@ -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!);
},