feat: 自动更新、系统设置、安全修复
后端: - 新增 GET /version 版本检查端点(version.go + version.yaml) - 新增 GET /license/info 接口,返回门店授权信息 - 修复 GenerateOrderNo 并发重复单号:事务内加 FOR UPDATE 行锁 - 修复 ApproveStockOut 超卖竞态:预检和库存更新均加 FOR UPDATE - 修复 Product Create 并发 code 冲突:加重试逻辑,schema 加 UNIQUE KEY - 修复 Product Update 全字段覆盖:改用 selective Updates() - 挂载 ReadOnly 中间件(全局)+ AdminOnly(用户管理路由) - version.go 配置缺失时返回 500 而非静默降级 前端: - 新增自动更新检测(update_provider.dart)+ shell 更新 banner/弹窗 - 新增系统设置"关于"标签页:版本、授权、开发信息、意见反馈 - 新增离线缓存:所有 AsyncNotifierProvider 支持断网浏览历史数据 - 新增门店信息弹窗(点击左上角 logo 或右上角门店号触发) - 提取 AppConfig 统一管理 BASE_URL,支持 --dart-define 注入 - update_provider.dart 加 kIsWeb 保护,修复 Web 平台崩溃 - dev.sh 新增 stop 命令,修复 stop 误杀前端进程问题 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import '../../core/theme/app_theme.dart';
|
||||
import '../../models/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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user