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)),
),
],
),
);
}
}