393e227de5
后端: - 新增 product_images 表,支持每商品最多5张图(服务端压缩至1200px/JPEG85%) - products 表新增 public_id(UUID)、description 字段 - 新增商品详情接口、二维码接口、公开商品接口(无鉴权) - 修复 XLS 导入:OLE2 magic bytes 检测 + 临时文件解析,兼容 extrame/xls - 修复商品/名称/系列/规格三张表导入数据为0(LastCol()=0 bug) - 所有导入接口返回 total/imported/skipped 统计 - config 新增 StorageConfig,支持 STORAGE_* 环境变量覆盖 - 种子数据修复:products 补 public_id、新增 product_images TRUNCATE、schema.sql 表名修正 前端: - 商品详情页:图片上传/删除、描述内联编辑、二维码弹窗、公开链接复制 - 公开商品页:无鉴权路由 /product/:public_id,Flutter Web SPA - 商品详情列表(批次追踪)商品名超链接跳转详情页 - 导航「商品管理」改名「商品详情」 - 所有列表表格新增每页条数选择(10/20/50/100) - 表格列头内嵌筛选(FilterableColumnHeader) - 导出 Excel 功能(入库/出库/库存/财务/批次/往来单位) - 网络恢复自动刷新 + 离线缓存展示 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
538 lines
17 KiB
Dart
538 lines
17 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
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' show ColDef, ColumnToggleButton, FilterableColumnHeader;
|
|
import '../../widgets/page_scaffold.dart';
|
|
import '../../providers/connectivity_provider.dart';
|
|
import '../../core/utils/export_util.dart';
|
|
|
|
class FinanceScreen extends ConsumerWidget {
|
|
const FinanceScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
return PageScaffold(
|
|
title: '财务管理',
|
|
tabs: const [
|
|
Tab(text: '全部记录'),
|
|
Tab(text: '应付账款'),
|
|
Tab(text: '应收账款'),
|
|
],
|
|
tabViews: [
|
|
_FinanceTab(typeFilter: ''),
|
|
_FinanceTab(typeFilter: 'payable'),
|
|
_FinanceTab(typeFilter: 'receivable'),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// Each tab has its own independent state — avoids shared-provider conflicts
|
|
class _FinanceTab extends ConsumerStatefulWidget {
|
|
final String typeFilter;
|
|
const _FinanceTab({required this.typeFilter});
|
|
|
|
@override
|
|
ConsumerState<_FinanceTab> createState() => _FinanceTabState();
|
|
}
|
|
|
|
class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
|
late String _month;
|
|
int _page = 1;
|
|
int _pageSize = 20;
|
|
|
|
// We drive fetches by maintaining a Future locally, bypassing the global provider
|
|
late Future<List<FinanceRecord>> _future;
|
|
List<FinanceRecord> _allRecords = [];
|
|
int _total = 0;
|
|
|
|
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() {
|
|
super.initState();
|
|
final now = DateTime.now();
|
|
_month = '${now.year}-${now.month.toString().padLeft(2, '0')}';
|
|
_fetch();
|
|
}
|
|
|
|
void _fetch() {
|
|
_future = ref
|
|
.read(financeRepositoryProvider)
|
|
.listRecords(
|
|
type: widget.typeFilter.isEmpty ? null : widget.typeFilter,
|
|
month: _month,
|
|
page: _page,
|
|
pageSize: _pageSize,
|
|
)
|
|
.then((r) {
|
|
_allRecords = r.data;
|
|
_total = r.total;
|
|
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) {
|
|
// 网络恢复时自动刷新
|
|
ref.listen(networkRecoveryCountProvider, (_, __) => _refetch());
|
|
|
|
return FutureBuilder<List<FinanceRecord>>(
|
|
future: _future,
|
|
builder: (context, snap) {
|
|
if (snap.connectionState == ConnectionState.waiting) {
|
|
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: [
|
|
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
|
|
const SizedBox(height: 12),
|
|
const Text('暂无数据,网络不可用', style: TextStyle(color: AppTheme.textSecondary)),
|
|
const SizedBox(height: 12),
|
|
ElevatedButton(onPressed: _refetch, child: const Text('重试')),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
final filtered = _applyFilters(_allRecords);
|
|
return _buildContent(filtered);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildContent(List<FinanceRecord> records) {
|
|
final totalAmount = records.fold(0.0, (s, r) => s + r.amount);
|
|
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) {
|
|
final label = switch (c.key) {
|
|
'type' => FilterableColumnHeader(
|
|
text: c.label,
|
|
options: typeOptions,
|
|
selected: _filterType,
|
|
onChanged: (v) => setState(() => _filterType = v),
|
|
),
|
|
'partner' => FilterableColumnHeader(
|
|
text: c.label,
|
|
options: partnerOptions,
|
|
selected: _filterPartner,
|
|
onChanged: (v) => setState(() => _filterPartner = v),
|
|
),
|
|
_ => Text(c.label),
|
|
};
|
|
return DataColumn(
|
|
label: 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)
|
|
if (widget.typeFilter.isNotEmpty && records.isNotEmpty)
|
|
Container(
|
|
color: AppTheme.background,
|
|
padding: const EdgeInsets.all(12),
|
|
child: Row(
|
|
children: [
|
|
_SummaryCard(
|
|
title: '本期总额',
|
|
value: '¥${(totalAmount / 10000).toStringAsFixed(2)}万',
|
|
icon: Icons.account_balance_wallet,
|
|
color: AppTheme.primary,
|
|
),
|
|
const SizedBox(width: 12),
|
|
_SummaryCard(
|
|
title: '已结清',
|
|
value: '¥${(totalPaid / 10000).toStringAsFixed(2)}万',
|
|
icon: Icons.check_circle,
|
|
color: AppTheme.success,
|
|
),
|
|
const SizedBox(width: 12),
|
|
_SummaryCard(
|
|
title: '未结清',
|
|
value: '¥${(totalBalance / 10000).toStringAsFixed(2)}万',
|
|
icon: Icons.pending_actions,
|
|
color: AppTheme.danger,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (widget.typeFilter.isNotEmpty && records.isNotEmpty)
|
|
const Divider(height: 1),
|
|
Expanded(
|
|
child: DataTableCard(
|
|
totalCount: _total > 0 ? _total : records.length,
|
|
page: _page,
|
|
pageSize: _pageSize,
|
|
onPageChanged: (p) {
|
|
setState(() {
|
|
_page = p;
|
|
_fetch();
|
|
});
|
|
},
|
|
onPageSizeChanged: (s) {
|
|
setState(() {
|
|
_pageSize = s;
|
|
_page = 1;
|
|
_fetch();
|
|
});
|
|
},
|
|
toolbar: Row(
|
|
children: [
|
|
OutlinedButton.icon(
|
|
onPressed: () {
|
|
final tabName = widget.typeFilter.isEmpty
|
|
? '财务全部记录'
|
|
: widget.typeFilter == 'payable'
|
|
? '应付账款'
|
|
: '应收账款';
|
|
exportExcel(
|
|
filename: tabName,
|
|
headers: ['日期', '类型', '往来单位', '关联单据', '金额', '余额', '备注'],
|
|
rows: records.map((r) => [
|
|
r.recordDate?.substring(0, 10) ?? '',
|
|
r.typeLabel,
|
|
r.partnerName ?? '',
|
|
r.refType != null && r.refId != null
|
|
? '${r.refType!.replaceAll('_', '-')}#${r.refId}'
|
|
: '',
|
|
r.amount,
|
|
r.balance,
|
|
r.remark ?? '',
|
|
]).toList(),
|
|
);
|
|
},
|
|
icon: const Icon(Icons.download, size: 16),
|
|
label: const Text('导出'),
|
|
),
|
|
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: columns,
|
|
rows: rows,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TypeBadge extends StatelessWidget {
|
|
final String label;
|
|
const _TypeBadge(this.label);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
Color bg, fg;
|
|
switch (label) {
|
|
case '应收账款':
|
|
bg = const Color(0xFFE3F2FD);
|
|
fg = AppTheme.primary;
|
|
break;
|
|
case '应付账款':
|
|
bg = const Color(0xFFFFEBEE);
|
|
fg = AppTheme.danger;
|
|
break;
|
|
case '收款':
|
|
bg = const Color(0xFFE8F5E9);
|
|
fg = AppTheme.success;
|
|
break;
|
|
case '付款':
|
|
bg = const Color(0xFFFFF3E0);
|
|
fg = AppTheme.accent;
|
|
break;
|
|
default:
|
|
bg = const Color(0xFFF5F5F5);
|
|
fg = AppTheme.textSecondary;
|
|
}
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
decoration:
|
|
BoxDecoration(color: bg, borderRadius: BorderRadius.circular(3)),
|
|
child: Text(label,
|
|
style: TextStyle(
|
|
color: fg, fontSize: 12, fontWeight: FontWeight.w500)),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SummaryCard extends StatelessWidget {
|
|
final String title;
|
|
final String value;
|
|
final IconData icon;
|
|
final Color color;
|
|
|
|
const _SummaryCard({
|
|
required this.title,
|
|
required this.value,
|
|
required this.icon,
|
|
required this.color,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Expanded(
|
|
child: Container(
|
|
height: 72,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.surface,
|
|
borderRadius: BorderRadius.circular(4),
|
|
border: Border.all(color: AppTheme.border, width: 0.5),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(icon, color: color, size: 22),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(title,
|
|
style: const TextStyle(
|
|
fontSize: 12, color: AppTheme.textSecondary)),
|
|
const SizedBox(height: 4),
|
|
Text(value,
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w700,
|
|
color: color)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MonthSelector extends StatelessWidget {
|
|
final String value;
|
|
final ValueChanged<String> onChanged;
|
|
|
|
const _MonthSelector({required this.value, required this.onChanged});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final now = DateTime.now();
|
|
final months = List.generate(12, (i) {
|
|
final d = DateTime(now.year, now.month - i, 1);
|
|
return '${d.year}-${d.month.toString().padLeft(2, '0')}';
|
|
});
|
|
final safeValue = months.contains(value) ? value : months.first;
|
|
|
|
return Container(
|
|
height: 36,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: AppTheme.border),
|
|
borderRadius: BorderRadius.circular(4),
|
|
color: AppTheme.surface,
|
|
),
|
|
child: DropdownButtonHideUnderline(
|
|
child: DropdownButton<String>(
|
|
value: safeValue,
|
|
items: months
|
|
.map((m) => DropdownMenuItem(
|
|
value: m,
|
|
child: Text(m, style: const TextStyle(fontSize: 13))))
|
|
.toList(),
|
|
onChanged: (v) => onChanged(v!),
|
|
style:
|
|
const TextStyle(fontSize: 13, color: AppTheme.textPrimary),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|