Files
jiu/client/lib/screens/finance/finance_screen.dart
T
wangjia dfa1920dda feat(client): 列表屏搜索前置/显示字段记忆/移动端表格切换与工具栏瘦身/退出登录归位
- 库存查询搜索框移到工具栏最前(桌面)/独占首行(移动端)
- 修复入库/出库/财务「显示字段」失效:移除 minWidth 运行时强制隐藏,
  用户勾选权威、minWidth 仅作首次默认;新增 ColumnPrefs 持久化记忆(四屏独立)
- 移动端库存支持表格/卡片列表切换
- 移动端三个列表屏工具栏改 Wrap 紧凑布局、导出/显示字段图标化
- 退出登录统一到左侧导航:删系统设置内与桌面右上角下拉,桌面左侧栏底部新增

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 18:33:31 +08:00

810 lines
26 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_theme.dart';
import '../../models/finance.dart';
import '../../providers/finance_provider.dart';
import '../../widgets/data_table_card.dart';
import '../../widgets/mobile_list_card.dart';
import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader;
import '../../core/storage/column_prefs.dart';
import '../../widgets/page_scaffold.dart';
import '../../providers/connectivity_provider.dart';
import '../../core/utils/export_util.dart';
import '../../repositories/finance_repository.dart';
import '../../widgets/write_guard.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'),
],
);
}
}
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;
late Future<List<FinanceRecord>> _future;
List<FinanceRecord> _allRecords = [];
int _total = 0;
Set<String> _filterType = {};
Set<String> _filterPartner = {};
Set<String>? _hiddenCols; // null = 尚未载入本地存档(回退到 minWidth 首次默认)
static const _screenId = 'finance';
static const _colDefs = [
ColDef('date', '日期', required: true),
ColDef('type', '类型'),
ColDef('partner', '往来单位'),
ColDef('ref', '关联单据', minWidth: 900),
ColDef('amount', '金额'),
ColDef('balance', '余额'),
ColDef('status', '状态'),
ColDef('remark', '备注', minWidth: 1000),
ColDef('actions', '操作'),
];
@override
void initState() {
super.initState();
final now = DateTime.now();
_month = '${now.year}-${now.month.toString().padLeft(2, '0')}';
_fetch();
ColumnPrefs.load(_screenId).then((saved) {
if (saved != null && mounted) setState(() => _hiddenCols = saved);
});
}
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();
}
Future<void> _closeRecord(FinanceRecord r) async {
try {
await ref.read(financeRepositoryProvider).close(r.id);
_refetch();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('操作失败:$e'), backgroundColor: AppTheme.danger),
);
}
}
}
void _showAddDialog() {
showDialog(
context: context,
builder: (_) => _AddPaymentDialog(
typeFilter: widget.typeFilter,
onSaved: _refetch,
repo: ref.read(financeRepositoryProvider),
),
);
}
/// 财务记录:窄屏卡片
Widget _financeCard(FinanceRecord r) {
final canClose = (r.type == 'payable' || r.type == 'receivable') &&
r.status == 'open';
final showStatus = r.type == 'payable' || r.type == 'receivable';
return MobileListCard(
title: Text(r.partnerName?.isNotEmpty == true ? r.partnerName! : r.typeLabel),
subtitle: Text(r.recordDate?.substring(0, 10) ?? '-'),
trailing: _TypeBadge(r.typeLabel),
fields: [
MobileCardField('金额', '¥${r.amount.toStringAsFixed(2)}'),
MobileCardField('余额', null,
valueWidget: Text(
'¥${r.balance.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 13,
color: r.balance > 0
? AppTheme.danger
: AppTheme.textSecondary,
fontWeight: FontWeight.w600,
),
)),
if (r.refType != null && r.refId != null)
MobileCardField(
'关联单据', '${r.refType!.replaceAll('_', '-')}#${r.refId}'),
if (showStatus)
MobileCardField('状态', null, valueWidget: _StatusBadge(r.status)),
if (r.remark?.isNotEmpty == true) MobileCardField('备注', r.remark),
],
actions: (canClose && !WriteGuard.isReadonly(ref))
? [
TextButton(
onPressed: () => _closeRecord(r),
child: const Text('结清',
style: TextStyle(fontSize: 13, color: AppTheme.success)),
),
]
: null,
);
}
@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) {
// Summary: only for payable/receivable tabs
final totalAmount = records.fold(0.0, (s, r) => s + r.amount);
final openAmount = records
.where((r) => (r.type == 'payable' || r.type == 'receivable') && r.status == 'open')
.fold(0.0, (s, r) => s + r.amount);
final closedAmount = records
.where((r) => (r.type == 'payable' || r.type == 'receivable') && r.status == 'closed')
.fold(0.0, (s, r) => s + r.amount);
final typeOptions = _allRecords.map((r) => r.typeLabel).toSet().toList()..sort();
final partnerOptions = _allRecords
.map((r) => r.partnerName ?? '')
.where((s) => s.isNotEmpty)
.toSet()
.toList()
..sort();
final screenWidth = MediaQuery.of(context).size.width;
// 隐藏列:本地存档优先;无存档时按 minWidth 计算首次默认隐藏集。
final hidden = _hiddenCols ??
_colDefs
.where((c) => c.minWidth != null && screenWidth < c.minWidth!)
.map((c) => c.key)
.toSet();
// 列可见性只看用户选择(minWidth 仅作首次默认,不再运行时强制隐藏)。
final visibleCols =
_colDefs.where((c) => !hidden.contains(c.key)).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 'status':
if (r.type != 'payable' && r.type != 'receivable') {
return const DataCell(SizedBox());
}
return DataCell(_StatusBadge(r.status));
case 'remark':
return DataCell(SizedBox(
width: 160,
child: Text(r.remark ?? '-',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)),
));
case 'actions':
if ((r.type == 'payable' || r.type == 'receivable') &&
r.status == 'open' &&
!WriteGuard.isReadonly(ref)) {
return DataCell(TextButton(
onPressed: () => _closeRecord(r),
child: const Text('结清',
style: TextStyle(fontSize: 12, color: AppTheme.success)),
));
}
return const DataCell(SizedBox());
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();
// Determine add button label
final addLabel = widget.typeFilter == 'payable'
? '添加付款'
: widget.typeFilter == 'receivable'
? '添加收款'
: null;
return Column(
children: [
if (widget.typeFilter.isNotEmpty && records.isNotEmpty)
Builder(builder: (context) {
final w = context.isMobile ? 150.0 : null;
final cards = [
_SummaryCard(
title: '合计金额',
value: '¥${(totalAmount / 10000).toStringAsFixed(2)}',
icon: Icons.account_balance_wallet,
color: AppTheme.primary,
width: w,
),
const SizedBox(width: 12),
_SummaryCard(
title: '未结清',
value: '¥${(openAmount / 10000).toStringAsFixed(2)}',
icon: Icons.pending_actions,
color: AppTheme.danger,
width: w,
),
const SizedBox(width: 12),
_SummaryCard(
title: '已结清',
value: '¥${(closedAmount / 10000).toStringAsFixed(2)}',
icon: Icons.check_circle,
color: AppTheme.success,
width: w,
),
];
return Container(
color: AppTheme.background,
padding: const EdgeInsets.all(12),
child: context.isMobile
? SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(children: cards),
)
: Row(children: cards),
);
}),
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: [
if (addLabel != null && !WriteGuard.isReadonly(ref))
ElevatedButton.icon(
onPressed: _showAddDialog,
icon: const Icon(Icons.add, size: 16),
label: Text(addLabel),
),
if (addLabel != null && !WriteGuard.isReadonly(ref))
const SizedBox(width: 8),
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.status == 'open' ? '未结清' : '已结清',
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: hidden,
onChanged: (v) {
setState(() => _hiddenCols = v);
ColumnPrefs.save(_screenId, v);
},
),
],
),
columns: columns,
rows: rows,
mobileCards: records.map(_financeCard).toList(),
),
),
],
);
}
}
// ── 添加付款/收款弹窗 ─────────────────────────────────────────
class _AddPaymentDialog extends StatefulWidget {
final String typeFilter; // 'payable' | 'receivable'
final VoidCallback onSaved;
final FinanceRepository repo;
const _AddPaymentDialog({
required this.typeFilter,
required this.onSaved,
required this.repo,
});
@override
State<_AddPaymentDialog> createState() => _AddPaymentDialogState();
}
class _AddPaymentDialogState extends State<_AddPaymentDialog> {
final _amountCtrl = TextEditingController();
final _remarkCtrl = TextEditingController();
final _partnerCtrl = TextEditingController();
DateTime _date = DateTime.now();
bool _saving = false;
String get _type => widget.typeFilter == 'payable' ? 'payment' : 'receipt';
String get _title => widget.typeFilter == 'payable' ? '添加付款记录' : '添加收款记录';
@override
void dispose() {
_amountCtrl.dispose();
_remarkCtrl.dispose();
_partnerCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
final amount = double.tryParse(_amountCtrl.text.trim());
if (amount == null || amount <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请输入有效金额'), backgroundColor: AppTheme.danger),
);
return;
}
setState(() => _saving = true);
try {
final body = <String, dynamic>{
'type': _type,
'amount': amount,
'record_date': '${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}',
if (_remarkCtrl.text.trim().isNotEmpty) 'remark': _remarkCtrl.text.trim(),
};
await widget.repo.create(body);
if (mounted) {
Navigator.of(context).pop();
widget.onSaved();
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),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(_title),
content: SizedBox(
width: context.dialogWidth(360),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _amountCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: '金额', prefixText: '¥ '),
),
const SizedBox(height: 12),
InkWell(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: _date,
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 30)),
);
if (picked != null) setState(() => _date = picked);
},
child: InputDecorator(
decoration: const InputDecoration(labelText: '日期'),
child: Text(
'${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 14),
),
),
),
const SizedBox(height: 12),
TextField(
controller: _remarkCtrl,
decoration: const InputDecoration(labelText: '备注(选填)'),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
ElevatedButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
width: 16, height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('保存'),
),
],
);
}
}
// ── Widgets ──────────────────────────────────────────────────
class _StatusBadge extends StatelessWidget {
final String status;
const _StatusBadge(this.status);
@override
Widget build(BuildContext context) {
final isOpen = status == 'open';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: isOpen ? const Color(0xFFFFF3E0) : const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(3),
),
child: Text(
isOpen ? '未结清' : '已结清',
style: TextStyle(
color: isOpen ? AppTheme.accent : AppTheme.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
);
}
}
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;
final double? width;
const _SummaryCard({
required this.title,
required this.value,
required this.icon,
required this.color,
this.width,
});
@override
Widget build(BuildContext context) {
final card = 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)),
],
),
],
),
);
return width != null
? SizedBox(width: width, child: card)
: Expanded(child: card);
}
}
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)),
),
],
),
);
}
}