Files
jiu/client/lib/screens/finance/finance_screen.dart
T
wangjia 7849f6e7e5 chore: 添加 macOS/Web 平台支持 + 修复编译错误 + 开发环境文档
- 修复 CardTheme → CardThemeData(Flutter 3.41 API 变更)
- 修复 finance_screen 缺少 FormDialog import
- 修复工具栏 Row overflow(添加横向滚动)
- flutter create --platforms=macos,web 生成平台配置
- 添加 docs/dev-setup.md:完整开发环境配置指南
  含 Go/Flutter/Docker 安装、已知 macOS 26 兼容问题及解决方案

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 13:36:39 +08:00

883 lines
29 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../widgets/page_scaffold.dart';
import '../../widgets/data_table_card.dart';
import '../../core/theme/app_theme.dart';
import '../../widgets/form_dialog.dart';
class FinanceScreen extends ConsumerStatefulWidget {
const FinanceScreen({super.key});
@override
ConsumerState<FinanceScreen> createState() => _FinanceScreenState();
}
class _FinanceScreenState extends ConsumerState<FinanceScreen> {
int _page = 1;
String _monthFilter = '2026-04';
String _typeFilter = '全部';
final List<Map<String, dynamic>> _transactions = [
{
'no': 'FN20260401001',
'type': '应付账款',
'partner': '贵州茅台酒股份有限公司',
'orderNo': 'RK20260401001',
'amount': '85000.00',
'paid': '85000.00',
'balance': '0.00',
'date': '2026-04-01',
'dueDate': '2026-05-01',
'status': '已结清',
},
{
'no': 'FN20260401002',
'type': '应付账款',
'partner': '四川五粮液股份有限公司',
'orderNo': 'RK20260401002',
'amount': '42500.00',
'paid': '0.00',
'balance': '42500.00',
'date': '2026-04-01',
'dueDate': '2026-05-01',
'status': '未付款',
},
{
'no': 'FN20260402001',
'type': '应付账款',
'partner': '江苏洋河酒厂股份有限公司',
'orderNo': 'RK20260402001',
'amount': '36800.00',
'paid': '20000.00',
'balance': '16800.00',
'date': '2026-04-02',
'dueDate': '2026-05-17',
'status': '部分付款',
},
{
'no': 'FN20260402002',
'type': '应付账款',
'partner': '泸州老窖股份有限公司',
'orderNo': 'RK20260403001',
'amount': '55200.00',
'paid': '55200.00',
'balance': '0.00',
'date': '2026-04-03',
'dueDate': '2026-05-03',
'status': '已结清',
},
{
'no': 'FN20260403001',
'type': '应付账款',
'partner': '法国拉菲集团中国总代理',
'orderNo': 'RK20260403002',
'amount': '124800.00',
'paid': '0.00',
'balance': '124800.00',
'date': '2026-04-03',
'dueDate': '2026-06-02',
'status': '未付款',
},
{
'no': 'FN20260404001',
'type': '应付账款',
'partner': '人头马轩尼诗(中国)有限公司',
'orderNo': 'RK20260404001',
'amount': '30240.00',
'paid': '0.00',
'balance': '30240.00',
'date': '2026-04-04',
'dueDate': '2026-06-03',
'status': '未付款',
},
{
'no': 'FN20260404002',
'type': '费用报销',
'partner': '张三',
'orderNo': 'BX20260404001',
'amount': '1280.00',
'paid': '1280.00',
'balance': '0.00',
'date': '2026-04-04',
'dueDate': '2026-04-04',
'status': '已结清',
},
];
// Summary stats
double get _totalPayable => _transactions
.where((t) => t['type'] == '应付账款')
.fold(0.0, (s, t) => s + (double.tryParse(t['balance'] as String) ?? 0));
double get _totalPaid => _transactions.fold(
0.0, (s, t) => s + (double.tryParse(t['paid'] as String) ?? 0));
double get _totalAmount => _transactions.fold(
0.0, (s, t) => s + (double.tryParse(t['amount'] as String) ?? 0));
@override
Widget build(BuildContext context) {
return PageScaffold(
title: '财务管理',
tabs: const [
Tab(text: '应付账款'),
Tab(text: '付款记录'),
Tab(text: '财务报表'),
],
tabViews: [
_buildPayableList(),
_buildPaymentHistory(),
_buildFinanceReport(),
],
);
}
Widget _buildPayableList() {
final filtered = _typeFilter == '全部'
? _transactions
: _transactions.where((t) => t['type'] == _typeFilter).toList();
return Column(
children: [
// Summary bar
Container(
color: AppTheme.background,
padding: const EdgeInsets.all(12),
child: Row(
children: [
_FinanceSummaryCard(
title: '本月采购总额',
value: '¥${(_totalAmount / 10000).toStringAsFixed(1)}',
icon: Icons.shopping_bag,
color: AppTheme.primary,
),
const SizedBox(width: 12),
_FinanceSummaryCard(
title: '已付款',
value: '¥${(_totalPaid / 10000).toStringAsFixed(1)}',
icon: Icons.check_circle,
color: AppTheme.success,
),
const SizedBox(width: 12),
_FinanceSummaryCard(
title: '未付款',
value: '¥${(_totalPayable / 10000).toStringAsFixed(1)}',
icon: Icons.pending_actions,
color: AppTheme.danger,
),
const SizedBox(width: 12),
_FinanceSummaryCard(
title: '即将到期(7天)',
value: '¥16.8万',
icon: Icons.alarm,
color: AppTheme.accent,
),
],
),
),
const Divider(height: 1),
Expanded(
child: DataTableCard(
totalCount: filtered.length,
page: _page,
onPageChanged: (p) => setState(() => _page = p),
toolbar: Row(
children: [
ElevatedButton.icon(
onPressed: () => _showPaymentDialog(context),
icon: const Icon(Icons.payment, size: 16),
label: const Text('登记付款'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {},
icon: const Icon(Icons.file_download_outlined, size: 16),
label: const Text('导出对账单'),
),
const Spacer(),
// Month filter
_MonthSelector(
value: _monthFilter,
onChanged: (v) => setState(() => _monthFilter = v),
),
const SizedBox(width: 8),
_DropdownFilter(
value: _typeFilter,
items: ['全部', '应付账款', '费用报销'],
onChanged: (v) => setState(() => _typeFilter = v!),
hint: '类型',
),
],
),
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('余额'), numeric: true),
DataColumn(label: Text('到期日')),
DataColumn(label: Text('状态')),
DataColumn(label: Text('操作')),
],
rows: filtered
.map((t) => DataRow(
color: WidgetStateProperty.resolveWith((states) {
if (t['status'] == '未付款') {
final due = DateTime.tryParse(t['dueDate'] as String);
if (due != null &&
due.difference(DateTime.now()).inDays <= 7) {
return AppTheme.accent.withOpacity(0.05);
}
}
return null;
}),
cells: [
DataCell(Text(t['no'] as String,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: AppTheme.textSecondary))),
DataCell(Text(t['type'] as String)),
DataCell(SizedBox(
width: 160,
child: Text(t['partner'] as String,
overflow: TextOverflow.ellipsis),
)),
DataCell(Text(t['orderNo'] as String,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: AppTheme.primary))),
DataCell(Text('¥${t['amount']}',
style: const TextStyle(
fontWeight: FontWeight.w500))),
DataCell(Text('¥${t['paid']}',
style: const TextStyle(
color: AppTheme.success))),
DataCell(Text(
'¥${t['balance']}',
style: TextStyle(
color: (t['balance'] as String) != '0.00'
? AppTheme.danger
: AppTheme.textSecondary,
fontWeight: FontWeight.w600,
),
)),
DataCell(Text(t['dueDate'] as String)),
DataCell(_PaymentStatusBadge(t['status'] as String)),
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: () {},
child: const Text('详情',
style: TextStyle(fontSize: 12))),
if (t['status'] != '已结清')
TextButton(
onPressed: () =>
_showPaymentDialog(context),
child: const Text('付款',
style: TextStyle(
color: AppTheme.success,
fontSize: 12))),
],
)),
],
))
.toList(),
),
),
],
);
}
Widget _buildPaymentHistory() {
final payments = [
{
'date': '2026-04-01',
'no': 'ZF20260401001',
'partner': '贵州茅台酒股份有限公司',
'amount': '85000.00',
'method': '银行转账',
'bank': '招商银行',
'remark': '4月货款结清',
},
{
'date': '2026-04-02',
'no': 'ZF20260402001',
'partner': '泸州老窖股份有限公司',
'amount': '55200.00',
'method': '银行转账',
'bank': '工商银行',
'remark': '4月货款',
},
{
'date': '2026-04-02',
'no': 'ZF20260402002',
'partner': '江苏洋河酒厂股份有限公司',
'amount': '20000.00',
'method': '银行转账',
'bank': '建设银行',
'remark': '部分预付',
},
{
'date': '2026-04-04',
'no': 'ZF20260404001',
'partner': '张三',
'amount': '1280.00',
'method': '现金',
'bank': '-',
'remark': '差旅报销',
},
];
return DataTableCard(
totalCount: payments.length,
page: 1,
toolbar: Row(
children: [
ElevatedButton.icon(
onPressed: () => _showPaymentDialog(context),
icon: const Icon(Icons.add, size: 16),
label: const Text('新增付款'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {},
icon: const Icon(Icons.file_download_outlined, size: 16),
label: const Text('导出'),
),
const Spacer(),
_MonthSelector(
value: _monthFilter,
onChanged: (v) => setState(() => _monthFilter = v),
),
],
),
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('备注')),
DataColumn(label: Text('操作')),
],
rows: payments
.map((p) => DataRow(cells: [
DataCell(Text(p['date'] as String)),
DataCell(Text(p['no'] as String,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: AppTheme.textSecondary))),
DataCell(SizedBox(
width: 160,
child: Text(p['partner'] as String,
overflow: TextOverflow.ellipsis),
)),
DataCell(Text(
'¥${p['amount']}',
style: const TextStyle(
color: AppTheme.danger, fontWeight: FontWeight.w600),
)),
DataCell(Text(p['method'] as String)),
DataCell(Text(p['bank'] as String)),
DataCell(Text(p['remark'] as String)),
DataCell(TextButton(
onPressed: () {},
child: const Text('查看',
style: TextStyle(fontSize: 12)))),
]))
.toList(),
);
}
Widget _buildFinanceReport() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Month/period selector
Row(
children: [
const Text('报表周期:',
style: TextStyle(fontSize: 14)),
const SizedBox(width: 8),
_MonthSelector(
value: _monthFilter,
onChanged: (v) => setState(() => _monthFilter = v),
),
const SizedBox(width: 16),
ElevatedButton.icon(
onPressed: () {},
icon: const Icon(Icons.picture_as_pdf, size: 16),
label: const Text('导出报表'),
),
],
),
const SizedBox(height: 16),
// Metrics row
Row(
children: [
_ReportCard(
title: '本月采购总额',
value: '¥${(_totalAmount / 10000).toStringAsFixed(2)}',
change: '+12.5%',
positive: false,
),
const SizedBox(width: 12),
_ReportCard(
title: '本月付款总额',
value: '¥${(_totalPaid / 10000).toStringAsFixed(2)}',
change: '+8.3%',
positive: true,
),
const SizedBox(width: 12),
_ReportCard(
title: '应付账款余额',
value: '¥${(_totalPayable / 10000).toStringAsFixed(2)}',
change: '-5.2%',
positive: true,
),
const SizedBox(width: 12),
_ReportCard(
title: '库存总价值',
value: '¥103.17万',
change: '+3.8%',
positive: false,
),
],
),
const SizedBox(height: 16),
// Category breakdown
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('按品类采购金额',
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(height: 16),
...[
('白酒', 238600.0, AppTheme.primary),
('葡萄酒', 124800.0, const Color(0xFF7B1FA2)),
('洋酒', 30240.0, AppTheme.accent),
('啤酒', 1970.0, const Color(0xFF0097A7)),
].map((item) {
final total = 395610.0;
final pct = item.$2 / total;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
SizedBox(
width: 60,
child: Text(item.$1,
style: const TextStyle(fontSize: 13))),
const SizedBox(width: 8),
Expanded(
child: LinearProgressIndicator(
value: pct,
backgroundColor: AppTheme.border,
color: item.$3,
minHeight: 8,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 8),
SizedBox(
width: 100,
child: Text(
'¥${(item.$2 / 10000).toStringAsFixed(1)}万 (${(pct * 100).toStringAsFixed(1)}%)',
style: const TextStyle(fontSize: 12),
textAlign: TextAlign.right,
),
),
],
),
],
),
);
}),
],
),
),
),
],
),
);
}
void _showPaymentDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => FormDialog(
title: '登记付款',
width: 480,
onConfirm: () {
Navigator.of(ctx).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('付款记录已登记'),
backgroundColor: AppTheme.success,
),
);
},
content: Column(
children: [
_DialogRow(
children: [
_DialogField(
label: '付款方式',
child: DropdownButtonFormField<String>(
value: '银行转账',
items: ['银行转账', '现金', '支票', '网银']
.map((s) => DropdownMenuItem(
value: s,
child: Text(s,
style: const TextStyle(fontSize: 13))))
.toList(),
onChanged: (_) {},
decoration: const InputDecoration(),
)),
_DialogField(
label: '付款金额',
required: true,
child: TextFormField(
decoration: const InputDecoration(
hintText: '0.00', prefixText: '¥'),
keyboardType:
const TextInputType.numberWithOptions(decimal: true))),
],
),
const SizedBox(height: 12),
_DialogRow(
children: [
_DialogField(
label: '付款银行',
child: DropdownButtonFormField<String>(
value: '招商银行',
items: ['招商银行', '工商银行', '建设银行', '农业银行', '中国银行']
.map((s) => DropdownMenuItem(
value: s,
child: Text(s,
style: const TextStyle(fontSize: 13))))
.toList(),
onChanged: (_) {},
decoration: const InputDecoration(),
)),
_DialogField(
label: '付款日期',
child: InputDecorator(
decoration: const InputDecoration(),
child: const Text('2026-04-04',
style: TextStyle(fontSize: 13)),
)),
],
),
const SizedBox(height: 12),
_DialogField(
label: '备注',
fullWidth: true,
child: TextFormField(
maxLines: 2,
decoration: const InputDecoration(hintText: '付款说明'),
),
),
],
),
),
);
}
}
class _DialogRow extends StatelessWidget {
final List<Widget> children;
const _DialogRow({required this.children});
@override
Widget build(BuildContext context) {
return Row(
children: children
.expand((child) => [
Expanded(child: child),
if (child != children.last) const SizedBox(width: 12),
])
.toList(),
);
}
}
class _DialogField extends StatelessWidget {
final String label;
final Widget child;
final bool required;
final bool fullWidth;
const _DialogField({
required this.label,
required this.child,
this.required = false,
this.fullWidth = false,
});
@override
Widget build(BuildContext context) {
Widget content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
if (required)
const Text('* ',
style: TextStyle(color: AppTheme.danger, fontSize: 13)),
Text(label,
style: const TextStyle(
fontSize: 13, color: AppTheme.textSecondary)),
]),
const SizedBox(height: 6),
child,
],
);
return fullWidth ? SizedBox(width: double.infinity, child: content) : content;
}
}
class _FinanceSummaryCard extends StatelessWidget {
final String title;
final String value;
final IconData icon;
final Color color;
const _FinanceSummaryCard({
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 _PaymentStatusBadge extends StatelessWidget {
final String status;
const _PaymentStatusBadge(this.status);
@override
Widget build(BuildContext context) {
Color bg, fg;
switch (status) {
case '已结清':
bg = const Color(0xFFE8F5E9);
fg = AppTheme.success;
break;
case '部分付款':
bg = const Color(0xFFFFF3E0);
fg = AppTheme.accent;
break;
case '未付款':
bg = const Color(0xFFFFEBEE);
fg = AppTheme.danger;
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(status,
style: TextStyle(
color: fg, fontSize: 12, fontWeight: FontWeight.w500)),
);
}
}
class _ReportCard extends StatelessWidget {
final String title;
final String value;
final String change;
final bool positive;
const _ReportCard({
required this.title,
required this.value,
required this.change,
required this.positive,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: Container(
height: 88,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: AppTheme.border, width: 0.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(
fontSize: 12, color: AppTheme.textSecondary)),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(value,
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w700)),
const Spacer(),
Text(
change,
style: TextStyle(
fontSize: 12,
color: positive ? AppTheme.success : AppTheme.danger,
fontWeight: FontWeight.w500,
),
),
],
),
],
),
),
);
}
}
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 months = [
'2026-04',
'2026-03',
'2026-02',
'2026-01',
'2025-12',
'2025-11',
];
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: value,
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 _DropdownFilter extends StatelessWidget {
final String value;
final List<String> items;
final ValueChanged<String?> onChanged;
final String hint;
const _DropdownFilter({
required this.value,
required this.items,
required this.onChanged,
required this.hint,
});
@override
Widget build(BuildContext context) {
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: value,
items: items
.map((s) => DropdownMenuItem(
value: s,
child: Text(s, style: const TextStyle(fontSize: 13))))
.toList(),
onChanged: onChanged,
style: const TextStyle(
fontSize: 13, color: AppTheme.textPrimary),
),
),
);
}
}