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:
parent
06dbe133c2
commit
2597d0ef46
|
|
@ -1,7 +1,9 @@
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { UserService, UserProfileResult } from '@/application/services';
|
import { UserService, UserProfileResult } from '@/application/services';
|
||||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
|
|
@ -23,4 +25,21 @@ export class UserController {
|
||||||
const result = await this.userService.getProfile(user.accountSequence);
|
const result = await this.userService.getProfile(user.accountSequence);
|
||||||
return { success: true, data: result };
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更换手机号
|
* 更换手机号
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -392,6 +392,30 @@ model TransferRecord {
|
||||||
@@map("transfer_records")
|
@@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 ====================
|
// ==================== Outbox ====================
|
||||||
|
|
||||||
enum OutboxStatus {
|
enum OutboxStatus {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { ApplicationModule } from '../application/application.module';
|
||||||
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
|
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
|
||||||
import { TradingController } from './controllers/trading.controller';
|
import { TradingController } from './controllers/trading.controller';
|
||||||
import { TransferController } from './controllers/transfer.controller';
|
import { TransferController } from './controllers/transfer.controller';
|
||||||
|
import { P2pTransferController } from './controllers/p2p-transfer.controller';
|
||||||
import { HealthController } from './controllers/health.controller';
|
import { HealthController } from './controllers/health.controller';
|
||||||
import { AdminController } from './controllers/admin.controller';
|
import { AdminController } from './controllers/admin.controller';
|
||||||
import { PriceController } from './controllers/price.controller';
|
import { PriceController } from './controllers/price.controller';
|
||||||
|
|
@ -17,6 +18,7 @@ import { PriceGateway } from './gateways/price.gateway';
|
||||||
controllers: [
|
controllers: [
|
||||||
TradingController,
|
TradingController,
|
||||||
TransferController,
|
TransferController,
|
||||||
|
P2pTransferController,
|
||||||
HealthController,
|
HealthController,
|
||||||
AdminController,
|
AdminController,
|
||||||
PriceController,
|
PriceController,
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { InfrastructureModule } from '../infrastructure/infrastructure.module';
|
||||||
import { ApiModule } from '../api/api.module';
|
import { ApiModule } from '../api/api.module';
|
||||||
import { OrderService } from './services/order.service';
|
import { OrderService } from './services/order.service';
|
||||||
import { TransferService } from './services/transfer.service';
|
import { TransferService } from './services/transfer.service';
|
||||||
|
import { P2pTransferService } from './services/p2p-transfer.service';
|
||||||
import { PriceService } from './services/price.service';
|
import { PriceService } from './services/price.service';
|
||||||
import { BurnService } from './services/burn.service';
|
import { BurnService } from './services/burn.service';
|
||||||
import { AssetService } from './services/asset.service';
|
import { AssetService } from './services/asset.service';
|
||||||
|
|
@ -27,6 +28,7 @@ import { C2cExpiryScheduler } from './schedulers/c2c-expiry.scheduler';
|
||||||
AssetService,
|
AssetService,
|
||||||
OrderService,
|
OrderService,
|
||||||
TransferService,
|
TransferService,
|
||||||
|
P2pTransferService,
|
||||||
MarketMakerService,
|
MarketMakerService,
|
||||||
C2cService,
|
C2cService,
|
||||||
// Schedulers
|
// Schedulers
|
||||||
|
|
@ -35,6 +37,6 @@ import { C2cExpiryScheduler } from './schedulers/c2c-expiry.scheduler';
|
||||||
PriceBroadcastScheduler,
|
PriceBroadcastScheduler,
|
||||||
C2cExpiryScheduler,
|
C2cExpiryScheduler,
|
||||||
],
|
],
|
||||||
exports: [OrderService, TransferService, PriceService, BurnService, AssetService, MarketMakerService, C2cService],
|
exports: [OrderService, TransferService, P2pTransferService, PriceService, BurnService, AssetService, MarketMakerService, C2cService],
|
||||||
})
|
})
|
||||||
export class ApplicationModule {}
|
export class ApplicationModule {}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ import '../../presentation/pages/c2c/c2c_order_detail_page.dart';
|
||||||
import '../../presentation/pages/profile/team_page.dart';
|
import '../../presentation/pages/profile/team_page.dart';
|
||||||
import '../../presentation/pages/profile/trading_records_page.dart';
|
import '../../presentation/pages/profile/trading_records_page.dart';
|
||||||
import '../../presentation/pages/trading/transfer_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/help_center_page.dart';
|
||||||
import '../../presentation/pages/profile/about_page.dart';
|
import '../../presentation/pages/profile/about_page.dart';
|
||||||
import '../../presentation/widgets/main_shell.dart';
|
import '../../presentation/widgets/main_shell.dart';
|
||||||
|
|
@ -167,6 +168,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
path: Routes.transferRecords,
|
path: Routes.transferRecords,
|
||||||
builder: (context, state) => const TransferRecordsPage(),
|
builder: (context, state) => const TransferRecordsPage(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: Routes.p2pTransferRecords,
|
||||||
|
builder: (context, state) => const P2pTransferRecordsPage(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.helpCenter,
|
path: Routes.helpCenter,
|
||||||
builder: (context, state) => const HelpCenterPage(),
|
builder: (context, state) => const HelpCenterPage(),
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ class Routes {
|
||||||
static const String tradingRecords = '/trading-records';
|
static const String tradingRecords = '/trading-records';
|
||||||
// 划转记录
|
// 划转记录
|
||||||
static const String transferRecords = '/transfer-records';
|
static const String transferRecords = '/transfer-records';
|
||||||
|
// P2P转账记录
|
||||||
|
static const String p2pTransferRecords = '/p2p-transfer-records';
|
||||||
// 其他设置
|
// 其他设置
|
||||||
static const String helpCenter = '/help-center';
|
static const String helpCenter = '/help-center';
|
||||||
static const String about = '/about';
|
static const String about = '/about';
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,12 @@ import '../../../core/network/price_websocket_service.dart';
|
||||||
import '../../../core/constants/app_constants.dart';
|
import '../../../core/constants/app_constants.dart';
|
||||||
import '../../../core/constants/app_colors.dart';
|
import '../../../core/constants/app_colors.dart';
|
||||||
import '../../../domain/entities/asset_display.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/user_providers.dart';
|
||||||
import '../../providers/asset_providers.dart';
|
import '../../providers/asset_providers.dart';
|
||||||
import '../../providers/mining_providers.dart';
|
import '../../providers/mining_providers.dart';
|
||||||
|
import '../../providers/trading_providers.dart';
|
||||||
import '../../widgets/shimmer_loading.dart';
|
import '../../widgets/shimmer_loading.dart';
|
||||||
|
|
||||||
class AssetPage extends ConsumerStatefulWidget {
|
class AssetPage extends ConsumerStatefulWidget {
|
||||||
|
|
@ -160,11 +163,14 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||||
final assetAsync = ref.watch(accountAssetProvider(accountSequence));
|
final assetAsync = ref.watch(accountAssetProvider(accountSequence));
|
||||||
// 从 mining-service 获取每秒收益
|
// 从 mining-service 获取每秒收益
|
||||||
final shareAccountAsync = ref.watch(shareAccountProvider(accountSequence));
|
final shareAccountAsync = ref.watch(shareAccountProvider(accountSequence));
|
||||||
|
// 获取订单列表,用于显示冻结状态
|
||||||
|
final ordersAsync = ref.watch(ordersProvider);
|
||||||
|
|
||||||
// 提取数据和加载状态
|
// 提取数据和加载状态
|
||||||
final isLoading = assetAsync.isLoading || accountSequence.isEmpty;
|
final isLoading = assetAsync.isLoading || accountSequence.isEmpty;
|
||||||
final asset = assetAsync.valueOrNull;
|
final asset = assetAsync.valueOrNull;
|
||||||
final shareAccount = shareAccountAsync.valueOrNull;
|
final shareAccount = shareAccountAsync.valueOrNull;
|
||||||
|
final orders = ordersAsync.valueOrNull?.data ?? [];
|
||||||
|
|
||||||
// 获取每秒收益(优先使用 mining-service 的数据)
|
// 获取每秒收益(优先使用 mining-service 的数据)
|
||||||
final perSecondEarning = shareAccount?.perSecondEarning ?? '0';
|
final perSecondEarning = shareAccount?.perSecondEarning ?? '0';
|
||||||
|
|
@ -205,6 +211,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||||
_lastAsset = null;
|
_lastAsset = null;
|
||||||
ref.invalidate(accountAssetProvider(accountSequence));
|
ref.invalidate(accountAssetProvider(accountSequence));
|
||||||
ref.invalidate(shareAccountProvider(accountSequence));
|
ref.invalidate(shareAccountProvider(accountSequence));
|
||||||
|
ref.invalidate(ordersProvider);
|
||||||
},
|
},
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
|
@ -227,7 +234,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||||
_buildQuickActions(context),
|
_buildQuickActions(context),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
// 资产列表 - 始终显示,数字部分闪烁,实时刷新
|
// 资产列表 - 始终显示,数字部分闪烁,实时刷新
|
||||||
_buildAssetList(context, asset, isLoading, _currentShareBalance, perSecondEarning),
|
_buildAssetList(context, asset, isLoading, _currentShareBalance, perSecondEarning, orders),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
// 交易统计
|
// 交易统计
|
||||||
_buildEarningsCard(context, asset, isLoading),
|
_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
|
final shareBalance = asset != null && currentShareBalance > 0
|
||||||
? currentShareBalance
|
? currentShareBalance
|
||||||
|
|
@ -490,6 +497,10 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||||
final currentPrice = double.tryParse(asset?.currentPrice ?? '0') ?? 0;
|
final currentPrice = double.tryParse(asset?.currentPrice ?? '0') ?? 0;
|
||||||
final isDark = AppColors.isDark(context);
|
final isDark = AppColors.isDark(context);
|
||||||
|
|
||||||
|
// 根据订单状态动态计算冻结原因
|
||||||
|
final frozenShares = double.tryParse(asset?.frozenShares ?? '0') ?? 0;
|
||||||
|
final frozenSharesSubtitle = _getFrozenSharesSubtitle(frozenShares, orders);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// 积分股 - 实时刷新
|
// 积分股 - 实时刷新
|
||||||
|
|
@ -528,12 +539,32 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||||
title: '冻结积分股',
|
title: '冻结积分股',
|
||||||
amount: asset?.frozenShares,
|
amount: asset?.frozenShares,
|
||||||
isLoading: isLoading,
|
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({
|
Widget _buildAssetItem({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
|
|
@ -542,6 +573,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||||
required String title,
|
required String title,
|
||||||
String? amount,
|
String? amount,
|
||||||
bool isLoading = false,
|
bool isLoading = false,
|
||||||
|
VoidCallback? onTap,
|
||||||
String? valueInCny,
|
String? valueInCny,
|
||||||
String? tag,
|
String? tag,
|
||||||
String? growthText,
|
String? growthText,
|
||||||
|
|
@ -551,20 +583,23 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||||
String? subtitle,
|
String? subtitle,
|
||||||
}) {
|
}) {
|
||||||
final isDark = AppColors.isDark(context);
|
final isDark = AppColors.isDark(context);
|
||||||
return Container(
|
return GestureDetector(
|
||||||
padding: const EdgeInsets.all(16),
|
onTap: onTap,
|
||||||
decoration: BoxDecoration(
|
behavior: HitTestBehavior.opaque,
|
||||||
color: AppColors.cardOf(context),
|
child: Container(
|
||||||
borderRadius: BorderRadius.circular(16),
|
padding: const EdgeInsets.all(16),
|
||||||
boxShadow: [
|
decoration: BoxDecoration(
|
||||||
BoxShadow(
|
color: AppColors.cardOf(context),
|
||||||
color: isDark ? Colors.black.withOpacity(0.2) : Colors.black.withOpacity(0.05),
|
borderRadius: BorderRadius.circular(16),
|
||||||
blurRadius: 2,
|
boxShadow: [
|
||||||
offset: const Offset(0, 1),
|
BoxShadow(
|
||||||
),
|
color: isDark ? Colors.black.withOpacity(0.2) : Colors.black.withOpacity(0.05),
|
||||||
],
|
blurRadius: 2,
|
||||||
),
|
offset: const Offset(0, 1),
|
||||||
child: Row(
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// 图标
|
// 图标
|
||||||
Container(
|
Container(
|
||||||
|
|
@ -698,6 +733,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||||
Icon(Icons.chevron_right, size: 14, color: AppColors.textMutedOf(context)),
|
Icon(Icons.chevron_right, size: 14, color: AppColors.textMutedOf(context)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/router/routes.dart';
|
||||||
import '../../../core/utils/format_utils.dart';
|
import '../../../core/utils/format_utils.dart';
|
||||||
import '../../providers/user_providers.dart';
|
import '../../providers/user_providers.dart';
|
||||||
import '../../providers/asset_providers.dart';
|
import '../../providers/asset_providers.dart';
|
||||||
|
|
@ -44,7 +45,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
||||||
final assetAsync = ref.watch(accountAssetProvider(accountSequence));
|
final assetAsync = ref.watch(accountAssetProvider(accountSequence));
|
||||||
final transferState = ref.watch(transferNotifierProvider);
|
final transferState = ref.watch(transferNotifierProvider);
|
||||||
|
|
||||||
final availableShares = assetAsync.valueOrNull?.availableShares ?? '0';
|
final availableCash = assetAsync.valueOrNull?.availableCash ?? '0';
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: _bgGray,
|
backgroundColor: _bgGray,
|
||||||
|
|
@ -64,6 +65,18 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.push(Routes.p2pTransferRecords),
|
||||||
|
child: const Text(
|
||||||
|
'转账记录',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: _orange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -76,7 +89,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// 转账金额
|
// 转账金额
|
||||||
_buildAmountSection(availableShares),
|
_buildAmountSection(availableCash),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
|
@ -86,7 +99,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// 发送按钮
|
// 发送按钮
|
||||||
_buildSendButton(transferState, availableShares),
|
_buildSendButton(transferState, availableCash),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
|
@ -248,7 +261,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAmountSection(String availableShares) {
|
Widget _buildAmountSection(String availableCash) {
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
|
|
@ -271,7 +284,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'可用: ${formatAmount(availableShares)}',
|
'可用: ${formatAmount(availableCash)}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: _grayText,
|
color: _grayText,
|
||||||
|
|
@ -301,7 +314,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
||||||
),
|
),
|
||||||
suffixIcon: TextButton(
|
suffixIcon: TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_amountController.text = availableShares;
|
_amountController.text = availableCash;
|
||||||
},
|
},
|
||||||
child: const Text(
|
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 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;
|
final isValid = _isRecipientVerified && amount > 0 && amount <= available;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue