feat(trading): 添加账本明细页面,含统计图表和流水筛选
后端新增: - GET /wallet/ledger/statistics 流水统计API(按类型汇总) - GET /wallet/ledger/trend 流水趋势API(按日期统计) - LedgerStatisticsResponseDTO, LedgerTrendResponseDTO 等DTO 前端新增: - 账本明细页面(统计概览Tab + 流水明细Tab) - 收支概览卡片、趋势柱状图、按类型统计 - 流水列表支持分页加载和类型筛选 - 兑换页面右上角添加账本明细入口 授权服务: - 5种授权方法添加认种前置检查(需至少认种1棵树才能授权) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
cf27e55c45
commit
15e2bfe236
|
|
@ -234,6 +234,9 @@ export class AuthorizationApplicationService {
|
|||
const userId = UserId.create(command.userId, command.accountSequence)
|
||||
const adminId = AdminUserId.create(command.adminId, command.adminAccountSequence)
|
||||
|
||||
// 0. 检查用户是否已认种(授权前置条件)
|
||||
await this.ensureUserHasPlanted(command.accountSequence)
|
||||
|
||||
// 1. 检查用户是否已有社区角色
|
||||
const existingUserCommunity = await this.authorizationRepository.findByAccountSequenceAndRoleType(
|
||||
command.accountSequence,
|
||||
|
|
@ -274,6 +277,9 @@ export class AuthorizationApplicationService {
|
|||
* - 同一个省份只允许授权给一个账户(按省份唯一)
|
||||
*/
|
||||
async grantProvinceCompany(command: GrantProvinceCompanyCommand): Promise<void> {
|
||||
// 0. 检查用户是否已认种(授权前置条件)
|
||||
await this.ensureUserHasPlanted(command.accountSequence)
|
||||
|
||||
const userId = UserId.create(command.userId, command.accountSequence)
|
||||
const adminId = AdminUserId.create(command.adminId, command.adminAccountSequence)
|
||||
|
||||
|
|
@ -329,6 +335,9 @@ export class AuthorizationApplicationService {
|
|||
* - 同一个城市只允许一个市区域角色被授权
|
||||
*/
|
||||
async grantCityCompany(command: GrantCityCompanyCommand): Promise<void> {
|
||||
// 0. 检查用户是否已认种(授权前置条件)
|
||||
await this.ensureUserHasPlanted(command.accountSequence)
|
||||
|
||||
const userId = UserId.create(command.userId, command.accountSequence)
|
||||
const adminId = AdminUserId.create(command.adminId, command.adminAccountSequence)
|
||||
|
||||
|
|
@ -382,6 +391,9 @@ export class AuthorizationApplicationService {
|
|||
* 需要验证团队内唯一性:同一推荐链上不能有重复的相同省份授权
|
||||
*/
|
||||
async grantAuthProvinceCompany(command: GrantAuthProvinceCompanyCommand): Promise<void> {
|
||||
// 0. 检查用户是否已认种(授权前置条件)
|
||||
await this.ensureUserHasPlanted(command.accountSequence)
|
||||
|
||||
const userId = UserId.create(command.userId, command.accountSequence)
|
||||
const adminId = AdminUserId.create(command.adminId, command.adminAccountSequence)
|
||||
const regionCode = RegionCode.create(command.provinceCode)
|
||||
|
|
@ -419,6 +431,9 @@ export class AuthorizationApplicationService {
|
|||
* 需要验证团队内唯一性:同一推荐链上不能有重复的相同城市授权
|
||||
*/
|
||||
async grantAuthCityCompany(command: GrantAuthCityCompanyCommand): Promise<void> {
|
||||
// 0. 检查用户是否已认种(授权前置条件)
|
||||
await this.ensureUserHasPlanted(command.accountSequence)
|
||||
|
||||
const userId = UserId.create(command.userId, command.accountSequence)
|
||||
const adminId = AdminUserId.create(command.adminId, command.adminAccountSequence)
|
||||
const regionCode = RegionCode.create(command.cityCode)
|
||||
|
|
@ -918,6 +933,25 @@ export class AuthorizationApplicationService {
|
|||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否已认种至少一棵树
|
||||
* 授权前置条件:用户必须先认种才能被授权任何角色
|
||||
*/
|
||||
private async ensureUserHasPlanted(accountSequence: string): Promise<void> {
|
||||
const teamStats = await this.referralServiceClient.findByAccountSequence(accountSequence)
|
||||
const selfPlantingCount = teamStats?.selfPlantingCount || 0
|
||||
|
||||
if (selfPlantingCount < 1) {
|
||||
throw new ApplicationError(
|
||||
`用户 ${accountSequence} 尚未认种任何树,无法授权。请先至少认种1棵树后再进行授权操作。`,
|
||||
)
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`[ensureUserHasPlanted] User ${accountSequence} has planted ${selfPlantingCount} tree(s), authorization allowed`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试激活授权权益
|
||||
* 仅当权益未激活时执行激活操作
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
import { WalletApplicationService } from '@/application/services';
|
||||
import { GetMyLedgerQuery } from '@/application/queries';
|
||||
import { CurrentUser, CurrentUserPayload } from '@/shared/decorators';
|
||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||
import { GetMyLedgerQueryDTO } from '@/api/dto/request';
|
||||
import { PaginatedLedgerResponseDTO } from '@/api/dto/response';
|
||||
import { PaginatedLedgerResponseDTO, LedgerStatisticsResponseDTO, LedgerTrendResponseDTO } from '@/api/dto/response';
|
||||
|
||||
@ApiTags('Ledger')
|
||||
@Controller('wallet/ledger')
|
||||
|
|
@ -32,4 +32,25 @@ export class LedgerController {
|
|||
);
|
||||
return this.walletService.getMyLedger(query);
|
||||
}
|
||||
|
||||
@Get('statistics')
|
||||
@ApiOperation({ summary: '获取流水统计', description: '按类型汇总用户的收支统计' })
|
||||
@ApiResponse({ status: 200, type: LedgerStatisticsResponseDTO })
|
||||
async getStatistics(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
): Promise<LedgerStatisticsResponseDTO> {
|
||||
return this.walletService.getLedgerStatistics(user.userId);
|
||||
}
|
||||
|
||||
@Get('trend')
|
||||
@ApiOperation({ summary: '获取流水趋势', description: '按日期统计用户的收支趋势' })
|
||||
@ApiQuery({ name: 'days', required: false, description: '统计天数(默认30天)' })
|
||||
@ApiResponse({ status: 200, type: LedgerTrendResponseDTO })
|
||||
async getTrend(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Query('days') days?: string,
|
||||
): Promise<LedgerTrendResponseDTO> {
|
||||
const numDays = days ? parseInt(days, 10) : 30;
|
||||
return this.walletService.getLedgerTrend(user.userId, numDays);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,3 +45,76 @@ export class PaginatedLedgerResponseDTO {
|
|||
@ApiProperty({ description: '总页数' })
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// ============ 统计相关 DTO ============
|
||||
|
||||
export class EntryTypeSummaryDTO {
|
||||
@ApiProperty({ description: '流水类型' })
|
||||
entryType: string;
|
||||
|
||||
@ApiProperty({ description: '流水类型中文名' })
|
||||
entryTypeName: string;
|
||||
|
||||
@ApiProperty({ description: '总金额' })
|
||||
totalAmount: number;
|
||||
|
||||
@ApiProperty({ description: '笔数' })
|
||||
count: number;
|
||||
}
|
||||
|
||||
export class LedgerStatisticsResponseDTO {
|
||||
@ApiProperty({ description: '总收入' })
|
||||
totalIncome: number;
|
||||
|
||||
@ApiProperty({ description: '总支出' })
|
||||
totalExpense: number;
|
||||
|
||||
@ApiProperty({ description: '净收益' })
|
||||
netAmount: number;
|
||||
|
||||
@ApiProperty({ description: '总笔数' })
|
||||
totalCount: number;
|
||||
|
||||
@ApiProperty({ type: [EntryTypeSummaryDTO], description: '按类型汇总' })
|
||||
byEntryType: EntryTypeSummaryDTO[];
|
||||
|
||||
@ApiProperty({ description: '统计时间范围开始' })
|
||||
startDate: string;
|
||||
|
||||
@ApiProperty({ description: '统计时间范围结束' })
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
export class DailyTrendItemDTO {
|
||||
@ApiProperty({ description: '日期' })
|
||||
date: string;
|
||||
|
||||
@ApiProperty({ description: '收入' })
|
||||
income: number;
|
||||
|
||||
@ApiProperty({ description: '支出' })
|
||||
expense: number;
|
||||
|
||||
@ApiProperty({ description: '净收益' })
|
||||
net: number;
|
||||
|
||||
@ApiProperty({ description: '笔数' })
|
||||
count: number;
|
||||
}
|
||||
|
||||
export class LedgerTrendResponseDTO {
|
||||
@ApiProperty({ type: [DailyTrendItemDTO], description: '每日趋势' })
|
||||
dailyTrend: DailyTrendItemDTO[];
|
||||
|
||||
@ApiProperty({ description: '统计天数' })
|
||||
days: number;
|
||||
|
||||
@ApiProperty({ description: '期间总收入' })
|
||||
periodIncome: number;
|
||||
|
||||
@ApiProperty({ description: '期间总支出' })
|
||||
periodExpense: number;
|
||||
|
||||
@ApiProperty({ description: '期间净收益' })
|
||||
periodNet: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1844,4 +1844,189 @@ export class WalletApplicationService {
|
|||
transferredToHeadquarters: totalExpiredUsdt,
|
||||
};
|
||||
}
|
||||
|
||||
// =============== Ledger Statistics ===============
|
||||
|
||||
/**
|
||||
* 获取流水统计信息
|
||||
*/
|
||||
async getLedgerStatistics(userId: string): Promise<{
|
||||
totalIncome: number;
|
||||
totalExpense: number;
|
||||
netAmount: number;
|
||||
totalCount: number;
|
||||
byEntryType: Array<{
|
||||
entryType: string;
|
||||
entryTypeName: string;
|
||||
totalAmount: number;
|
||||
count: number;
|
||||
}>;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}> {
|
||||
const userIdBigInt = BigInt(userId);
|
||||
|
||||
// 获取所有流水
|
||||
const entries = await this.prisma.ledgerEntry.findMany({
|
||||
where: { userId: userIdBigInt, assetType: 'USDT' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
if (entries.length === 0) {
|
||||
return {
|
||||
totalIncome: 0,
|
||||
totalExpense: 0,
|
||||
netAmount: 0,
|
||||
totalCount: 0,
|
||||
byEntryType: [],
|
||||
startDate: new Date().toISOString(),
|
||||
endDate: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// 计算收支
|
||||
let totalIncome = 0;
|
||||
let totalExpense = 0;
|
||||
const byTypeMap = new Map<string, { totalAmount: number; count: number }>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const amount = Number(entry.amount);
|
||||
if (amount > 0) {
|
||||
totalIncome += amount;
|
||||
} else {
|
||||
totalExpense += Math.abs(amount);
|
||||
}
|
||||
|
||||
// 按类型汇总
|
||||
const existing = byTypeMap.get(entry.entryType) || { totalAmount: 0, count: 0 };
|
||||
existing.totalAmount += amount;
|
||||
existing.count += 1;
|
||||
byTypeMap.set(entry.entryType, existing);
|
||||
}
|
||||
|
||||
// 转换为数组
|
||||
const byEntryType = Array.from(byTypeMap.entries()).map(([entryType, data]) => ({
|
||||
entryType,
|
||||
entryTypeName: this.getEntryTypeName(entryType),
|
||||
totalAmount: data.totalAmount,
|
||||
count: data.count,
|
||||
}));
|
||||
|
||||
return {
|
||||
totalIncome,
|
||||
totalExpense,
|
||||
netAmount: totalIncome - totalExpense,
|
||||
totalCount: entries.length,
|
||||
byEntryType,
|
||||
startDate: entries[0].createdAt.toISOString(),
|
||||
endDate: entries[entries.length - 1].createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取流水趋势
|
||||
*/
|
||||
async getLedgerTrend(userId: string, days: number = 30): Promise<{
|
||||
dailyTrend: Array<{
|
||||
date: string;
|
||||
income: number;
|
||||
expense: number;
|
||||
net: number;
|
||||
count: number;
|
||||
}>;
|
||||
days: number;
|
||||
periodIncome: number;
|
||||
periodExpense: number;
|
||||
periodNet: number;
|
||||
}> {
|
||||
const userIdBigInt = BigInt(userId);
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - days);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const entries = await this.prisma.ledgerEntry.findMany({
|
||||
where: {
|
||||
userId: userIdBigInt,
|
||||
assetType: 'USDT',
|
||||
createdAt: { gte: startDate },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
// 按日期分组
|
||||
const dailyMap = new Map<string, { income: number; expense: number; count: number }>();
|
||||
|
||||
// 初始化所有日期
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = new Date(startDate);
|
||||
date.setDate(date.getDate() + i);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
dailyMap.set(dateStr, { income: 0, expense: 0, count: 0 });
|
||||
}
|
||||
|
||||
// 填充数据
|
||||
let periodIncome = 0;
|
||||
let periodExpense = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const dateStr = entry.createdAt.toISOString().split('T')[0];
|
||||
const amount = Number(entry.amount);
|
||||
const existing = dailyMap.get(dateStr) || { income: 0, expense: 0, count: 0 };
|
||||
|
||||
if (amount > 0) {
|
||||
existing.income += amount;
|
||||
periodIncome += amount;
|
||||
} else {
|
||||
existing.expense += Math.abs(amount);
|
||||
periodExpense += Math.abs(amount);
|
||||
}
|
||||
existing.count += 1;
|
||||
dailyMap.set(dateStr, existing);
|
||||
}
|
||||
|
||||
// 转换为数组
|
||||
const dailyTrend = Array.from(dailyMap.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, data]) => ({
|
||||
date,
|
||||
income: data.income,
|
||||
expense: data.expense,
|
||||
net: data.income - data.expense,
|
||||
count: data.count,
|
||||
}));
|
||||
|
||||
return {
|
||||
dailyTrend,
|
||||
days,
|
||||
periodIncome,
|
||||
periodExpense,
|
||||
periodNet: periodIncome - periodExpense,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取流水类型中文名
|
||||
*/
|
||||
private getEntryTypeName(entryType: string): string {
|
||||
const nameMap: Record<string, string> = {
|
||||
DEPOSIT_KAVA: '充值 (KAVA)',
|
||||
DEPOSIT_BSC: '充值 (BSC)',
|
||||
PLANT_PAYMENT: '认种支付',
|
||||
PLANT_FREEZE: '认种冻结',
|
||||
PLANT_UNFREEZE: '认种解冻',
|
||||
REWARD_PENDING: '待领取奖励',
|
||||
REWARD_TO_SETTLEABLE: '奖励转可结算',
|
||||
REWARD_EXPIRED: '奖励过期',
|
||||
REWARD_SETTLED: '奖励结算',
|
||||
TRANSFER_TO_POOL: '转入矿池',
|
||||
SWAP_EXECUTED: '兑换执行',
|
||||
WITHDRAWAL: '提现',
|
||||
TRANSFER_IN: '转入',
|
||||
TRANSFER_OUT: '转出',
|
||||
FREEZE: '冻结',
|
||||
UNFREEZE: '解冻',
|
||||
SYSTEM_ALLOCATION: '系统分配',
|
||||
};
|
||||
return nameMap[entryType] || entryType;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -375,6 +375,116 @@ class WalletService {
|
|||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// =============== 账本流水相关 API ===============
|
||||
|
||||
/// 获取账本流水列表
|
||||
///
|
||||
/// 调用 GET /wallet/ledger/my-ledger (wallet-service)
|
||||
Future<PaginatedLedger> getLedger({
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
String? entryType,
|
||||
String? assetType,
|
||||
DateTime? startDate,
|
||||
DateTime? endDate,
|
||||
}) async {
|
||||
try {
|
||||
debugPrint('[WalletService] ========== 获取账本流水 ==========');
|
||||
debugPrint('[WalletService] 请求: GET /wallet/ledger/my-ledger');
|
||||
debugPrint('[WalletService] 参数: page=$page, pageSize=$pageSize, entryType=$entryType');
|
||||
|
||||
final queryParams = <String, String>{
|
||||
'page': page.toString(),
|
||||
'pageSize': pageSize.toString(),
|
||||
};
|
||||
if (entryType != null) queryParams['entryType'] = entryType;
|
||||
if (assetType != null) queryParams['assetType'] = assetType;
|
||||
if (startDate != null) queryParams['startDate'] = startDate.toIso8601String();
|
||||
if (endDate != null) queryParams['endDate'] = endDate.toIso8601String();
|
||||
|
||||
final queryString = queryParams.entries.map((e) => '${e.key}=${e.value}').join('&');
|
||||
final response = await _apiClient.get('/wallet/ledger/my-ledger?$queryString');
|
||||
|
||||
debugPrint('[WalletService] 响应状态码: ${response.statusCode}');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final responseData = response.data as Map<String, dynamic>;
|
||||
final data = responseData['data'] as Map<String, dynamic>? ?? responseData;
|
||||
final result = PaginatedLedger.fromJson(data);
|
||||
debugPrint('[WalletService] 获取成功: ${result.data.length} 条流水, 共 ${result.total} 条');
|
||||
debugPrint('[WalletService] ================================');
|
||||
return result;
|
||||
}
|
||||
|
||||
throw Exception('获取账本流水失败: ${response.statusCode}');
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('[WalletService] !!!!!!!!!! 获取账本流水异常 !!!!!!!!!!');
|
||||
debugPrint('[WalletService] 错误: $e');
|
||||
debugPrint('[WalletService] 堆栈: $stackTrace');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取流水统计
|
||||
///
|
||||
/// 调用 GET /wallet/ledger/statistics (wallet-service)
|
||||
Future<LedgerStatistics> getLedgerStatistics() async {
|
||||
try {
|
||||
debugPrint('[WalletService] ========== 获取流水统计 ==========');
|
||||
debugPrint('[WalletService] 请求: GET /wallet/ledger/statistics');
|
||||
|
||||
final response = await _apiClient.get('/wallet/ledger/statistics');
|
||||
|
||||
debugPrint('[WalletService] 响应状态码: ${response.statusCode}');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final responseData = response.data as Map<String, dynamic>;
|
||||
final data = responseData['data'] as Map<String, dynamic>? ?? responseData;
|
||||
final result = LedgerStatistics.fromJson(data);
|
||||
debugPrint('[WalletService] 获取成功: 总收入=${result.totalIncome}, 总支出=${result.totalExpense}');
|
||||
debugPrint('[WalletService] ================================');
|
||||
return result;
|
||||
}
|
||||
|
||||
throw Exception('获取流水统计失败: ${response.statusCode}');
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('[WalletService] !!!!!!!!!! 获取流水统计异常 !!!!!!!!!!');
|
||||
debugPrint('[WalletService] 错误: $e');
|
||||
debugPrint('[WalletService] 堆栈: $stackTrace');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取流水趋势
|
||||
///
|
||||
/// 调用 GET /wallet/ledger/trend (wallet-service)
|
||||
Future<LedgerTrend> getLedgerTrend({int days = 30}) async {
|
||||
try {
|
||||
debugPrint('[WalletService] ========== 获取流水趋势 ==========');
|
||||
debugPrint('[WalletService] 请求: GET /wallet/ledger/trend?days=$days');
|
||||
|
||||
final response = await _apiClient.get('/wallet/ledger/trend?days=$days');
|
||||
|
||||
debugPrint('[WalletService] 响应状态码: ${response.statusCode}');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final responseData = response.data as Map<String, dynamic>;
|
||||
final data = responseData['data'] as Map<String, dynamic>? ?? responseData;
|
||||
final result = LedgerTrend.fromJson(data);
|
||||
debugPrint('[WalletService] 获取成功: ${result.dailyTrend.length} 天数据');
|
||||
debugPrint('[WalletService] ================================');
|
||||
return result;
|
||||
}
|
||||
|
||||
throw Exception('获取流水趋势失败: ${response.statusCode}');
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('[WalletService] !!!!!!!!!! 获取流水趋势异常 !!!!!!!!!!');
|
||||
debugPrint('[WalletService] 错误: $e');
|
||||
debugPrint('[WalletService] 堆栈: $stackTrace');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 提取响应
|
||||
|
|
@ -460,3 +570,214 @@ class WithdrawRecord {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============== 账本流水相关模型 ===============
|
||||
|
||||
/// 账本流水条目
|
||||
class LedgerEntry {
|
||||
final String id;
|
||||
final String entryType;
|
||||
final double amount;
|
||||
final String assetType;
|
||||
final double? balanceAfter;
|
||||
final String? refOrderId;
|
||||
final String? refTxHash;
|
||||
final String? memo;
|
||||
final DateTime createdAt;
|
||||
|
||||
LedgerEntry({
|
||||
required this.id,
|
||||
required this.entryType,
|
||||
required this.amount,
|
||||
required this.assetType,
|
||||
this.balanceAfter,
|
||||
this.refOrderId,
|
||||
this.refTxHash,
|
||||
this.memo,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory LedgerEntry.fromJson(Map<String, dynamic> json) {
|
||||
return LedgerEntry(
|
||||
id: json['id']?.toString() ?? '',
|
||||
entryType: json['entryType'] ?? '',
|
||||
amount: (json['amount'] ?? 0).toDouble(),
|
||||
assetType: json['assetType'] ?? 'USDT',
|
||||
balanceAfter: json['balanceAfter']?.toDouble(),
|
||||
refOrderId: json['refOrderId'],
|
||||
refTxHash: json['refTxHash'],
|
||||
memo: json['memo'],
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.tryParse(json['createdAt']) ?? DateTime.now()
|
||||
: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取流水类型中文名
|
||||
String get entryTypeName {
|
||||
const nameMap = {
|
||||
'DEPOSIT_KAVA': '充值 (KAVA)',
|
||||
'DEPOSIT_BSC': '充值 (BSC)',
|
||||
'PLANT_PAYMENT': '认种支付',
|
||||
'PLANT_FREEZE': '认种冻结',
|
||||
'PLANT_UNFREEZE': '认种解冻',
|
||||
'REWARD_PENDING': '待领取奖励',
|
||||
'REWARD_TO_SETTLEABLE': '奖励转可结算',
|
||||
'REWARD_EXPIRED': '奖励过期',
|
||||
'REWARD_SETTLED': '奖励结算',
|
||||
'TRANSFER_TO_POOL': '转入矿池',
|
||||
'SWAP_EXECUTED': '兑换执行',
|
||||
'WITHDRAWAL': '提现',
|
||||
'TRANSFER_IN': '转入',
|
||||
'TRANSFER_OUT': '转出',
|
||||
'FREEZE': '冻结',
|
||||
'UNFREEZE': '解冻',
|
||||
'SYSTEM_ALLOCATION': '系统分配',
|
||||
};
|
||||
return nameMap[entryType] ?? entryType;
|
||||
}
|
||||
|
||||
/// 是否为收入
|
||||
bool get isIncome => amount > 0;
|
||||
}
|
||||
|
||||
/// 分页的账本流水响应
|
||||
class PaginatedLedger {
|
||||
final List<LedgerEntry> data;
|
||||
final int total;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
final int totalPages;
|
||||
|
||||
PaginatedLedger({
|
||||
required this.data,
|
||||
required this.total,
|
||||
required this.page,
|
||||
required this.pageSize,
|
||||
required this.totalPages,
|
||||
});
|
||||
|
||||
factory PaginatedLedger.fromJson(Map<String, dynamic> json) {
|
||||
final dataList = json['data'] as List<dynamic>? ?? [];
|
||||
return PaginatedLedger(
|
||||
data: dataList.map((e) => LedgerEntry.fromJson(e as Map<String, dynamic>)).toList(),
|
||||
total: json['total'] ?? 0,
|
||||
page: json['page'] ?? 1,
|
||||
pageSize: json['pageSize'] ?? 20,
|
||||
totalPages: json['totalPages'] ?? 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 按类型汇总的统计项
|
||||
class EntryTypeSummary {
|
||||
final String entryType;
|
||||
final String entryTypeName;
|
||||
final double totalAmount;
|
||||
final int count;
|
||||
|
||||
EntryTypeSummary({
|
||||
required this.entryType,
|
||||
required this.entryTypeName,
|
||||
required this.totalAmount,
|
||||
required this.count,
|
||||
});
|
||||
|
||||
factory EntryTypeSummary.fromJson(Map<String, dynamic> json) {
|
||||
return EntryTypeSummary(
|
||||
entryType: json['entryType'] ?? '',
|
||||
entryTypeName: json['entryTypeName'] ?? '',
|
||||
totalAmount: (json['totalAmount'] ?? 0).toDouble(),
|
||||
count: json['count'] ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 流水统计
|
||||
class LedgerStatistics {
|
||||
final double totalIncome;
|
||||
final double totalExpense;
|
||||
final double netAmount;
|
||||
final int totalCount;
|
||||
final List<EntryTypeSummary> byEntryType;
|
||||
final DateTime startDate;
|
||||
final DateTime endDate;
|
||||
|
||||
LedgerStatistics({
|
||||
required this.totalIncome,
|
||||
required this.totalExpense,
|
||||
required this.netAmount,
|
||||
required this.totalCount,
|
||||
required this.byEntryType,
|
||||
required this.startDate,
|
||||
required this.endDate,
|
||||
});
|
||||
|
||||
factory LedgerStatistics.fromJson(Map<String, dynamic> json) {
|
||||
final byTypeList = json['byEntryType'] as List<dynamic>? ?? [];
|
||||
return LedgerStatistics(
|
||||
totalIncome: (json['totalIncome'] ?? 0).toDouble(),
|
||||
totalExpense: (json['totalExpense'] ?? 0).toDouble(),
|
||||
netAmount: (json['netAmount'] ?? 0).toDouble(),
|
||||
totalCount: json['totalCount'] ?? 0,
|
||||
byEntryType: byTypeList.map((e) => EntryTypeSummary.fromJson(e as Map<String, dynamic>)).toList(),
|
||||
startDate: DateTime.tryParse(json['startDate'] ?? '') ?? DateTime.now(),
|
||||
endDate: DateTime.tryParse(json['endDate'] ?? '') ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 每日趋势项
|
||||
class DailyTrendItem {
|
||||
final String date;
|
||||
final double income;
|
||||
final double expense;
|
||||
final double net;
|
||||
final int count;
|
||||
|
||||
DailyTrendItem({
|
||||
required this.date,
|
||||
required this.income,
|
||||
required this.expense,
|
||||
required this.net,
|
||||
required this.count,
|
||||
});
|
||||
|
||||
factory DailyTrendItem.fromJson(Map<String, dynamic> json) {
|
||||
return DailyTrendItem(
|
||||
date: json['date'] ?? '',
|
||||
income: (json['income'] ?? 0).toDouble(),
|
||||
expense: (json['expense'] ?? 0).toDouble(),
|
||||
net: (json['net'] ?? 0).toDouble(),
|
||||
count: json['count'] ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 流水趋势
|
||||
class LedgerTrend {
|
||||
final List<DailyTrendItem> dailyTrend;
|
||||
final int days;
|
||||
final double periodIncome;
|
||||
final double periodExpense;
|
||||
final double periodNet;
|
||||
|
||||
LedgerTrend({
|
||||
required this.dailyTrend,
|
||||
required this.days,
|
||||
required this.periodIncome,
|
||||
required this.periodExpense,
|
||||
required this.periodNet,
|
||||
});
|
||||
|
||||
factory LedgerTrend.fromJson(Map<String, dynamic> json) {
|
||||
final trendList = json['dailyTrend'] as List<dynamic>? ?? [];
|
||||
return LedgerTrend(
|
||||
dailyTrend: trendList.map((e) => DailyTrendItem.fromJson(e as Map<String, dynamic>)).toList(),
|
||||
days: json['days'] ?? 30,
|
||||
periodIncome: (json['periodIncome'] ?? 0).toDouble(),
|
||||
periodExpense: (json['periodExpense'] ?? 0).toDouble(),
|
||||
periodNet: (json['periodNet'] ?? 0).toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,983 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
import '../../../../core/services/wallet_service.dart';
|
||||
|
||||
/// 账本明细页面 - 显示用户的流水账、统计图表和筛选功能
|
||||
class LedgerDetailPage extends ConsumerStatefulWidget {
|
||||
const LedgerDetailPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LedgerDetailPage> createState() => _LedgerDetailPageState();
|
||||
}
|
||||
|
||||
class _LedgerDetailPageState extends ConsumerState<LedgerDetailPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
// 数据状态
|
||||
bool _isLoading = true;
|
||||
LedgerStatistics? _statistics;
|
||||
LedgerTrend? _trend;
|
||||
PaginatedLedger? _ledger;
|
||||
String? _errorMessage;
|
||||
|
||||
// 筛选条件
|
||||
String? _selectedEntryType;
|
||||
int _currentPage = 1;
|
||||
static const int _pageSize = 20;
|
||||
|
||||
// 流水类型选项
|
||||
final List<Map<String, String>> _entryTypes = [
|
||||
{'value': '', 'label': '全部'},
|
||||
{'value': 'DEPOSIT_KAVA', 'label': '充值 (KAVA)'},
|
||||
{'value': 'DEPOSIT_BSC', 'label': '充值 (BSC)'},
|
||||
{'value': 'PLANT_PAYMENT', 'label': '认种支付'},
|
||||
{'value': 'REWARD_TO_SETTLEABLE', 'label': '奖励转可结算'},
|
||||
{'value': 'REWARD_EXPIRED', 'label': '奖励过期'},
|
||||
{'value': 'WITHDRAWAL', 'label': '提现'},
|
||||
{'value': 'TRANSFER_IN', 'label': '转入'},
|
||||
{'value': 'TRANSFER_OUT', 'label': '转出'},
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 加载所有数据
|
||||
Future<void> _loadData() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final walletService = ref.read(walletServiceProvider);
|
||||
|
||||
// 并行加载统计和趋势
|
||||
final results = await Future.wait([
|
||||
walletService.getLedgerStatistics(),
|
||||
walletService.getLedgerTrend(days: 30),
|
||||
walletService.getLedger(
|
||||
page: _currentPage,
|
||||
pageSize: _pageSize,
|
||||
entryType: _selectedEntryType,
|
||||
),
|
||||
]);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statistics = results[0] as LedgerStatistics;
|
||||
_trend = results[1] as LedgerTrend;
|
||||
_ledger = results[2] as PaginatedLedger;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[LedgerDetailPage] 加载数据失败: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = '加载失败: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载更多流水
|
||||
Future<void> _loadMoreLedger() async {
|
||||
if (_ledger == null || _currentPage >= _ledger!.totalPages) return;
|
||||
|
||||
try {
|
||||
final walletService = ref.read(walletServiceProvider);
|
||||
final nextPage = _currentPage + 1;
|
||||
final newLedger = await walletService.getLedger(
|
||||
page: nextPage,
|
||||
pageSize: _pageSize,
|
||||
entryType: _selectedEntryType,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentPage = nextPage;
|
||||
_ledger = PaginatedLedger(
|
||||
data: [..._ledger!.data, ...newLedger.data],
|
||||
total: newLedger.total,
|
||||
page: nextPage,
|
||||
pageSize: _pageSize,
|
||||
totalPages: newLedger.totalPages,
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[LedgerDetailPage] 加载更多失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 筛选流水类型
|
||||
Future<void> _filterByEntryType(String? entryType) async {
|
||||
setState(() {
|
||||
_selectedEntryType = entryType?.isEmpty == true ? null : entryType;
|
||||
_currentPage = 1;
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final walletService = ref.read(walletServiceProvider);
|
||||
final newLedger = await walletService.getLedger(
|
||||
page: 1,
|
||||
pageSize: _pageSize,
|
||||
entryType: _selectedEntryType,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_ledger = newLedger;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[LedgerDetailPage] 筛选失败: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式化金额
|
||||
String _formatAmount(double amount) {
|
||||
final formatter = NumberFormat('#,##0.00', 'zh_CN');
|
||||
return formatter.format(amount);
|
||||
}
|
||||
|
||||
/// 格式化日期
|
||||
String _formatDate(DateTime date) {
|
||||
return DateFormat('yyyy-MM-dd HH:mm').format(date);
|
||||
}
|
||||
|
||||
/// 格式化短日期
|
||||
String _formatShortDate(String date) {
|
||||
try {
|
||||
final parsed = DateTime.parse(date);
|
||||
return DateFormat('MM/dd').format(parsed);
|
||||
} catch (e) {
|
||||
return date;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFFFFF5E6),
|
||||
Color(0xFFFFE4B5),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶部标题栏
|
||||
_buildAppBar(),
|
||||
// Tab 切换
|
||||
_buildTabBar(),
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFFD4AF37)),
|
||||
),
|
||||
)
|
||||
: _errorMessage != null
|
||||
? _buildErrorView()
|
||||
: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildStatisticsTab(),
|
||||
_buildLedgerListTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建顶部标题栏
|
||||
Widget _buildAppBar() {
|
||||
return Container(
|
||||
height: 56,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: const Icon(
|
||||
Icons.arrow_back_ios,
|
||||
color: Color(0xFF5D4037),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'账本明细',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// 刷新按钮
|
||||
GestureDetector(
|
||||
onTap: _loadData,
|
||||
child: const Icon(
|
||||
Icons.refresh,
|
||||
color: Color(0xFF5D4037),
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建 Tab 切换栏
|
||||
Widget _buildTabBar() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x1A8B5A2B),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
indicator: BoxDecoration(
|
||||
color: const Color(0xFFD4AF37),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
labelColor: Colors.white,
|
||||
unselectedLabelColor: const Color(0xFF5D4037),
|
||||
labelStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
tabs: const [
|
||||
Tab(text: '统计概览'),
|
||||
Tab(text: '流水明细'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建错误视图
|
||||
Widget _buildErrorView() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: Color(0xFF8B5A2B),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage ?? '加载失败',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF5D4037),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadData,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFD4AF37),
|
||||
),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建统计概览 Tab
|
||||
Widget _buildStatisticsTab() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 收支概览卡片
|
||||
_buildSummaryCard(),
|
||||
const SizedBox(height: 16),
|
||||
// 趋势图表
|
||||
_buildTrendChart(),
|
||||
const SizedBox(height: 16),
|
||||
// 按类型统计
|
||||
_buildTypeBreakdown(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建收支概览卡片
|
||||
Widget _buildSummaryCard() {
|
||||
final stats = _statistics;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'收支概览',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildSummaryItem(
|
||||
'总收入',
|
||||
stats?.totalIncome ?? 0,
|
||||
const Color(0xFF4CAF50),
|
||||
Icons.arrow_downward,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 60,
|
||||
color: const Color(0x1A8B5A2B),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildSummaryItem(
|
||||
'总支出',
|
||||
stats?.totalExpense ?? 0,
|
||||
const Color(0xFFE53935),
|
||||
Icons.arrow_upward,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x1AD4AF37),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'净收益',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${(stats?.netAmount ?? 0) >= 0 ? '+' : ''}${_formatAmount(stats?.netAmount ?? 0)} 绿积分',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: (stats?.netAmount ?? 0) >= 0
|
||||
? const Color(0xFF4CAF50)
|
||||
: const Color(0xFFE53935),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'共 ${stats?.totalCount ?? 0} 笔交易',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0x995D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建收支概览项
|
||||
Widget _buildSummaryItem(String label, double amount, Color color, IconData icon) {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0x995D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_formatAmount(amount),
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'绿积分',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Color(0x995D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建趋势图表
|
||||
Widget _buildTrendChart() {
|
||||
final trend = _trend;
|
||||
if (trend == null || trend.dailyTrend.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// 找出最大值用于计算比例
|
||||
double maxValue = 1;
|
||||
for (final item in trend.dailyTrend) {
|
||||
if (item.income > maxValue) maxValue = item.income;
|
||||
if (item.expense > maxValue) maxValue = item.expense;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'近30天趋势',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
_buildLegendItem('收入', const Color(0xFF4CAF50)),
|
||||
const SizedBox(width: 12),
|
||||
_buildLegendItem('支出', const Color(0xFFE53935)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// 简化的柱状图
|
||||
SizedBox(
|
||||
height: 120,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// 只显示最近7天
|
||||
...trend.dailyTrend.reversed.take(7).toList().reversed.map((item) {
|
||||
final incomeHeight = (item.income / maxValue) * 100;
|
||||
final expenseHeight = (item.expense / maxValue) * 100;
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: incomeHeight.clamp(2, 100),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF4CAF50),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Container(
|
||||
width: 8,
|
||||
height: expenseHeight.clamp(2, 100),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE53935),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_formatShortDate(item.date),
|
||||
style: const TextStyle(
|
||||
fontSize: 8,
|
||||
color: Color(0x995D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 期间汇总
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildPeriodSummary('期间收入', trend.periodIncome, const Color(0xFF4CAF50)),
|
||||
_buildPeriodSummary('期间支出', trend.periodExpense, const Color(0xFFE53935)),
|
||||
_buildPeriodSummary('期间净收益', trend.periodNet,
|
||||
trend.periodNet >= 0 ? const Color(0xFF4CAF50) : const Color(0xFFE53935)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建图例项
|
||||
Widget _buildLegendItem(String label, Color color) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Color(0x995D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建期间汇总项
|
||||
Widget _buildPeriodSummary(String label, double amount, Color color) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Color(0x995D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_formatAmount(amount),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建按类型统计
|
||||
Widget _buildTypeBreakdown() {
|
||||
final stats = _statistics;
|
||||
if (stats == null || stats.byEntryType.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// 按金额绝对值排序
|
||||
final sortedTypes = List<EntryTypeSummary>.from(stats.byEntryType)
|
||||
..sort((a, b) => b.totalAmount.abs().compareTo(a.totalAmount.abs()));
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'按类型统计',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...sortedTypes.take(8).map((item) => _buildTypeItem(item)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建类型统计项
|
||||
Widget _buildTypeItem(EntryTypeSummary item) {
|
||||
final isIncome = item.totalAmount > 0;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: isIncome
|
||||
? const Color(0x1A4CAF50)
|
||||
: const Color(0x1AE53935),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
isIncome ? Icons.arrow_downward : Icons.arrow_upward,
|
||||
size: 16,
|
||||
color: isIncome ? const Color(0xFF4CAF50) : const Color(0xFFE53935),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.entryTypeName,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${item.count} 笔',
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Color(0x995D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${isIncome ? '+' : ''}${_formatAmount(item.totalAmount)}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isIncome ? const Color(0xFF4CAF50) : const Color(0xFFE53935),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建流水明细 Tab
|
||||
Widget _buildLedgerListTab() {
|
||||
return Column(
|
||||
children: [
|
||||
// 筛选栏
|
||||
_buildFilterBar(),
|
||||
// 流水列表
|
||||
Expanded(
|
||||
child: _ledger == null || _ledger!.data.isEmpty
|
||||
? _buildEmptyView()
|
||||
: _buildLedgerList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建筛选栏
|
||||
Widget _buildFilterBar() {
|
||||
return Container(
|
||||
height: 44,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: _entryTypes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final type = _entryTypes[index];
|
||||
final isSelected = (_selectedEntryType ?? '') == type['value'];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: GestureDetector(
|
||||
onTap: () => _filterByEntryType(type['value']),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFFD4AF37) : const Color(0x1A8B5A2B),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
type['label']!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isSelected ? Colors.white : const Color(0xFF5D4037),
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建空视图
|
||||
Widget _buildEmptyView() {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.receipt_long_outlined,
|
||||
size: 64,
|
||||
color: Color(0x808B5A2B),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'暂无流水记录',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0x995D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建流水列表
|
||||
Widget _buildLedgerList() {
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
if (notification is ScrollEndNotification &&
|
||||
notification.metrics.extentAfter < 100) {
|
||||
_loadMoreLedger();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: _ledger!.data.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == _ledger!.data.length) {
|
||||
// 底部加载更多指示器
|
||||
if (_currentPage < _ledger!.totalPages) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFFD4AF37)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'共 ${_ledger!.total} 条记录',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0x995D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final entry = _ledger!.data[index];
|
||||
return _buildLedgerItem(entry);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建流水项
|
||||
Widget _buildLedgerItem(LedgerEntry entry) {
|
||||
final isIncome = entry.isIncome;
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.03),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 类型图标
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: isIncome
|
||||
? const Color(0x1A4CAF50)
|
||||
: const Color(0x1AE53935),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
isIncome ? Icons.arrow_downward : Icons.arrow_upward,
|
||||
size: 20,
|
||||
color: isIncome ? const Color(0xFF4CAF50) : const Color(0xFFE53935),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 类型和时间
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
entry.entryTypeName,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_formatDate(entry.createdAt),
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Color(0x995D4037),
|
||||
),
|
||||
),
|
||||
if (entry.memo != null && entry.memo!.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
entry.memo!,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Color(0x995D4037),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// 金额
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${isIncome ? '+' : ''}${_formatAmount(entry.amount)}',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isIncome ? const Color(0xFF4CAF50) : const Color(0xFFE53935),
|
||||
),
|
||||
),
|
||||
if (entry.balanceAfter != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'余额: ${_formatAmount(entry.balanceAfter!)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Color(0x995D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -281,18 +281,39 @@ class _TradingPageState extends ConsumerState<TradingPage> {
|
|||
return Container(
|
||||
height: 56,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'兑换',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.25,
|
||||
letterSpacing: -0.27,
|
||||
color: Color(0xFF5D4037),
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
const Text(
|
||||
'兑换',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.25,
|
||||
letterSpacing: -0.27,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// 账本明细入口
|
||||
GestureDetector(
|
||||
onTap: () => context.push(RoutePaths.ledgerDetail),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x1A8B5A2B),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.receipt_long_outlined,
|
||||
color: Color(0xFF5D4037),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import '../features/home/presentation/pages/home_shell_page.dart';
|
|||
import '../features/ranking/presentation/pages/ranking_page.dart';
|
||||
import '../features/mining/presentation/pages/mining_page.dart';
|
||||
import '../features/trading/presentation/pages/trading_page.dart';
|
||||
import '../features/trading/presentation/pages/ledger_detail_page.dart';
|
||||
import '../features/profile/presentation/pages/profile_page.dart';
|
||||
import '../features/profile/presentation/pages/edit_profile_page.dart';
|
||||
import '../features/share/presentation/pages/share_page.dart';
|
||||
|
|
@ -257,6 +258,13 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||
builder: (context, state) => const BindEmailPage(),
|
||||
),
|
||||
|
||||
// Ledger Detail Page (账本明细)
|
||||
GoRoute(
|
||||
path: RoutePaths.ledgerDetail,
|
||||
name: RouteNames.ledgerDetail,
|
||||
builder: (context, state) => const LedgerDetailPage(),
|
||||
),
|
||||
|
||||
// Withdraw USDT Page (USDT 提款)
|
||||
GoRoute(
|
||||
path: RoutePaths.withdrawUsdt,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ class RouteNames {
|
|||
static const changePassword = 'change-password';
|
||||
static const bindEmail = 'bind-email';
|
||||
static const transactionHistory = 'transaction-history';
|
||||
static const ledgerDetail = 'ledger-detail';
|
||||
static const withdrawUsdt = 'withdraw-usdt';
|
||||
static const withdrawConfirm = 'withdraw-confirm';
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ class RoutePaths {
|
|||
static const changePassword = '/security/password';
|
||||
static const bindEmail = '/security/email';
|
||||
static const transactionHistory = '/trading/history';
|
||||
static const ledgerDetail = '/trading/ledger';
|
||||
static const withdrawUsdt = '/withdraw/usdt';
|
||||
static const withdrawConfirm = '/withdraw/confirm';
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue