gcx/frontend/admin-app/lib/features/finance/presentation/pages/finance_page.dart

598 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../../app/i18n/app_localizations.dart';
import '../../../../core/services/issuer_finance_service.dart';
/// 财务管理页面
///
/// 法币展示,不暴露链上稳定币细节
/// 包含销售收入、Breakage收入、手续费、保证金、冻结款、提现、对账报表
class FinancePage extends StatelessWidget {
const FinancePage({super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: Text(context.t('finance_title')),
actions: [
IconButton(
icon: const Icon(Icons.download_rounded),
onPressed: () => _showExportDialog(context),
),
],
bottom: TabBar(
tabs: [
Tab(text: context.t('finance_tab_overview')),
Tab(text: context.t('finance_tab_transactions')),
Tab(text: context.t('finance_tab_reconciliation')),
],
),
),
body: const TabBarView(
children: [
_OverviewTab(),
_TransactionDetailTab(),
_ReconciliationTab(),
],
),
),
);
}
void _showExportDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => SimpleDialog(
title: Text(context.t('finance_export_title')),
children: [
SimpleDialogOption(onPressed: () => Navigator.pop(ctx), child: Text(context.t('finance_export_csv'))),
SimpleDialogOption(onPressed: () => Navigator.pop(ctx), child: Text(context.t('finance_export_excel'))),
SimpleDialogOption(onPressed: () => Navigator.pop(ctx), child: Text(context.t('finance_export_pdf'))),
],
),
);
}
}
class _OverviewTab extends StatefulWidget {
const _OverviewTab();
@override
State<_OverviewTab> createState() => _OverviewTabState();
}
class _OverviewTabState extends State<_OverviewTab> {
final _financeService = IssuerFinanceService();
bool _isLoading = true;
String? _error;
FinanceBalance? _balance;
FinanceStats? _stats;
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final results = await Future.wait([
_financeService.getBalance(),
_financeService.getStats(),
]);
if (!mounted) return;
setState(() {
_balance = results[0] as FinanceBalance;
_stats = results[1] as FinanceStats;
_isLoading = false;
});
} catch (e) {
debugPrint('[FinancePage] loadData error: $e');
if (!mounted) return;
setState(() {
_isLoading = false;
_error = e.toString();
});
}
}
Future<void> _handleWithdraw() async {
if (_balance == null || _balance!.withdrawable <= 0) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认提现'),
content: Text('提现金额: \$${_balance!.withdrawable.toStringAsFixed(2)}'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(context.t('cancel'))),
ElevatedButton(onPressed: () => Navigator.pop(ctx, true), child: Text(context.t('confirm'))),
],
),
);
if (confirmed != true) return;
try {
await _financeService.withdraw(_balance!.withdrawable);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('提现申请已提交'), backgroundColor: AppColors.success),
);
_loadData();
} catch (e) {
debugPrint('[FinancePage] withdraw error: $e');
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('提现失败: $e'), backgroundColor: AppColors.error),
);
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary),
const SizedBox(height: 16),
Text(_error!, style: const TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 16),
ElevatedButton(onPressed: _loadData, child: Text(context.t('retry'))),
],
),
);
}
return RefreshIndicator(
onRefresh: _loadData,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(20),
child: Column(
children: [
// Balance Card
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.t('finance_withdrawable'), style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: 0.7))),
const SizedBox(height: 4),
Text(
'\$${_balance?.withdrawable.toStringAsFixed(2) ?? '0.00'}',
style: const TextStyle(fontSize: 32, fontWeight: FontWeight.w700, color: Colors.white),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _handleWithdraw,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: AppColors.primary,
),
child: Text(context.t('finance_withdraw')),
),
),
],
),
),
const SizedBox(height: 20),
// Financial Stats
_buildFinanceStatsGrid(context),
const SizedBox(height: 20),
// Guarantee Fund
_buildGuaranteeFundCard(context),
const SizedBox(height: 20),
// Revenue Trend
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.borderLight),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.t('finance_revenue_trend'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
const SizedBox(height: 16),
Container(
height: 160,
decoration: BoxDecoration(
color: AppColors.gray50,
borderRadius: BorderRadius.circular(8),
),
child: Center(child: Text(context.t('finance_revenue_chart'), style: const TextStyle(color: AppColors.textTertiary))),
),
],
),
),
],
),
),
);
}
Widget _buildFinanceStatsGrid(BuildContext context) {
final s = _stats;
final statItems = [
(context.t('finance_sales_income'), '\$${s?.salesAmount.toStringAsFixed(0) ?? '0'}', AppColors.success),
(context.t('finance_breakage_income'), '\$${s?.breakageIncome.toStringAsFixed(0) ?? '0'}', AppColors.info),
(context.t('finance_platform_fee'), '-\$${s?.platformFee.toStringAsFixed(0) ?? '0'}', AppColors.error),
(context.t('finance_pending_settlement'), '\$${s?.pendingSettlement.toStringAsFixed(0) ?? '0'}', AppColors.warning),
(context.t('finance_withdrawn'), '\$${s?.withdrawnAmount.toStringAsFixed(0) ?? '0'}', AppColors.textSecondary),
(context.t('finance_total_income'), '\$${s?.totalRevenue.toStringAsFixed(0) ?? '0'}', AppColors.primary),
];
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 2,
),
itemCount: statItems.length,
itemBuilder: (context, index) {
final (label, value, color) = statItems[index];
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.borderLight),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
Text(value, style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: color)),
],
),
);
},
);
}
Widget _buildGuaranteeFundCard(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.borderLight),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.shield_rounded, color: AppColors.info, size: 20),
const SizedBox(width: 8),
Text(context.t('finance_guarantee_title'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 16),
_buildRow(context.t('finance_guarantee_deposit'), '\$${_balance?.pending.toStringAsFixed(0) ?? '0'}'),
_buildRow(context.t('finance_frozen_sales'), '--'),
_buildRow(context.t('finance_frozen_ratio'), '--'),
const SizedBox(height: 12),
SwitchListTile(
title: Text(context.t('finance_auto_freeze'), style: const TextStyle(fontSize: 14)),
subtitle: Text(context.t('finance_auto_freeze_desc')),
value: true,
onChanged: (_) {
// TODO: Toggle auto-freeze setting
},
activeColor: AppColors.primary,
contentPadding: EdgeInsets.zero,
),
],
),
);
}
Widget _buildRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
Text(value, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
],
),
);
}
}
class _TransactionDetailTab extends StatefulWidget {
const _TransactionDetailTab();
@override
State<_TransactionDetailTab> createState() => _TransactionDetailTabState();
}
class _TransactionDetailTabState extends State<_TransactionDetailTab> {
final _financeService = IssuerFinanceService();
bool _isLoading = true;
String? _error;
List<TransactionModel> _transactions = [];
@override
void initState() {
super.initState();
_loadTransactions();
}
Future<void> _loadTransactions() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final result = await _financeService.getTransactions();
if (!mounted) return;
setState(() {
_transactions = result.items;
_isLoading = false;
});
} catch (e) {
debugPrint('[FinancePage] loadTransactions error: $e');
if (!mounted) return;
setState(() {
_isLoading = false;
_error = e.toString();
});
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary),
const SizedBox(height: 16),
Text(_error!, style: const TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 16),
ElevatedButton(onPressed: _loadTransactions, child: Text(context.t('retry'))),
],
),
);
}
if (_transactions.isEmpty) {
return const Center(
child: Text('暂无交易记录', style: TextStyle(color: AppColors.textSecondary)),
);
}
return RefreshIndicator(
onRefresh: _loadTransactions,
child: ListView.separated(
padding: const EdgeInsets.all(20),
itemCount: _transactions.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final tx = _transactions[index];
final isPositive = tx.amount >= 0;
final color = isPositive ? AppColors.success : AppColors.error;
return ListTile(
contentPadding: const EdgeInsets.symmetric(vertical: 6),
title: Text(tx.description, style: const TextStyle(fontSize: 14)),
subtitle: Text(
_formatTime(tx.createdAt),
style: const TextStyle(fontSize: 12, color: AppColors.textTertiary),
),
trailing: Text(
'${isPositive ? '+' : ''}\$${tx.amount.toStringAsFixed(2)}',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: color),
),
);
},
),
);
}
String _formatTime(DateTime dt) {
final now = DateTime.now();
final isToday = dt.year == now.year && dt.month == now.month && dt.day == now.day;
final timeStr = '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
if (isToday) return '今天 $timeStr';
final yesterday = now.subtract(const Duration(days: 1));
final isYesterday = dt.year == yesterday.year && dt.month == yesterday.month && dt.day == yesterday.day;
if (isYesterday) return '昨天 $timeStr';
return '${dt.month}/${dt.day} $timeStr';
}
}
class _ReconciliationTab extends StatefulWidget {
const _ReconciliationTab();
@override
State<_ReconciliationTab> createState() => _ReconciliationTabState();
}
class _ReconciliationTabState extends State<_ReconciliationTab> {
final _financeService = IssuerFinanceService();
bool _isLoading = true;
String? _error;
Map<String, dynamic> _reconciliation = {};
@override
void initState() {
super.initState();
_loadReconciliation();
}
Future<void> _loadReconciliation() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final data = await _financeService.getReconciliation();
if (!mounted) return;
setState(() {
_reconciliation = data;
_isLoading = false;
});
} catch (e) {
debugPrint('[FinancePage] loadReconciliation error: $e');
if (!mounted) return;
setState(() {
_isLoading = false;
_error = e.toString();
});
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary),
const SizedBox(height: 16),
Text(_error!, style: const TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 16),
ElevatedButton(onPressed: _loadReconciliation, child: Text(context.t('retry'))),
],
),
);
}
final reports = (_reconciliation['reports'] as List?)?.cast<Map<String, dynamic>>() ?? [];
return RefreshIndicator(
onRefresh: _loadReconciliation,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(20),
children: [
// Generate New
OutlinedButton.icon(
onPressed: () {
// TODO: Trigger reconciliation report generation
},
icon: const Icon(Icons.add_rounded),
label: Text(context.t('finance_generate_report')),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
),
),
const SizedBox(height: 20),
if (reports.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Text('暂无对账报表', style: TextStyle(color: AppColors.textSecondary)),
),
)
else
...reports.map((r) {
final title = r['title'] ?? '';
final summary = r['summary'] ?? '';
final status = r['status'] ?? '已生成';
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.borderLight),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.successLight,
borderRadius: BorderRadius.circular(999),
),
child: Text(status, style: const TextStyle(fontSize: 11, color: AppColors.success)),
),
],
),
const SizedBox(height: 6),
Text(summary, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
const SizedBox(height: 12),
Row(
children: [
TextButton.icon(
onPressed: () {
// TODO: Navigate to reconciliation detail view
},
icon: const Icon(Icons.visibility_rounded, size: 16),
label: Text(context.t('view')),
),
TextButton.icon(
onPressed: () {
// TODO: Export reconciliation report
},
icon: const Icon(Icons.download_rounded, size: 16),
label: Text(context.t('export')),
),
],
),
],
),
);
}),
],
),
);
}
}