feat(frontend): 添加积分股划转功能

- 交易页面添加划转入口链接
- 实现双向划转弹窗(划入交易账户/划出到挖矿账户)
- 新增划转历史记录页面
- 添加划转相关 API 调用

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-18 06:57:23 -08:00
parent 1760f9b82c
commit 4e181354f4
7 changed files with 834 additions and 22 deletions

View File

@ -21,6 +21,7 @@ import '../../presentation/pages/c2c/c2c_publish_page.dart';
import '../../presentation/pages/c2c/c2c_order_detail_page.dart';
import '../../presentation/pages/profile/team_page.dart';
import '../../presentation/pages/profile/trading_records_page.dart';
import '../../presentation/pages/trading/transfer_records_page.dart';
import '../../presentation/pages/profile/help_center_page.dart';
import '../../presentation/pages/profile/about_page.dart';
import '../../presentation/widgets/main_shell.dart';
@ -157,6 +158,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: Routes.tradingRecords,
builder: (context, state) => const TradingRecordsPage(),
),
GoRoute(
path: Routes.transferRecords,
builder: (context, state) => const TransferRecordsPage(),
),
GoRoute(
path: Routes.helpCenter,
builder: (context, state) => const HelpCenterPage(),

View File

@ -23,6 +23,8 @@ class Routes {
static const String myTeam = '/my-team';
//
static const String tradingRecords = '/trading-records';
//
static const String transferRecords = '/transfer-records';
//
static const String helpCenter = '/help-center';
static const String about = '/about';

View File

@ -48,6 +48,9 @@ abstract class TradingRemoteDataSource {
/// ()
Future<Map<String, dynamic>> transferOut(String amount);
///
Future<List<dynamic>> getTransferHistory({int page = 1, int pageSize = 50});
///
Future<AssetDisplayModel> getMyAsset({String? dailyAllocation});
@ -243,6 +246,19 @@ class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
}
}
@override
Future<List<dynamic>> getTransferHistory({int page = 1, int pageSize = 50}) async {
try {
final response = await client.get(
ApiEndpoints.transferHistory,
queryParameters: {'page': page, 'pageSize': pageSize},
);
return response.data['data'] ?? [];
} catch (e) {
throw ServerException(e.toString());
}
}
@override
Future<AssetDisplayModel> getMyAsset({String? dailyAllocation}) async {
try {

View File

@ -151,6 +151,18 @@ class TradingRepositoryImpl implements TradingRepository {
}
}
@override
Future<Either<Failure, List<dynamic>>> getTransferHistory({int page = 1, int pageSize = 50}) async {
try {
final result = await remoteDataSource.getTransferHistory(page: page, pageSize: pageSize);
return Right(result);
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on NetworkException {
return Left(const NetworkFailure());
}
}
@override
Future<Either<Failure, AssetDisplay>> getMyAsset({String? dailyAllocation}) async {
try {

View File

@ -45,6 +45,9 @@ abstract class TradingRepository {
/// ()
Future<Either<Failure, Map<String, dynamic>>> transferOut(String amount);
///
Future<Either<Failure, List<dynamic>>> getTransferHistory({int page = 1, int pageSize = 50});
///
Future<Either<Failure, AssetDisplay>> getMyAsset({String? dailyAllocation});

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../core/constants/app_colors.dart';
import '../../../core/router/routes.dart';
import '../../../core/utils/format_utils.dart';
import '../../../data/models/trade_order_model.dart';
import '../../../domain/entities/price_info.dart';
@ -352,7 +354,11 @@ class _TradingPageState extends ConsumerState<TradingPage> {
final buyEnabledAsync = ref.watch(buyEnabledProvider);
final buyEnabled = buyEnabledAsync.valueOrNull ?? false;
//
//
final miningShareBalance = asset?.miningShareBalance ?? '0';
//
final tradingShareBalance = asset?.tradingShareBalance ?? '0';
// +
final availableShares = asset?.availableShares ?? '0';
//
final availableCash = asset?.availableCash ?? '0';
@ -497,32 +503,75 @@ class _TradingPageState extends ConsumerState<TradingPage> {
),
] else ...[
//
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _orange.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
if (_selectedTab == 0) ...[
//
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _orange.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'可用积分值',
style: TextStyle(fontSize: 12, color: _grayText),
),
Text(
formatAmount(availableCash),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _orange,
),
),
],
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_selectedTab == 0 ? '可用积分值' : '可用积分股',
style: const TextStyle(fontSize: 12, color: _grayText),
),
Text(
_selectedTab == 0
? formatAmount(availableCash)
: formatAmount(availableShares),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
] else ...[
//
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _orange.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'可用积分股',
style: TextStyle(fontSize: 12, color: _grayText),
),
Text(
formatAmount(tradingShareBalance),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _orange,
),
),
],
),
),
const SizedBox(height: 4),
//
Align(
alignment: Alignment.centerRight,
child: GestureDetector(
onTap: () => _showTransferDialog(miningShareBalance, tradingShareBalance),
child: const Text(
'划转',
style: TextStyle(
fontSize: 12,
color: _orange,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
],
const SizedBox(height: 16),
//
_buildInputField('价格', _priceController, '请输入价格', '积分值'),
@ -1094,4 +1143,411 @@ class _TradingPageState extends ConsumerState<TradingPage> {
ref.invalidate(marketOverviewProvider);
ref.invalidate(accountAssetProvider(accountSeq));
}
///
void _showTransferDialog(String miningBalance, String tradingBalance) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => _TransferBottomSheet(
miningBalance: miningBalance,
tradingBalance: tradingBalance,
onTransferComplete: () {
final user = ref.read(userNotifierProvider);
final accountSeq = user.accountSequence ?? '';
_doRefresh(accountSeq);
},
),
);
}
}
///
class _TransferBottomSheet extends ConsumerStatefulWidget {
final String miningBalance;
final String tradingBalance;
final VoidCallback onTransferComplete;
const _TransferBottomSheet({
required this.miningBalance,
required this.tradingBalance,
required this.onTransferComplete,
});
@override
ConsumerState<_TransferBottomSheet> createState() => _TransferBottomSheetState();
}
class _TransferBottomSheetState extends ConsumerState<_TransferBottomSheet> {
static const Color _orange = Color(0xFFFF6B00);
static const Color _grayText = Color(0xFF6B7280);
static const Color _darkText = Color(0xFF1F2937);
static const Color _bgGray = Color(0xFFF3F4F6);
static const Color _green = Color(0xFF10B981);
// 0: , 1:
int _direction = 0;
final _amountController = TextEditingController();
bool _isLoading = false;
@override
void dispose() {
_amountController.dispose();
super.dispose();
}
String get _availableBalance {
return _direction == 0 ? widget.miningBalance : widget.tradingBalance;
}
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'积分股划转',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
GestureDetector(
onTap: () => Navigator.pop(context),
child: const Icon(Icons.close, color: _grayText),
),
],
),
const SizedBox(height: 20),
//
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: _bgGray,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => setState(() {
_direction = 0;
_amountController.clear();
}),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: _direction == 0 ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(6),
boxShadow: _direction == 0
? [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 4)]
: null,
),
child: Text(
'划入交易账户',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
fontWeight: _direction == 0 ? FontWeight.bold : FontWeight.normal,
color: _direction == 0 ? _orange : _grayText,
),
),
),
),
),
Expanded(
child: GestureDetector(
onTap: () => setState(() {
_direction = 1;
_amountController.clear();
}),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: _direction == 1 ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(6),
boxShadow: _direction == 1
? [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 4)]
: null,
),
child: Text(
'划出到挖矿账户',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
fontWeight: _direction == 1 ? FontWeight.bold : FontWeight.normal,
color: _direction == 1 ? _orange : _grayText,
),
),
),
),
),
],
),
),
const SizedBox(height: 20),
//
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _bgGray,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_direction == 0 ? '挖矿账户' : '交易账户',
style: const TextStyle(fontSize: 12, color: _grayText),
),
const SizedBox(height: 4),
Text(
formatAmount(_direction == 0 ? widget.miningBalance : widget.tradingBalance),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
],
),
),
const Icon(Icons.arrow_forward, color: _orange),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_direction == 0 ? '交易账户' : '挖矿账户',
style: const TextStyle(fontSize: 12, color: _grayText),
),
const SizedBox(height: 4),
Text(
formatAmount(_direction == 0 ? widget.tradingBalance : widget.miningBalance),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
],
),
),
],
),
),
const SizedBox(height: 20),
//
const Text(
'划转数量',
style: TextStyle(fontSize: 14, color: _darkText),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
border: Border.all(color: _bgGray),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _amountController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
hintText: '请输入划转数量',
hintStyle: TextStyle(color: _grayText, fontSize: 14),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
),
),
GestureDetector(
onTap: () {
_amountController.text = _availableBalance;
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: _orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'全部',
style: TextStyle(
fontSize: 12,
color: _orange,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
const SizedBox(height: 8),
Text(
'可用: ${formatAmount(_availableBalance)} 积分股',
style: const TextStyle(fontSize: 12, color: _grayText),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'提示: 最低划转数量为 5 积分股',
style: TextStyle(fontSize: 12, color: _grayText),
),
GestureDetector(
onTap: () {
Navigator.pop(context);
context.push(Routes.transferRecords);
},
child: const Text(
'划转记录',
style: TextStyle(
fontSize: 12,
color: _orange,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: 24),
//
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: _isLoading ? null : _handleTransfer,
style: ElevatedButton.styleFrom(
backgroundColor: _orange,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Text(
_direction == 0 ? '划入交易账户' : '划出到挖矿账户',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
],
),
),
),
);
}
Future<void> _handleTransfer() async {
final amount = _amountController.text.trim();
if (amount.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请输入划转数量')),
);
return;
}
final amountValue = double.tryParse(amount) ?? 0;
if (amountValue < 5) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('最低划转数量为 5 积分股')),
);
return;
}
final available = double.tryParse(_availableBalance) ?? 0;
if (amountValue > available) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('划转数量超过可用余额')),
);
return;
}
setState(() => _isLoading = true);
try {
bool success;
if (_direction == 0) {
//
success = await ref.read(tradingNotifierProvider.notifier).transferIn(amount);
} else {
//
success = await ref.read(tradingNotifierProvider.notifier).transferOut(amount);
}
if (mounted) {
if (success) {
Navigator.pop(context);
widget.onTransferComplete();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_direction == 0 ? '划入成功' : '划出成功'),
backgroundColor: _green,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('划转失败,请稍后重试'),
backgroundColor: Colors.red,
),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('划转失败: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
}

View File

@ -0,0 +1,318 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../../core/constants/app_colors.dart';
import '../../../core/utils/format_utils.dart';
import '../../../domain/repositories/trading_repository.dart';
import '../../providers/trading_providers.dart';
///
class TransferRecord {
final String transferNo;
final String direction; // IN: , OUT:
final String amount;
final String status; // PENDING, COMPLETED, FAILED
final DateTime createdAt;
final DateTime? completedAt;
final String? errorMessage;
TransferRecord({
required this.transferNo,
required this.direction,
required this.amount,
required this.status,
required this.createdAt,
this.completedAt,
this.errorMessage,
});
factory TransferRecord.fromJson(Map<String, dynamic> json) {
return TransferRecord(
transferNo: json['transferNo'] ?? '',
direction: json['direction'] ?? '',
amount: json['amount']?.toString() ?? '0',
status: json['status'] ?? '',
createdAt: DateTime.tryParse(json['createdAt'] ?? '') ?? DateTime.now(),
completedAt: json['completedAt'] != null
? DateTime.tryParse(json['completedAt'])
: null,
errorMessage: json['errorMessage'],
);
}
}
/// Provider
final transferRecordsProvider = FutureProvider<List<TransferRecord>>((ref) async {
final repository = ref.watch(tradingRepositoryProvider);
final result = await repository.getTransferHistory();
return result.fold(
(failure) => throw Exception(failure.message),
(records) => records.map((json) => TransferRecord.fromJson(json as Map<String, dynamic>)).toList(),
);
});
class TransferRecordsPage extends ConsumerWidget {
const TransferRecordsPage({super.key});
static const Color _orange = Color(0xFFFF6B00);
static const Color _green = Color(0xFF10B981);
static const Color _red = Color(0xFFEF4444);
static const Color _grayText = Color(0xFF6B7280);
static const Color _darkText = Color(0xFF1F2937);
static const Color _bgGray = Color(0xFFF3F4F6);
@override
Widget build(BuildContext context, WidgetRef ref) {
final recordsAsync = ref.watch(transferRecordsProvider);
return Scaffold(
backgroundColor: _bgGray,
appBar: AppBar(
title: const Text(
'划转记录',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
centerTitle: true,
backgroundColor: Colors.white,
elevation: 0,
iconTheme: const IconThemeData(color: _darkText),
),
body: RefreshIndicator(
onRefresh: () async {
ref.invalidate(transferRecordsProvider);
},
child: recordsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: _grayText),
const SizedBox(height: 16),
Text(
'加载失败',
style: TextStyle(color: _grayText),
),
const SizedBox(height: 8),
TextButton(
onPressed: () => ref.invalidate(transferRecordsProvider),
child: const Text('点击重试'),
),
],
),
),
data: (records) {
if (records.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.swap_horiz,
size: 64,
color: _grayText.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
const Text(
'暂无划转记录',
style: TextStyle(
fontSize: 16,
color: _grayText,
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: records.length,
itemBuilder: (context, index) {
return _buildRecordCard(records[index]);
},
);
},
),
),
);
}
Widget _buildRecordCard(TransferRecord record) {
final isIn = record.direction == 'IN';
final statusColor = _getStatusColor(record.status);
final statusText = _getStatusText(record.status);
final dateFormat = DateFormat('yyyy-MM-dd HH:mm:ss');
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: (isIn ? _green : _orange).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
isIn ? Icons.arrow_downward : Icons.arrow_upward,
size: 18,
color: isIn ? _green : _orange,
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isIn ? '划入交易账户' : '划出到挖矿账户',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: _darkText,
),
),
const SizedBox(height: 2),
Text(
dateFormat.format(record.createdAt),
style: const TextStyle(
fontSize: 12,
color: _grayText,
),
),
],
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
statusText,
style: TextStyle(
fontSize: 12,
color: statusColor,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
//
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'划转数量',
style: TextStyle(fontSize: 13, color: _grayText),
),
Text(
'${isIn ? '+' : '-'}${formatAmount(record.amount)} 积分股',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: isIn ? _green : _orange,
),
),
],
),
const SizedBox(height: 8),
//
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'划转单号',
style: TextStyle(fontSize: 12, color: _grayText),
),
Text(
record.transferNo,
style: const TextStyle(
fontSize: 12,
color: _grayText,
fontFamily: 'monospace',
),
),
],
),
//
if (record.status == 'FAILED' && record.errorMessage != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _red.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
const Icon(Icons.info_outline, size: 14, color: _red),
const SizedBox(width: 4),
Expanded(
child: Text(
record.errorMessage!,
style: const TextStyle(
fontSize: 12,
color: _red,
),
),
),
],
),
),
],
],
),
);
}
Color _getStatusColor(String status) {
switch (status) {
case 'COMPLETED':
return _green;
case 'PENDING':
return _orange;
case 'FAILED':
return _red;
default:
return _grayText;
}
}
String _getStatusText(String status) {
switch (status) {
case 'COMPLETED':
return '已完成';
case 'PENDING':
return '处理中';
case 'FAILED':
return '失败';
default:
return status;
}
}
}