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:
hailin 2025-12-17 07:41:07 -08:00
parent cf27e55c45
commit 15e2bfe236
10 changed files with 1661 additions and 13 deletions

View File

@ -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`,
)
}
/**
*
*

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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(),
);
}
}

View File

@ -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),
),
),
],
],
),
],
),
);
}
}

View File

@ -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,
),
),
),
],
),
);
}

View File

@ -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,

View File

@ -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';

View File

@ -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';