feat: 实现P2P转账功能及前端资产页面优化

- trading-service: 添加P2pTransfer模型和P2P转账API
- auth-service: 添加用户手机号查询接口用于转账验证
- frontend: 修复资产页面冻结份额显示和转账页面余额字段
- frontend: 添加P2P转账记录页面

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-28 06:25:42 -08:00
parent 06dbe133c2
commit 2597d0ef46
13 changed files with 854 additions and 26 deletions

View File

@ -1,7 +1,9 @@
import {
Controller,
Get,
Query,
UseGuards,
BadRequestException,
} from '@nestjs/common';
import { UserService, UserProfileResult } from '@/application/services';
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
@ -23,4 +25,21 @@ export class UserController {
const result = await this.userService.getProfile(user.accountSequence);
return { success: true, data: result };
}
/**
* P2P转账验证
* GET /user/lookup?phone=13800138000
*/
@Get('lookup')
async lookupByPhone(
@Query('phone') phone: string,
@CurrentUser() currentUser: { accountSequence: string },
): Promise<{ success: boolean; data: { exists: boolean; nickname?: string; accountSequence?: string } }> {
if (!phone || phone.length !== 11) {
throw new BadRequestException('请输入有效的11位手机号');
}
const result = await this.userService.lookupByPhone(phone);
return { success: true, data: result };
}
}

View File

@ -48,6 +48,24 @@ export class UserService {
};
}
/**
* P2P转账验证
*/
async lookupByPhone(phone: string): Promise<{ exists: boolean; accountSequence?: string; nickname?: string }> {
const phoneVO = Phone.create(phone);
const user = await this.userRepository.findByPhone(phoneVO);
if (!user || user.status !== 'ACTIVE') {
return { exists: false };
}
return {
exists: true,
accountSequence: user.accountSequence.value,
nickname: user.isKycVerified ? this.maskName(user.realName!) : user.phone.masked,
};
}
/**
*
*/

View File

@ -0,0 +1,34 @@
-- CreateTable
CREATE TABLE "p2p_transfers" (
"id" TEXT NOT NULL,
"transfer_no" TEXT NOT NULL,
"from_account_sequence" TEXT NOT NULL,
"to_account_sequence" TEXT NOT NULL,
"to_phone" TEXT NOT NULL,
"to_nickname" TEXT,
"from_phone" TEXT,
"from_nickname" TEXT,
"amount" DECIMAL(30,8) NOT NULL,
"memo" TEXT,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"error_message" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completed_at" TIMESTAMP(3),
CONSTRAINT "p2p_transfers_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "p2p_transfers_transfer_no_key" ON "p2p_transfers"("transfer_no");
-- CreateIndex
CREATE INDEX "p2p_transfers_from_account_sequence_idx" ON "p2p_transfers"("from_account_sequence");
-- CreateIndex
CREATE INDEX "p2p_transfers_to_account_sequence_idx" ON "p2p_transfers"("to_account_sequence");
-- CreateIndex
CREATE INDEX "p2p_transfers_status_idx" ON "p2p_transfers"("status");
-- CreateIndex
CREATE INDEX "p2p_transfers_created_at_idx" ON "p2p_transfers"("created_at" DESC);

View File

@ -392,6 +392,30 @@ model TransferRecord {
@@map("transfer_records")
}
// P2P用户间转账记录
model P2pTransfer {
id String @id @default(uuid())
transferNo String @unique @map("transfer_no")
fromAccountSequence String @map("from_account_sequence")
toAccountSequence String @map("to_account_sequence")
toPhone String @map("to_phone") // 收款方手机号(用于显示)
toNickname String? @map("to_nickname") // 收款方昵称
fromPhone String? @map("from_phone") // 发送方手机号
fromNickname String? @map("from_nickname") // 发送方昵称
amount Decimal @db.Decimal(30, 8)
memo String? @db.Text // 备注
status String @default("PENDING") // PENDING, COMPLETED, FAILED
errorMessage String? @map("error_message")
createdAt DateTime @default(now()) @map("created_at")
completedAt DateTime? @map("completed_at")
@@index([fromAccountSequence])
@@index([toAccountSequence])
@@index([status])
@@index([createdAt(sort: Desc)])
@@map("p2p_transfers")
}
// ==================== Outbox ====================
enum OutboxStatus {

View File

@ -3,6 +3,7 @@ import { ApplicationModule } from '../application/application.module';
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
import { TradingController } from './controllers/trading.controller';
import { TransferController } from './controllers/transfer.controller';
import { P2pTransferController } from './controllers/p2p-transfer.controller';
import { HealthController } from './controllers/health.controller';
import { AdminController } from './controllers/admin.controller';
import { PriceController } from './controllers/price.controller';
@ -17,6 +18,7 @@ import { PriceGateway } from './gateways/price.gateway';
controllers: [
TradingController,
TransferController,
P2pTransferController,
HealthController,
AdminController,
PriceController,

View File

@ -0,0 +1,76 @@
import { Controller, Get, Post, Body, Query, Param, Req, Headers, BadRequestException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
import { IsString, IsOptional, Length, Matches } from 'class-validator';
import { P2pTransferService } from '../../application/services/p2p-transfer.service';
class P2pTransferDto {
@IsString()
@Length(11, 11)
@Matches(/^\d{11}$/, { message: '请输入有效的11位手机号' })
toPhone: string;
@IsString()
amount: string;
@IsOptional()
@IsString()
@Length(0, 100)
memo?: string;
}
@ApiTags('P2P Transfer')
@ApiBearerAuth()
@Controller('p2p')
export class P2pTransferController {
constructor(private readonly p2pTransferService: P2pTransferService) {}
@Post('transfer')
@ApiOperation({ summary: 'P2P转账积分值' })
async transfer(
@Body() dto: P2pTransferDto,
@Headers('authorization') authHeader: string,
@Req() req: any,
) {
const accountSequence = req.user?.accountSequence;
if (!accountSequence) {
throw new BadRequestException('Unauthorized');
}
const token = authHeader?.replace('Bearer ', '') || '';
const result = await this.p2pTransferService.transfer(
accountSequence,
dto.toPhone,
dto.amount,
dto.memo,
token,
);
return { success: true, data: result };
}
@Get('transfers/:accountSequence')
@ApiOperation({ summary: '获取P2P转账历史' })
@ApiParam({ name: 'accountSequence', required: true, description: '账户序列号' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
async getHistory(
@Param('accountSequence') accountSequence: string,
@Req() req: any,
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
) {
// 验证只能查询自己的转账历史
const currentUser = req.user?.accountSequence;
if (!currentUser || currentUser !== accountSequence) {
throw new BadRequestException('Unauthorized');
}
const result = await this.p2pTransferService.getTransferHistory(
accountSequence,
page ?? 1,
pageSize ?? 20,
);
return { success: true, data: result.data };
}
}

View File

@ -4,6 +4,7 @@ import { InfrastructureModule } from '../infrastructure/infrastructure.module';
import { ApiModule } from '../api/api.module';
import { OrderService } from './services/order.service';
import { TransferService } from './services/transfer.service';
import { P2pTransferService } from './services/p2p-transfer.service';
import { PriceService } from './services/price.service';
import { BurnService } from './services/burn.service';
import { AssetService } from './services/asset.service';
@ -27,6 +28,7 @@ import { C2cExpiryScheduler } from './schedulers/c2c-expiry.scheduler';
AssetService,
OrderService,
TransferService,
P2pTransferService,
MarketMakerService,
C2cService,
// Schedulers
@ -35,6 +37,6 @@ import { C2cExpiryScheduler } from './schedulers/c2c-expiry.scheduler';
PriceBroadcastScheduler,
C2cExpiryScheduler,
],
exports: [OrderService, TransferService, PriceService, BurnService, AssetService, MarketMakerService, C2cService],
exports: [OrderService, TransferService, P2pTransferService, PriceService, BurnService, AssetService, MarketMakerService, C2cService],
})
export class ApplicationModule {}

View File

@ -0,0 +1,297 @@
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TradingAccountRepository } from '../../infrastructure/persistence/repositories/trading-account.repository';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import { TradingAccountAggregate } from '../../domain/aggregates/trading-account.aggregate';
import { Money } from '../../domain/value-objects/money.vo';
interface RecipientInfo {
exists: boolean;
accountSequence?: string;
nickname?: string;
}
export interface P2pTransferResult {
transferNo: string;
amount: string;
toPhone: string;
toNickname?: string;
status: string;
createdAt: Date;
}
export interface P2pTransferHistoryItem {
transferNo: string;
fromAccountSequence: string;
toAccountSequence: string;
toPhone: string;
amount: string;
memo?: string | null;
status: string;
createdAt: Date;
}
@Injectable()
export class P2pTransferService {
private readonly logger = new Logger(P2pTransferService.name);
private readonly authServiceUrl: string;
private readonly minTransferAmount: number;
constructor(
private readonly accountRepository: TradingAccountRepository,
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
) {
this.authServiceUrl = this.configService.get<string>('AUTH_SERVICE_URL', 'http://localhost:3020');
this.minTransferAmount = this.configService.get<number>('MIN_P2P_TRANSFER_AMOUNT', 0.01);
}
/**
*
*/
async lookupRecipient(phone: string, token: string): Promise<RecipientInfo> {
try {
const response = await fetch(`${this.authServiceUrl}/api/v2/auth/user/lookup?phone=${phone}`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
return { exists: false };
}
const result = await response.json();
if (!result.success || !result.data) {
return { exists: false };
}
return result.data;
} catch (error: any) {
this.logger.warn(`Failed to lookup recipient: ${error.message}`);
return { exists: false };
}
}
/**
* P2P转账
*/
async transfer(
fromAccountSequence: string,
toPhone: string,
amount: string,
memo?: string,
token?: string,
): Promise<P2pTransferResult> {
const transferAmount = new Money(amount);
// 验证转账金额
if (transferAmount.value.lessThan(this.minTransferAmount)) {
throw new BadRequestException(`最小转账金额为 ${this.minTransferAmount}`);
}
// 查找收款方
const recipient = await this.lookupRecipient(toPhone, token || '');
if (!recipient.exists || !recipient.accountSequence) {
throw new NotFoundException('收款方账户不存在');
}
// 不能转给自己
if (recipient.accountSequence === fromAccountSequence) {
throw new BadRequestException('不能转账给自己');
}
// 查找发送方账户
const fromAccount = await this.accountRepository.findByAccountSequence(fromAccountSequence);
if (!fromAccount) {
throw new NotFoundException('发送方账户不存在');
}
// 检查余额
if (fromAccount.availableCash.isLessThan(transferAmount)) {
throw new BadRequestException('可用积分值不足');
}
const transferNo = this.generateTransferNo();
// 使用事务执行转账
try {
await this.prisma.$transaction(async (tx) => {
// 1. 创建转账记录
await tx.p2pTransfer.create({
data: {
transferNo,
fromAccountSequence,
toAccountSequence: recipient.accountSequence!,
toPhone,
toNickname: recipient.nickname,
amount: transferAmount.value,
memo,
status: 'PENDING',
},
});
// 2. 扣减发送方余额
fromAccount.withdraw(transferAmount, transferNo);
// 保存发送方账户变动
await tx.tradingAccount.update({
where: { accountSequence: fromAccountSequence },
data: {
cashBalance: fromAccount.cashBalance.value,
},
});
// 记录发送方交易流水
for (const txn of fromAccount.pendingTransactions) {
await tx.tradingTransaction.create({
data: {
accountSequence: fromAccountSequence,
type: txn.type,
assetType: txn.assetType,
amount: txn.amount.value,
balanceBefore: txn.balanceBefore.value,
balanceAfter: txn.balanceAfter.value,
referenceId: transferNo,
referenceType: 'P2P_TRANSFER',
counterpartyType: 'USER',
counterpartyAccountSeq: recipient.accountSequence,
memo: `P2P转出给 ${toPhone}`,
},
});
}
fromAccount.clearPendingTransactions();
// 3. 增加收款方余额
let toAccount = await this.accountRepository.findByAccountSequence(recipient.accountSequence!);
if (!toAccount) {
toAccount = TradingAccountAggregate.create(recipient.accountSequence!);
// 创建新账户
await tx.tradingAccount.create({
data: {
accountSequence: recipient.accountSequence!,
shareBalance: 0,
cashBalance: 0,
frozenShares: 0,
frozenCash: 0,
totalBought: 0,
totalSold: 0,
},
});
}
toAccount.deposit(transferAmount, transferNo);
// 保存收款方账户变动
await tx.tradingAccount.update({
where: { accountSequence: recipient.accountSequence! },
data: {
cashBalance: toAccount.cashBalance.value,
},
});
// 记录收款方交易流水
for (const txn of toAccount.pendingTransactions) {
await tx.tradingTransaction.create({
data: {
accountSequence: recipient.accountSequence!,
type: txn.type,
assetType: txn.assetType,
amount: txn.amount.value,
balanceBefore: txn.balanceBefore.value,
balanceAfter: txn.balanceAfter.value,
referenceId: transferNo,
referenceType: 'P2P_TRANSFER',
counterpartyType: 'USER',
counterpartyAccountSeq: fromAccountSequence,
memo: `P2P转入来自 ${fromAccountSequence}`,
},
});
}
// 4. 更新转账记录为完成
await tx.p2pTransfer.update({
where: { transferNo },
data: {
status: 'COMPLETED',
completedAt: new Date(),
},
});
});
this.logger.log(`P2P transfer completed: ${transferNo}, ${fromAccountSequence} -> ${toPhone}, amount=${amount}`);
return {
transferNo,
amount,
toPhone,
toNickname: recipient.nickname,
status: 'COMPLETED',
createdAt: new Date(),
};
} catch (error: any) {
// 更新转账记录为失败
await this.prisma.p2pTransfer.update({
where: { transferNo },
data: {
status: 'FAILED',
errorMessage: error.message,
},
}).catch(() => {});
this.logger.error(`P2P transfer failed: ${transferNo}, ${error.message}`);
throw error;
}
}
/**
* P2P转账历史
*/
async getTransferHistory(
accountSequence: string,
page: number = 1,
pageSize: number = 20,
): Promise<{ data: P2pTransferHistoryItem[]; total: number }> {
const [records, total] = await Promise.all([
this.prisma.p2pTransfer.findMany({
where: {
OR: [
{ fromAccountSequence: accountSequence },
{ toAccountSequence: accountSequence },
],
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.p2pTransfer.count({
where: {
OR: [
{ fromAccountSequence: accountSequence },
{ toAccountSequence: accountSequence },
],
},
}),
]);
const data = records.map((record) => ({
transferNo: record.transferNo,
fromAccountSequence: record.fromAccountSequence,
toAccountSequence: record.toAccountSequence,
toPhone: record.toPhone,
amount: record.amount.toString(),
memo: record.memo,
status: record.status,
createdAt: record.createdAt,
} as P2pTransferHistoryItem));
return { data, total };
}
private generateTransferNo(): string {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 8);
return `P2P${timestamp}${random}`.toUpperCase();
}
}

View File

@ -23,6 +23,7 @@ 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/asset/p2p_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';
@ -167,6 +168,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: Routes.transferRecords,
builder: (context, state) => const TransferRecordsPage(),
),
GoRoute(
path: Routes.p2pTransferRecords,
builder: (context, state) => const P2pTransferRecordsPage(),
),
GoRoute(
path: Routes.helpCenter,
builder: (context, state) => const HelpCenterPage(),

View File

@ -26,6 +26,8 @@ class Routes {
static const String tradingRecords = '/trading-records';
//
static const String transferRecords = '/transfer-records';
// P2P转账记录
static const String p2pTransferRecords = '/p2p-transfer-records';
//
static const String helpCenter = '/help-center';
static const String about = '/about';

View File

@ -8,9 +8,12 @@ import '../../../core/network/price_websocket_service.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/constants/app_colors.dart';
import '../../../domain/entities/asset_display.dart';
import '../../../domain/entities/trade_order.dart';
import '../../../data/models/trade_order_model.dart';
import '../../providers/user_providers.dart';
import '../../providers/asset_providers.dart';
import '../../providers/mining_providers.dart';
import '../../providers/trading_providers.dart';
import '../../widgets/shimmer_loading.dart';
class AssetPage extends ConsumerStatefulWidget {
@ -160,11 +163,14 @@ class _AssetPageState extends ConsumerState<AssetPage> {
final assetAsync = ref.watch(accountAssetProvider(accountSequence));
// mining-service
final shareAccountAsync = ref.watch(shareAccountProvider(accountSequence));
//
final ordersAsync = ref.watch(ordersProvider);
//
final isLoading = assetAsync.isLoading || accountSequence.isEmpty;
final asset = assetAsync.valueOrNull;
final shareAccount = shareAccountAsync.valueOrNull;
final orders = ordersAsync.valueOrNull?.data ?? [];
// 使 mining-service
final perSecondEarning = shareAccount?.perSecondEarning ?? '0';
@ -205,6 +211,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_lastAsset = null;
ref.invalidate(accountAssetProvider(accountSequence));
ref.invalidate(shareAccountProvider(accountSequence));
ref.invalidate(ordersProvider);
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
@ -227,7 +234,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_buildQuickActions(context),
const SizedBox(height: 24),
// -
_buildAssetList(context, asset, isLoading, _currentShareBalance, perSecondEarning),
_buildAssetList(context, asset, isLoading, _currentShareBalance, perSecondEarning, orders),
const SizedBox(height: 24),
//
_buildEarningsCard(context, asset, isLoading),
@ -480,7 +487,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
);
}
Widget _buildAssetList(BuildContext context, AssetDisplay? asset, bool isLoading, double currentShareBalance, String perSecondEarning) {
Widget _buildAssetList(BuildContext context, AssetDisplay? asset, bool isLoading, double currentShareBalance, String perSecondEarning, List<TradeOrder> orders) {
// 使
final shareBalance = asset != null && currentShareBalance > 0
? currentShareBalance
@ -490,6 +497,10 @@ class _AssetPageState extends ConsumerState<AssetPage> {
final currentPrice = double.tryParse(asset?.currentPrice ?? '0') ?? 0;
final isDark = AppColors.isDark(context);
//
final frozenShares = double.tryParse(asset?.frozenShares ?? '0') ?? 0;
final frozenSharesSubtitle = _getFrozenSharesSubtitle(frozenShares, orders);
return Column(
children: [
// -
@ -528,12 +539,32 @@ class _AssetPageState extends ConsumerState<AssetPage> {
title: '冻结积分股',
amount: asset?.frozenShares,
isLoading: isLoading,
subtitle: '交易挂单中',
subtitle: frozenSharesSubtitle,
onTap: frozenShares > 0 ? () => context.push(Routes.tradingRecords) : null,
),
],
);
}
///
String? _getFrozenSharesSubtitle(double frozenShares, List<TradeOrder> orders) {
if (frozenShares <= 0) {
return null;
}
// pending partial
final hasPendingSellOrder = orders.any(
(order) => order.isSell && (order.isPending || order.isPartial),
);
if (hasPendingSellOrder) {
return '交易挂单中';
}
//
return '处理中';
}
Widget _buildAssetItem({
required BuildContext context,
required IconData icon,
@ -542,6 +573,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
required String title,
String? amount,
bool isLoading = false,
VoidCallback? onTap,
String? valueInCny,
String? tag,
String? growthText,
@ -551,20 +583,23 @@ class _AssetPageState extends ConsumerState<AssetPage> {
String? subtitle,
}) {
final isDark = AppColors.isDark(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.cardOf(context),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: isDark ? Colors.black.withOpacity(0.2) : Colors.black.withOpacity(0.05),
blurRadius: 2,
offset: const Offset(0, 1),
),
],
),
child: Row(
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.cardOf(context),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: isDark ? Colors.black.withOpacity(0.2) : Colors.black.withOpacity(0.05),
blurRadius: 2,
offset: const Offset(0, 1),
),
],
),
child: Row(
children: [
//
Container(
@ -698,6 +733,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
Icon(Icons.chevron_right, size: 14, color: AppColors.textMutedOf(context)),
],
),
),
);
}

View File

@ -0,0 +1,300 @@
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 '../../../data/models/p2p_transfer_model.dart';
import '../../providers/transfer_providers.dart';
import '../../providers/user_providers.dart';
/// P2P转账记录页面
class P2pTransferRecordsPage extends ConsumerWidget {
const P2pTransferRecordsPage({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 user = ref.watch(userNotifierProvider);
final accountSequence = user.accountSequence ?? '';
final recordsAsync = ref.watch(p2pTransferHistoryProvider(accountSequence));
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(p2pTransferHistoryProvider(accountSequence));
},
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),
const Text(
'加载失败',
style: TextStyle(color: _grayText),
),
const SizedBox(height: 8),
TextButton(
onPressed: () => ref.invalidate(p2pTransferHistoryProvider(accountSequence)),
child: const Text('点击重试'),
),
],
),
),
data: (records) {
if (records.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.receipt_long,
size: 64,
color: _grayText.withOpacity(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(context, records[index], accountSequence);
},
);
},
),
),
);
}
Widget _buildRecordCard(BuildContext context, P2pTransferModel record, String myAccountSequence) {
//
final isSend = record.fromAccountSequence == myAccountSequence;
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: (isSend ? _orange : _green).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
isSend ? Icons.arrow_upward : Icons.arrow_downward,
size: 18,
color: isSend ? _orange : _green,
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isSend ? '转出' : '转入',
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.withOpacity(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(
'${isSend ? '-' : '+'}${formatAmount(record.amount)} 积分值',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: isSend ? _orange : _green,
),
),
],
),
const SizedBox(height: 8),
//
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
isSend ? '收款方' : '付款方',
style: const TextStyle(fontSize: 12, color: _grayText),
),
Text(
_maskPhone(record.toPhone),
style: const TextStyle(
fontSize: 12,
color: _grayText,
),
),
],
),
//
if (record.memo != null && record.memo!.isNotEmpty) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'备注',
style: TextStyle(fontSize: 12, color: _grayText),
),
Flexible(
child: Text(
record.memo!,
style: const TextStyle(
fontSize: 12,
color: _darkText,
),
textAlign: TextAlign.right,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
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',
),
),
],
),
],
),
);
}
String _maskPhone(String phone) {
if (phone.length != 11) return phone;
return '${phone.substring(0, 3)}****${phone.substring(7)}';
}
Color _getStatusColor(String status) {
switch (status.toUpperCase()) {
case 'COMPLETED':
case 'SUCCESS':
return _green;
case 'PENDING':
return _orange;
case 'FAILED':
return _red;
default:
return _grayText;
}
}
String _getStatusText(String status) {
switch (status.toUpperCase()) {
case 'COMPLETED':
case 'SUCCESS':
return '已完成';
case 'PENDING':
return '处理中';
case 'FAILED':
return '失败';
default:
return status;
}
}
}

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/router/routes.dart';
import '../../../core/utils/format_utils.dart';
import '../../providers/user_providers.dart';
import '../../providers/asset_providers.dart';
@ -44,7 +45,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
final assetAsync = ref.watch(accountAssetProvider(accountSequence));
final transferState = ref.watch(transferNotifierProvider);
final availableShares = assetAsync.valueOrNull?.availableShares ?? '0';
final availableCash = assetAsync.valueOrNull?.availableCash ?? '0';
return Scaffold(
backgroundColor: _bgGray,
@ -64,6 +65,18 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
),
),
centerTitle: true,
actions: [
TextButton(
onPressed: () => context.push(Routes.p2pTransferRecords),
child: const Text(
'转账记录',
style: TextStyle(
fontSize: 14,
color: _orange,
),
),
),
],
),
body: SingleChildScrollView(
child: Column(
@ -76,7 +89,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
const SizedBox(height: 16),
//
_buildAmountSection(availableShares),
_buildAmountSection(availableCash),
const SizedBox(height: 16),
@ -86,7 +99,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
const SizedBox(height: 32),
//
_buildSendButton(transferState, availableShares),
_buildSendButton(transferState, availableCash),
const SizedBox(height: 16),
@ -248,7 +261,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
);
}
Widget _buildAmountSection(String availableShares) {
Widget _buildAmountSection(String availableCash) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
@ -271,7 +284,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
),
),
Text(
'可用: ${formatAmount(availableShares)}',
'可用: ${formatAmount(availableCash)}',
style: const TextStyle(
fontSize: 12,
color: _grayText,
@ -301,7 +314,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
),
suffixIcon: TextButton(
onPressed: () {
_amountController.text = availableShares;
_amountController.text = availableCash;
},
child: const Text(
'全部',
@ -365,9 +378,9 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
);
}
Widget _buildSendButton(TransferState transferState, String availableShares) {
Widget _buildSendButton(TransferState transferState, String availableCash) {
final amount = double.tryParse(_amountController.text) ?? 0;
final available = double.tryParse(availableShares) ?? 0;
final available = double.tryParse(availableCash) ?? 0;
final isValid = _isRecipientVerified && amount > 0 && amount <= available;
return Padding(