feat(trading): 交易记录页面添加成交明细Tab,显示手续费

后端:
- trading-service 添加 GET /trading/trades API 获取成交记录
- 成交记录包含: 交易总额、手续费(10%)、实际收到金额

前端:
- 新增 TradeRecord 实体和 TradesPageModel
- 交易记录页面添加 Tab: "订单记录" 和 "成交明细"
- 成交明细显示: 价格、数量、交易总额、手续费、实际收到

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-20 03:15:25 -08:00
parent 63e02666ea
commit 7da98c248b
10 changed files with 465 additions and 91 deletions

View File

@ -122,4 +122,24 @@ export class TradingController {
total: result.total,
};
}
@Get('trades')
@ApiOperation({ summary: '获取用户成交记录(含手续费明细)' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
async getTrades(
@Req() req: any,
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
) {
const accountSequence = req.user?.accountSequence;
if (!accountSequence) {
throw new Error('Unauthorized');
}
return this.orderRepository.findTradesByAccountSequence(accountSequence, {
page: page ?? 1,
pageSize: pageSize ?? 50,
});
}
}

View File

@ -213,6 +213,84 @@ export class OrderRepository {
};
}
/**
*
*/
async findTradesByAccountSequence(
accountSequence: string,
options?: { page?: number; pageSize?: number },
): Promise<{
data: Array<{
id: string;
tradeNo: string;
type: 'BUY' | 'SELL';
price: string;
quantity: string;
amount: string;
fee: string;
netAmount: string;
counterparty: string;
createdAt: Date;
}>;
total: number;
}> {
const page = options?.page ?? 1;
const pageSize = options?.pageSize ?? 50;
// 查询作为买方和卖方的成交记录
const [buyerTrades, sellerTrades, buyerCount, sellerCount] = await Promise.all([
this.prisma.trade.findMany({
where: { buyerSequence: accountSequence },
orderBy: { createdAt: 'desc' },
}),
this.prisma.trade.findMany({
where: { sellerSequence: accountSequence },
orderBy: { createdAt: 'desc' },
}),
this.prisma.trade.count({ where: { buyerSequence: accountSequence } }),
this.prisma.trade.count({ where: { sellerSequence: accountSequence } }),
]);
// 合并并转换格式
const allTrades = [
...buyerTrades.map((t) => ({
id: t.id,
tradeNo: t.tradeNo,
type: 'BUY' as const,
price: t.price.toString(),
quantity: t.quantity.toString(),
amount: t.amount.toString(),
fee: '0', // 买方不支付手续费
netAmount: t.amount.toString(),
counterparty: t.sellerSequence,
createdAt: t.createdAt,
})),
...sellerTrades.map((t) => ({
id: t.id,
tradeNo: t.tradeNo,
type: 'SELL' as const,
price: t.price.toString(),
quantity: t.quantity.toString(),
amount: t.amount.toString(), // 这是扣除手续费后的金额
fee: t.fee.toString(),
netAmount: t.amount.toString(), // amount 已经是 net扣除手续费后
counterparty: t.buyerSequence,
createdAt: t.createdAt,
})),
];
// 按时间排序
allTrades.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
// 分页
const paged = allTrades.slice((page - 1) * pageSize, page * pageSize);
return {
data: paged,
total: buyerCount + sellerCount,
};
}
private toDomain(record: any): OrderAggregate {
return OrderAggregate.reconstitute({
id: record.id,

View File

@ -38,6 +38,7 @@ class ApiEndpoints {
static const String orderBook = '/api/v2/trading/trading/orderbook';
static const String createOrder = '/api/v2/trading/trading/orders';
static const String orders = '/api/v2/trading/trading/orders';
static const String trades = '/api/v2/trading/trading/trades';
static String cancelOrder(String orderNo) =>
'/api/v2/trading/trading/orders/$orderNo/cancel';

View File

@ -39,6 +39,12 @@ abstract class TradingRemoteDataSource {
int pageSize = 50,
});
///
Future<TradesPageModel> getTrades({
int page = 1,
int pageSize = 50,
});
///
Future<Map<String, dynamic>> estimateSell(String quantity);
@ -222,6 +228,22 @@ class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
}
}
@override
Future<TradesPageModel> getTrades({
int page = 1,
int pageSize = 50,
}) async {
try {
final response = await client.get(
ApiEndpoints.trades,
queryParameters: {'page': page, 'pageSize': pageSize},
);
return TradesPageModel.fromJson(response.data);
} catch (e) {
throw ServerException(e.toString());
}
}
@override
Future<Map<String, dynamic>> estimateSell(String quantity) async {
try {

View File

@ -1,4 +1,5 @@
import '../../domain/entities/trade_order.dart';
import '../../domain/entities/trade_record.dart';
class TradeOrderModel extends TradeOrder {
const TradeOrderModel({
@ -86,3 +87,55 @@ class OrdersPageModel {
);
}
}
/// Model
class TradeRecordModel extends TradeRecord {
const TradeRecordModel({
required super.id,
required super.tradeNo,
required super.type,
required super.price,
required super.quantity,
required super.amount,
required super.fee,
required super.netAmount,
required super.counterparty,
required super.createdAt,
});
factory TradeRecordModel.fromJson(Map<String, dynamic> json) {
return TradeRecordModel(
id: json['id']?.toString() ?? '',
tradeNo: json['tradeNo']?.toString() ?? '',
type: json['type'] == 'BUY' ? TradeType.buy : TradeType.sell,
price: json['price']?.toString() ?? '0',
quantity: json['quantity']?.toString() ?? '0',
amount: json['amount']?.toString() ?? '0',
fee: json['fee']?.toString() ?? '0',
netAmount: json['netAmount']?.toString() ?? '0',
counterparty: json['counterparty']?.toString() ?? '',
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'].toString())
: DateTime.now(),
);
}
}
///
class TradesPageModel {
final List<TradeRecordModel> data;
final int total;
const TradesPageModel({
required this.data,
required this.total,
});
factory TradesPageModel.fromJson(Map<String, dynamic> json) {
final dataList = (json['data'] as List<dynamic>?) ?? [];
return TradesPageModel(
data: dataList.map((e) => TradeRecordModel.fromJson(e)).toList(),
total: json['total'] ?? 0,
);
}
}

View File

@ -115,6 +115,24 @@ class TradingRepositoryImpl implements TradingRepository {
}
}
@override
Future<Either<Failure, TradesPageModel>> getTrades({
int page = 1,
int pageSize = 50,
}) async {
try {
final result = await remoteDataSource.getTrades(
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, Map<String, dynamic>>> estimateSell(String quantity) async {
try {

View File

@ -0,0 +1,52 @@
import 'package:equatable/equatable.dart';
enum TradeType { buy, sell }
///
class TradeRecord extends Equatable {
/// ID
final String id;
///
final String tradeNo;
///
final TradeType type;
///
final String price;
///
final String quantity;
///
final String amount;
/// 0
final String fee;
/// /
final String netAmount;
///
final String counterparty;
///
final DateTime createdAt;
const TradeRecord({
required this.id,
required this.tradeNo,
required this.type,
required this.price,
required this.quantity,
required this.amount,
required this.fee,
required this.netAmount,
required this.counterparty,
required this.createdAt,
});
bool get isBuy => type == TradeType.buy;
bool get isSell => type == TradeType.sell;
///
bool get hasFee {
final feeValue = double.tryParse(fee) ?? 0;
return feeValue > 0;
}
@override
List<Object?> get props => [id, tradeNo, type, price, quantity, fee];
}

View File

@ -36,6 +36,12 @@ abstract class TradingRepository {
int pageSize = 50,
});
///
Future<Either<Failure, TradesPageModel>> getTrades({
int page = 1,
int pageSize = 50,
});
///
Future<Either<Failure, Map<String, dynamic>>> estimateSell(String quantity);

View File

@ -2,7 +2,9 @@ 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/entities/trade_order.dart';
import '../../../domain/entities/trade_record.dart';
import '../../../data/models/trade_order_model.dart';
import '../../providers/trading_providers.dart';
@ -14,7 +16,7 @@ class TradingRecordsPage extends ConsumerStatefulWidget {
ConsumerState<TradingRecordsPage> createState() => _TradingRecordsPageState();
}
class _TradingRecordsPageState extends ConsumerState<TradingRecordsPage> {
class _TradingRecordsPageState extends ConsumerState<TradingRecordsPage> with SingleTickerProviderStateMixin {
static const Color _orange = Color(0xFFFF6B00);
static const Color _green = Color(0xFF22C55E);
static const Color _red = Color(0xFFEF4444);
@ -22,10 +24,22 @@ class _TradingRecordsPageState extends ConsumerState<TradingRecordsPage> {
static const Color _darkText = Color(0xFF1F2937);
static const Color _lightGray = Color(0xFF9CA3AF);
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final ordersAsync = ref.watch(ordersProvider);
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: AppBar(
@ -44,22 +58,66 @@ class _TradingRecordsPageState extends ConsumerState<TradingRecordsPage> {
),
),
centerTitle: true,
),
body: RefreshIndicator(
onRefresh: () async {
ref.invalidate(ordersProvider);
},
child: ordersAsync.when(
loading: () => _buildLoadingList(),
error: (error, stack) => _buildErrorView(error.toString()),
data: (ordersPage) {
if (ordersPage == null || ordersPage.data.isEmpty) {
return _buildEmptyView();
}
return _buildOrdersList(ordersPage);
},
bottom: TabBar(
controller: _tabController,
labelColor: _orange,
unselectedLabelColor: _grayText,
indicatorColor: _orange,
tabs: const [
Tab(text: '订单记录'),
Tab(text: '成交明细'),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
_buildOrdersTab(),
_buildTradesTab(),
],
),
);
}
// ==================== Tab ====================
Widget _buildOrdersTab() {
final ordersAsync = ref.watch(ordersProvider);
return RefreshIndicator(
onRefresh: () async {
ref.invalidate(ordersProvider);
},
child: ordersAsync.when(
loading: () => _buildLoadingList(),
error: (error, stack) => _buildErrorView(error.toString(), () => ref.invalidate(ordersProvider)),
data: (ordersPage) {
if (ordersPage == null || ordersPage.data.isEmpty) {
return _buildEmptyView('暂无订单记录', '前往交易页面进行买卖操作');
}
return _buildOrdersList(ordersPage);
},
),
);
}
// ==================== Tab ====================
Widget _buildTradesTab() {
final tradesAsync = ref.watch(tradesProvider);
return RefreshIndicator(
onRefresh: () async {
ref.invalidate(tradesProvider);
},
child: tradesAsync.when(
loading: () => _buildLoadingList(),
error: (error, stack) => _buildErrorView(error.toString(), () => ref.invalidate(tradesProvider)),
data: (tradesPage) {
if (tradesPage == null || tradesPage.data.isEmpty) {
return _buildEmptyView('暂无成交记录', '成交后将在此显示明细');
}
return _buildTradesList(tradesPage);
},
),
);
}
@ -125,34 +183,12 @@ class _TradingRecordsPageState extends ConsumerState<TradingRecordsPage> {
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 120,
height: 12,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(4),
),
),
Container(
width: 100,
height: 12,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(4),
),
),
],
),
],
),
);
}
Widget _buildErrorView(String error) {
Widget _buildErrorView(String error, VoidCallback onRetry) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@ -171,9 +207,7 @@ class _TradingRecordsPageState extends ConsumerState<TradingRecordsPage> {
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
ref.invalidate(ordersProvider);
},
onPressed: onRetry,
style: ElevatedButton.styleFrom(backgroundColor: _orange),
child: const Text('重试', style: TextStyle(color: Colors.white)),
),
@ -182,22 +216,16 @@ class _TradingRecordsPageState extends ConsumerState<TradingRecordsPage> {
);
}
Widget _buildEmptyView() {
Widget _buildEmptyView(String title, String subtitle) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.receipt_long_outlined, size: 64, color: _grayText.withValues(alpha: 0.5)),
const SizedBox(height: 16),
Text(
'暂无交易记录',
style: TextStyle(fontSize: 16, color: _grayText),
),
Text(title, style: TextStyle(fontSize: 16, color: _grayText)),
const SizedBox(height: 8),
Text(
'前往交易页面进行买卖操作',
style: TextStyle(fontSize: 14, color: _grayText.withValues(alpha: 0.7)),
),
Text(subtitle, style: TextStyle(fontSize: 14, color: _grayText.withValues(alpha: 0.7))),
],
),
);
@ -209,13 +237,26 @@ class _TradingRecordsPageState extends ConsumerState<TradingRecordsPage> {
itemCount: ordersPage.data.length + 1,
itemBuilder: (context, index) {
if (index == ordersPage.data.length) {
return _buildBottomInfo(ordersPage.total);
return _buildBottomInfo('${ordersPage.total} 条订单记录');
}
return _buildOrderCard(ordersPage.data[index]);
},
);
}
Widget _buildTradesList(TradesPageModel tradesPage) {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: tradesPage.data.length + 1,
itemBuilder: (context, index) {
if (index == tradesPage.data.length) {
return _buildBottomInfo('${tradesPage.total} 条成交记录');
}
return _buildTradeCard(tradesPage.data[index]);
},
);
}
Widget _buildOrderCard(TradeOrder order) {
final isBuy = order.isBuy;
final typeColor = isBuy ? _green : _red;
@ -233,7 +274,6 @@ class _TradingRecordsPageState extends ConsumerState<TradingRecordsPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// +
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@ -245,11 +285,7 @@ class _TradingRecordsPageState extends ConsumerState<TradingRecordsPage> {
),
child: Text(
typeText,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: typeColor,
),
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: typeColor),
),
),
Container(
@ -260,18 +296,12 @@ class _TradingRecordsPageState extends ConsumerState<TradingRecordsPage> {
),
child: Text(
statusText,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: statusColor,
),
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: statusColor),
),
),
],
),
const SizedBox(height: 12),
//
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@ -280,8 +310,6 @@ class _TradingRecordsPageState extends ConsumerState<TradingRecordsPage> {
],
),
const SizedBox(height: 8),
//
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@ -290,26 +318,17 @@ class _TradingRecordsPageState extends ConsumerState<TradingRecordsPage> {
],
),
const SizedBox(height: 8),
//
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Row(
children: [
Text(
'订单号: ',
style: TextStyle(fontSize: 11, color: _lightGray),
),
Text('订单号: ', style: TextStyle(fontSize: 11, color: _lightGray)),
Expanded(
child: Text(
order.orderNo,
style: TextStyle(
fontSize: 11,
color: _grayText,
fontFamily: 'monospace',
),
style: TextStyle(fontSize: 11, color: _grayText, fontFamily: 'monospace'),
overflow: TextOverflow.ellipsis,
),
),
@ -334,19 +353,110 @@ class _TradingRecordsPageState extends ConsumerState<TradingRecordsPage> {
);
}
Widget _buildInfoItem(String label, String value) {
Widget _buildTradeCard(TradeRecord trade) {
final isBuy = trade.isBuy;
final typeColor = isBuy ? _green : _red;
final typeText = isBuy ? '买入' : '卖出';
// netAmount + fee
final grossAmount = isBuy
? double.tryParse(trade.amount) ?? 0
: (double.tryParse(trade.netAmount) ?? 0) + (double.tryParse(trade.fee) ?? 0);
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: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: typeColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
typeText,
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: typeColor),
),
),
Text(
DateFormat('MM-dd HH:mm').format(trade.createdAt),
style: TextStyle(fontSize: 12, color: _lightGray),
),
],
),
const SizedBox(height: 12),
//
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildInfoItem('价格', _formatPrice(trade.price)),
_buildInfoItem('数量', _formatQuantity(trade.quantity)),
],
),
const SizedBox(height: 8),
//
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildInfoItem('交易总额', '${formatAmount(grossAmount.toString())} 积分值'),
if (trade.hasFee)
_buildInfoItem('手续费(10%)', '${formatAmount(trade.fee)} 积分值', valueColor: _red),
],
),
//
if (trade.isSell && trade.hasFee) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildInfoItem('实际收到', '${formatAmount(trade.netAmount)} 积分值', valueColor: _green),
const SizedBox(),
],
),
],
const SizedBox(height: 8),
//
Row(
children: [
Text('成交编号: ', style: TextStyle(fontSize: 11, color: _lightGray)),
Expanded(
child: Text(
trade.tradeNo,
style: TextStyle(fontSize: 11, color: _grayText, fontFamily: 'monospace'),
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
);
}
Widget _buildInfoItem(String label, String value, {Color? valueColor}) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$label: ',
style: TextStyle(fontSize: 12, color: _grayText),
),
Text('$label: ', style: TextStyle(fontSize: 12, color: _grayText)),
Text(
value,
style: const TextStyle(
style: TextStyle(
fontSize: 12,
color: _darkText,
color: valueColor ?? _darkText,
fontWeight: FontWeight.w500,
fontFamily: 'monospace',
),
@ -402,14 +512,11 @@ class _TradingRecordsPageState extends ConsumerState<TradingRecordsPage> {
}
}
Widget _buildBottomInfo(int total) {
Widget _buildBottomInfo(String text) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Text(
'$total 条交易记录',
style: TextStyle(fontSize: 12, color: _grayText),
),
child: Text(text, style: TextStyle(fontSize: 12, color: _grayText)),
),
);
}

View File

@ -329,6 +329,23 @@ final ordersProvider = FutureProvider<OrdersPageModel?>((ref) async {
);
});
// Provider
final tradesProvider = FutureProvider<TradesPageModel?>((ref) async {
final repository = ref.watch(tradingRepositoryProvider);
final result = await repository.getTrades(page: 1, pageSize: 50);
ref.keepAlive();
final timer = Timer(const Duration(seconds: 30), () {
ref.invalidateSelf();
});
ref.onDispose(() => timer.cancel());
return result.fold(
(failure) => throw Exception(failure.message),
(trades) => trades,
);
});
//
class TradingState {
final bool isLoading;