fix(admin): correct distributed shares calculation to use 2M pool
The dashboard was incorrectly using 5 billion as the distribution pool default when calculating already distributed shares. The actual mining distribution pool is 2 million shares, not 100 billion. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
20a90fce4c
commit
64ccb8162a
|
|
@ -536,13 +536,14 @@ export class DashboardService {
|
|||
if (miningResponse.ok) {
|
||||
const miningResult = await miningResponse.json();
|
||||
const miningData = miningResult.data || miningResult;
|
||||
// 使用 remainingDistribution 计算已分配
|
||||
// 总量 50亿 - 剩余 = 已分配
|
||||
// 使用 distributionPool - remainingDistribution 计算已分配
|
||||
// 分配池是 200万(不是100亿),remainingDistribution 是剩余待分配量
|
||||
// 已分配 = 分配池 - 剩余
|
||||
const distributionPool = new Decimal(
|
||||
miningData.distributionPool || '5000000000',
|
||||
miningData.distributionPool || '2000000',
|
||||
);
|
||||
const remaining = new Decimal(
|
||||
miningData.remainingDistribution || '5000000000',
|
||||
miningData.remainingDistribution || '2000000',
|
||||
);
|
||||
totalDistributed = distributionPool.minus(remaining).toString();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import '../../data/datasources/remote/auth_remote_datasource.dart';
|
|||
import '../../data/datasources/remote/mining_remote_datasource.dart';
|
||||
import '../../data/datasources/remote/trading_remote_datasource.dart';
|
||||
import '../../data/datasources/remote/contribution_remote_datasource.dart';
|
||||
import '../../data/datasources/remote/referral_remote_datasource.dart';
|
||||
import '../../data/repositories/mining_repository_impl.dart';
|
||||
import '../../data/repositories/trading_repository_impl.dart';
|
||||
import '../../data/repositories/contribution_repository_impl.dart';
|
||||
|
|
@ -47,6 +48,11 @@ Future<void> configureDependencies() async {
|
|||
() => AuthRemoteDataSourceImpl(client: getIt<ApiClient>()),
|
||||
);
|
||||
|
||||
// Referral Data Source
|
||||
getIt.registerLazySingleton<ReferralRemoteDataSource>(
|
||||
() => ReferralRemoteDataSourceImpl(client: getIt<ApiClient>()),
|
||||
);
|
||||
|
||||
// Repositories
|
||||
getIt.registerLazySingleton<MiningRepository>(
|
||||
() => MiningRepositoryImpl(
|
||||
|
|
|
|||
|
|
@ -78,4 +78,10 @@ class ApiEndpoints {
|
|||
|
||||
// Mining Wallet Service 2.0 (Kong路由: /api/v2/mining-wallet)
|
||||
static const String sharePoolBalance = '/api/v2/mining-wallet/pool-accounts/share-pool-balance';
|
||||
|
||||
// Referral Service 2.0 (Kong路由: /api/v2/referral)
|
||||
static const String referralMe = '/api/v2/referral/me';
|
||||
static const String referralDirects = '/api/v2/referral/me/direct-referrals';
|
||||
static String userDirectReferrals(String accountSequence) =>
|
||||
'/api/v2/referral/user/$accountSequence/direct-referrals';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import '../../presentation/pages/asset/receive_shares_page.dart';
|
|||
import '../../presentation/pages/c2c/c2c_market_page.dart';
|
||||
import '../../presentation/pages/c2c/c2c_publish_page.dart';
|
||||
import '../../presentation/pages/c2c/c2c_order_detail_page.dart';
|
||||
import '../../presentation/pages/profile/team_page.dart';
|
||||
import '../../presentation/widgets/main_shell.dart';
|
||||
import '../../presentation/providers/user_providers.dart';
|
||||
import 'routes.dart';
|
||||
|
|
@ -145,6 +146,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||
return C2cOrderDetailPage(orderNo: orderNo);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.myTeam,
|
||||
builder: (context, state) => const TeamPage(),
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => MainShell(child: child),
|
||||
routes: [
|
||||
|
|
|
|||
|
|
@ -19,4 +19,6 @@ class Routes {
|
|||
static const String c2cMarket = '/c2c-market';
|
||||
static const String c2cPublish = '/c2c-publish';
|
||||
static const String c2cOrderDetail = '/c2c-order-detail';
|
||||
// 团队路由
|
||||
static const String myTeam = '/my-team';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/api_endpoints.dart';
|
||||
import '../../models/referral_model.dart';
|
||||
|
||||
abstract class ReferralRemoteDataSource {
|
||||
/// 获取当前用户推荐信息
|
||||
Future<ReferralInfoResponse> getMyReferralInfo();
|
||||
|
||||
/// 获取当前用户直推列表
|
||||
Future<DirectReferralsResponse> getDirectReferrals({
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
});
|
||||
|
||||
/// 获取指定用户的直推列表(用于伞下树懒加载)
|
||||
Future<DirectReferralsResponse> getUserDirectReferrals({
|
||||
required String accountSequence,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
});
|
||||
}
|
||||
|
||||
class ReferralRemoteDataSourceImpl implements ReferralRemoteDataSource {
|
||||
final ApiClient client;
|
||||
|
||||
ReferralRemoteDataSourceImpl({required this.client});
|
||||
|
||||
@override
|
||||
Future<ReferralInfoResponse> getMyReferralInfo() async {
|
||||
try {
|
||||
debugPrint('获取推荐信息...');
|
||||
final response = await client.get(ApiEndpoints.referralMe);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
debugPrint('推荐信息获取成功: directReferralCount=${data['directReferralCount']}');
|
||||
return ReferralInfoResponse.fromJson(data);
|
||||
}
|
||||
|
||||
throw Exception('获取推荐信息失败');
|
||||
} catch (e) {
|
||||
debugPrint('获取推荐信息失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DirectReferralsResponse> getDirectReferrals({
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
try {
|
||||
debugPrint('获取直推列表...');
|
||||
final response = await client.get(
|
||||
ApiEndpoints.referralDirects,
|
||||
queryParameters: {
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
debugPrint('直推列表获取成功: total=${data['total']}');
|
||||
return DirectReferralsResponse.fromJson(data);
|
||||
}
|
||||
|
||||
throw Exception('获取直推列表失败');
|
||||
} catch (e) {
|
||||
debugPrint('获取直推列表失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DirectReferralsResponse> getUserDirectReferrals({
|
||||
required String accountSequence,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
try {
|
||||
debugPrint('获取用户直推列表: accountSequence=$accountSequence');
|
||||
final response = await client.get(
|
||||
ApiEndpoints.userDirectReferrals(accountSequence),
|
||||
queryParameters: {
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
debugPrint('用户直推列表获取成功: total=${data['total']}');
|
||||
return DirectReferralsResponse.fromJson(data);
|
||||
}
|
||||
|
||||
throw Exception('获取用户直推列表失败');
|
||||
} catch (e) {
|
||||
debugPrint('获取用户直推列表失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
/// 推荐信息响应 (来自 referral-service)
|
||||
class ReferralInfoResponse {
|
||||
final String userId;
|
||||
final String referralCode;
|
||||
final String? referrerId;
|
||||
final int referralChainDepth;
|
||||
final int directReferralCount;
|
||||
final int totalTeamCount;
|
||||
final int personalPlantingCount;
|
||||
final int teamPlantingCount;
|
||||
final double leaderboardScore;
|
||||
final int? leaderboardRank;
|
||||
final DateTime createdAt;
|
||||
|
||||
ReferralInfoResponse({
|
||||
required this.userId,
|
||||
required this.referralCode,
|
||||
this.referrerId,
|
||||
required this.referralChainDepth,
|
||||
required this.directReferralCount,
|
||||
required this.totalTeamCount,
|
||||
required this.personalPlantingCount,
|
||||
required this.teamPlantingCount,
|
||||
required this.leaderboardScore,
|
||||
this.leaderboardRank,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory ReferralInfoResponse.fromJson(Map<String, dynamic> json) {
|
||||
return ReferralInfoResponse(
|
||||
userId: json['userId']?.toString() ?? '',
|
||||
referralCode: json['referralCode'] ?? '',
|
||||
referrerId: json['referrerId']?.toString(),
|
||||
referralChainDepth: json['referralChainDepth'] ?? 0,
|
||||
directReferralCount: json['directReferralCount'] ?? 0,
|
||||
totalTeamCount: json['totalTeamCount'] ?? 0,
|
||||
personalPlantingCount: json['personalPlantingCount'] ?? 0,
|
||||
teamPlantingCount: json['teamPlantingCount'] ?? 0,
|
||||
leaderboardScore: (json['leaderboardScore'] ?? 0).toDouble(),
|
||||
leaderboardRank: json['leaderboardRank'],
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.parse(json['createdAt'])
|
||||
: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 直推成员信息
|
||||
class DirectReferralInfo {
|
||||
final String userId;
|
||||
final String accountSequence; // 账户序列号(新格式: D + YYMMDD + 5位序号),用于显示
|
||||
final String referralCode;
|
||||
final int personalPlantingCount; // 个人认种量
|
||||
final int teamPlantingCount; // 团队认种量
|
||||
final int directReferralCount; // 直推人数(用于判断是否有下级)
|
||||
final DateTime joinedAt;
|
||||
|
||||
DirectReferralInfo({
|
||||
required this.userId,
|
||||
required this.accountSequence,
|
||||
required this.referralCode,
|
||||
required this.personalPlantingCount,
|
||||
required this.teamPlantingCount,
|
||||
required this.directReferralCount,
|
||||
required this.joinedAt,
|
||||
});
|
||||
|
||||
factory DirectReferralInfo.fromJson(Map<String, dynamic> json) {
|
||||
return DirectReferralInfo(
|
||||
userId: json['userId']?.toString() ?? '',
|
||||
accountSequence: json['accountSequence']?.toString() ?? '',
|
||||
referralCode: json['referralCode'] ?? '',
|
||||
personalPlantingCount: json['personalPlantingCount'] ?? 0,
|
||||
teamPlantingCount: json['teamPlantingCount'] ?? 0,
|
||||
directReferralCount: json['directReferralCount'] ?? 0,
|
||||
joinedAt: json['joinedAt'] != null
|
||||
? DateTime.parse(json['joinedAt'])
|
||||
: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 直推列表响应
|
||||
class DirectReferralsResponse {
|
||||
final List<DirectReferralInfo> referrals;
|
||||
final int total;
|
||||
final bool hasMore;
|
||||
|
||||
DirectReferralsResponse({
|
||||
required this.referrals,
|
||||
required this.total,
|
||||
required this.hasMore,
|
||||
});
|
||||
|
||||
factory DirectReferralsResponse.fromJson(Map<String, dynamic> json) {
|
||||
return DirectReferralsResponse(
|
||||
referrals: (json['referrals'] as List? ?? [])
|
||||
.map((e) => DirectReferralInfo.fromJson(e))
|
||||
.toList(),
|
||||
total: json['total'] ?? 0,
|
||||
hasMore: json['hasMore'] ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/di/injection.dart';
|
||||
import '../../../data/datasources/remote/referral_remote_datasource.dart';
|
||||
import '../../widgets/team_tree_widget.dart';
|
||||
import '../../providers/user_providers.dart';
|
||||
import '../../providers/profile_providers.dart';
|
||||
|
||||
class TeamPage extends ConsumerStatefulWidget {
|
||||
const TeamPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<TeamPage> createState() => _TeamPageState();
|
||||
}
|
||||
|
||||
class _TeamPageState extends ConsumerState<TeamPage> {
|
||||
// 设计色彩
|
||||
static const Color _orange = Color(0xFFFF6B00);
|
||||
static const Color _darkText = Color(0xFF1F2937);
|
||||
static const Color _grayText = Color(0xFF6B7280);
|
||||
static const Color _bgGray = Color(0xFFF3F4F6);
|
||||
|
||||
TeamTreeNode? _rootNode;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRootNode();
|
||||
}
|
||||
|
||||
Future<void> _loadRootNode() async {
|
||||
final user = ref.read(userNotifierProvider);
|
||||
final stats = ref.read(userStatsProvider).valueOrNull;
|
||||
|
||||
if (user.accountSequence == null) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_error = '用户信息未加载';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final dataSource = getIt<ReferralRemoteDataSource>();
|
||||
final referralInfo = await dataSource.getMyReferralInfo();
|
||||
|
||||
setState(() {
|
||||
_rootNode = TeamTreeNode.createRoot(
|
||||
accountSequence: user.accountSequence!,
|
||||
personalPlantingCount: referralInfo.personalPlantingCount,
|
||||
teamPlantingCount: referralInfo.teamPlantingCount,
|
||||
directReferralCount: referralInfo.directReferralCount,
|
||||
);
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_error = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: _bgGray,
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'我的团队',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _darkText,
|
||||
),
|
||||
),
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
iconTheme: const IconThemeData(color: _darkText),
|
||||
),
|
||||
body: _buildBody(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(_orange),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'加载失败',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
_loadRootNode();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _orange,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_rootNode == null) {
|
||||
return const Center(
|
||||
child: Text('暂无数据'),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 统计信息卡片
|
||||
_buildStatsCard(),
|
||||
// 说明文字
|
||||
_buildInstructions(),
|
||||
// 团队树
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: TeamTreeWidget(
|
||||
rootNode: _rootNode!,
|
||||
referralDataSource: getIt<ReferralRemoteDataSource>(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatsCard() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildStatItem(
|
||||
'个人认种',
|
||||
'${_rootNode?.personalPlantingCount ?? 0} 棵',
|
||||
Icons.eco,
|
||||
),
|
||||
_buildDivider(),
|
||||
_buildStatItem(
|
||||
'团队认种',
|
||||
'${_rootNode?.teamPlantingCount ?? 0} 棵',
|
||||
Icons.groups,
|
||||
),
|
||||
_buildDivider(),
|
||||
_buildStatItem(
|
||||
'直推人数',
|
||||
'${_rootNode?.directReferralCount ?? 0} 人',
|
||||
Icons.person_add,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatItem(String label, String value, IconData icon) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: _orange,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _darkText,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: _grayText,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDivider() {
|
||||
return Container(
|
||||
width: 1,
|
||||
height: 50,
|
||||
color: _bgGray,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInstructions() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: _orange.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: _orange,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'点击节点展开/收起下级成员,长按查看详情',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _grayText,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,550 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../data/models/referral_model.dart';
|
||||
import '../../data/datasources/remote/referral_remote_datasource.dart';
|
||||
|
||||
/// 伞下树节点数据模型
|
||||
class TeamTreeNode {
|
||||
final String userId;
|
||||
final String accountSequence;
|
||||
final int personalPlantingCount;
|
||||
final int teamPlantingCount;
|
||||
final int directReferralCount;
|
||||
List<TeamTreeNode>? children;
|
||||
bool isExpanded;
|
||||
bool isLoading;
|
||||
|
||||
TeamTreeNode({
|
||||
required this.userId,
|
||||
required this.accountSequence,
|
||||
required this.personalPlantingCount,
|
||||
required this.teamPlantingCount,
|
||||
required this.directReferralCount,
|
||||
this.children,
|
||||
this.isExpanded = false,
|
||||
this.isLoading = false,
|
||||
});
|
||||
|
||||
/// 是否有下级
|
||||
bool get hasChildren => directReferralCount > 0;
|
||||
|
||||
/// 从 DirectReferralInfo 创建
|
||||
factory TeamTreeNode.fromDirectReferralInfo(DirectReferralInfo info) {
|
||||
return TeamTreeNode(
|
||||
userId: info.userId,
|
||||
accountSequence: info.accountSequence,
|
||||
personalPlantingCount: info.personalPlantingCount,
|
||||
teamPlantingCount: info.teamPlantingCount,
|
||||
directReferralCount: info.directReferralCount,
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建根节点(自己)
|
||||
factory TeamTreeNode.createRoot({
|
||||
required String accountSequence,
|
||||
required int personalPlantingCount,
|
||||
required int teamPlantingCount,
|
||||
required int directReferralCount,
|
||||
}) {
|
||||
return TeamTreeNode(
|
||||
userId: '',
|
||||
accountSequence: accountSequence,
|
||||
personalPlantingCount: personalPlantingCount,
|
||||
teamPlantingCount: teamPlantingCount,
|
||||
directReferralCount: directReferralCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 伞下树组件
|
||||
class TeamTreeWidget extends StatefulWidget {
|
||||
final TeamTreeNode rootNode;
|
||||
final ReferralRemoteDataSource referralDataSource;
|
||||
|
||||
const TeamTreeWidget({
|
||||
super.key,
|
||||
required this.rootNode,
|
||||
required this.referralDataSource,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TeamTreeWidget> createState() => _TeamTreeWidgetState();
|
||||
}
|
||||
|
||||
class _TeamTreeWidgetState extends State<TeamTreeWidget> {
|
||||
// 缓存已加载的节点数据
|
||||
final Map<String, List<TeamTreeNode>> _childrenCache = {};
|
||||
|
||||
// 节点框的尺寸
|
||||
static const double nodeWidth = 80.0;
|
||||
static const double nodeHeight = 60.0;
|
||||
static const double nodeHorizontalSpacing = 12.0;
|
||||
static const double nodeVerticalSpacing = 40.0;
|
||||
|
||||
// 当前容器宽度(由 LayoutBuilder 传递)
|
||||
double _containerWidth = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 使用实际容器宽度而不是屏幕宽度
|
||||
_containerWidth = constraints.maxWidth;
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minWidth: _containerWidth,
|
||||
),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: _buildTreeLevel([widget.rootNode], 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建树的一层
|
||||
Widget _buildTreeLevel(List<TeamTreeNode> nodes, int level) {
|
||||
if (nodes.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
// 显示所有节点,允许左右滚动
|
||||
return Column(
|
||||
children: [
|
||||
// 当前层的节点
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (int i = 0; i < nodes.length; i++) ...[
|
||||
_buildNodeWithChildren(nodes[i]),
|
||||
if (i < nodes.length - 1) SizedBox(width: nodeHorizontalSpacing),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建单个节点及其子节点
|
||||
Widget _buildNodeWithChildren(TeamTreeNode node) {
|
||||
return Column(
|
||||
children: [
|
||||
// 节点本身
|
||||
_buildNodeBox(node),
|
||||
// 如果展开了且有子节点,显示连线和子节点
|
||||
if (node.isExpanded && node.children != null && node.children!.isNotEmpty) ...[
|
||||
// 连接线
|
||||
CustomPaint(
|
||||
size: Size(_calculateSubtreeWidth(node.children!), nodeVerticalSpacing),
|
||||
painter: _TreeLinePainter(
|
||||
parentWidth: nodeWidth,
|
||||
childCount: node.children!.length,
|
||||
childWidth: nodeWidth,
|
||||
spacing: nodeHorizontalSpacing,
|
||||
),
|
||||
),
|
||||
// 子节点层
|
||||
_buildTreeLevel(node.children!, 0),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 计算子树宽度
|
||||
double _calculateSubtreeWidth(List<TeamTreeNode> children) {
|
||||
if (children.isEmpty) return nodeWidth;
|
||||
return children.length * nodeWidth + (children.length - 1) * nodeHorizontalSpacing;
|
||||
}
|
||||
|
||||
/// 构建节点框
|
||||
Widget _buildNodeBox(TeamTreeNode node) {
|
||||
return GestureDetector(
|
||||
onTap: () => _handleNodeTap(node),
|
||||
onLongPress: () => _showNodeDetails(node),
|
||||
child: Container(
|
||||
width: nodeWidth,
|
||||
height: nodeHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF5E6),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFFF6B00),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x1A000000), // 10% opacity black
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: node.isLoading
|
||||
? const Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFFFF6B00)),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 序列号(简化显示)
|
||||
Text(
|
||||
_formatAccountSequence(node.accountSequence),
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// 个人/团队认种数
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'${node.personalPlantingCount}/${node.teamPlantingCount}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xCC5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 展开/收起按钮
|
||||
if (node.hasChildren)
|
||||
Container(
|
||||
width: 20,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF6B00),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
node.isExpanded ? '−' : '+',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 格式化账户序列号(简化显示)
|
||||
String _formatAccountSequence(String seq) {
|
||||
if (seq.length > 8) {
|
||||
// D25121300001 -> ...00001
|
||||
return '...${seq.substring(seq.length - 5)}';
|
||||
}
|
||||
return seq;
|
||||
}
|
||||
|
||||
/// 处理节点点击
|
||||
Future<void> _handleNodeTap(TeamTreeNode node) async {
|
||||
if (!node.hasChildren) return;
|
||||
|
||||
if (node.isExpanded) {
|
||||
// 收起节点
|
||||
setState(() {
|
||||
node.isExpanded = false;
|
||||
});
|
||||
} else {
|
||||
// 展开节点
|
||||
if (node.children != null) {
|
||||
// 已经加载过,直接展开
|
||||
setState(() {
|
||||
node.isExpanded = true;
|
||||
});
|
||||
} else {
|
||||
// 需要加载子节点
|
||||
await _loadChildren(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载子节点
|
||||
Future<void> _loadChildren(TeamTreeNode node) async {
|
||||
// 检查缓存
|
||||
if (_childrenCache.containsKey(node.accountSequence)) {
|
||||
setState(() {
|
||||
node.children = _childrenCache[node.accountSequence];
|
||||
node.isExpanded = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示加载状态
|
||||
setState(() {
|
||||
node.isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final response = await widget.referralDataSource.getUserDirectReferrals(
|
||||
accountSequence: node.accountSequence,
|
||||
);
|
||||
|
||||
final children = response.referrals
|
||||
.map((info) => TeamTreeNode.fromDirectReferralInfo(info))
|
||||
.toList();
|
||||
|
||||
// 缓存结果
|
||||
_childrenCache[node.accountSequence] = children;
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
node.children = children;
|
||||
node.isExpanded = true;
|
||||
node.isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('加载子节点失败: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
node.isLoading = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('加载失败: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示节点详情弹窗
|
||||
void _showNodeDetails(TeamTreeNode node) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (context) => _NodeDetailsSheet(
|
||||
node: node,
|
||||
onExpandTap: node.hasChildren
|
||||
? () {
|
||||
Navigator.pop(context);
|
||||
_handleNodeTap(node);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 树形连接线绘制器
|
||||
class _TreeLinePainter extends CustomPainter {
|
||||
final double parentWidth;
|
||||
final int childCount;
|
||||
final double childWidth;
|
||||
final double spacing;
|
||||
|
||||
_TreeLinePainter({
|
||||
required this.parentWidth,
|
||||
required this.childCount,
|
||||
required this.childWidth,
|
||||
required this.spacing,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (childCount == 0) return;
|
||||
|
||||
final paint = Paint()
|
||||
..color = const Color(0xFFFF6B00)
|
||||
..strokeWidth = 1.5
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
// 父节点中心点
|
||||
final parentCenterX = size.width / 2;
|
||||
const parentBottomY = 0.0;
|
||||
|
||||
// 中间连接点
|
||||
final midY = size.height / 2;
|
||||
|
||||
// 计算子节点位置
|
||||
final totalChildrenWidth = childCount * childWidth + (childCount - 1) * spacing;
|
||||
final startX = (size.width - totalChildrenWidth) / 2;
|
||||
|
||||
// 从父节点向下画垂直线到中间
|
||||
canvas.drawLine(
|
||||
Offset(parentCenterX, parentBottomY),
|
||||
Offset(parentCenterX, midY),
|
||||
paint,
|
||||
);
|
||||
|
||||
if (childCount == 1) {
|
||||
// 只有一个子节点,直接画直线到底部
|
||||
canvas.drawLine(
|
||||
Offset(parentCenterX, midY),
|
||||
Offset(parentCenterX, size.height),
|
||||
paint,
|
||||
);
|
||||
} else {
|
||||
// 多个子节点,画水平线
|
||||
final firstChildCenterX = startX + childWidth / 2;
|
||||
final lastChildCenterX = startX + (childCount - 1) * (childWidth + spacing) + childWidth / 2;
|
||||
|
||||
canvas.drawLine(
|
||||
Offset(firstChildCenterX, midY),
|
||||
Offset(lastChildCenterX, midY),
|
||||
paint,
|
||||
);
|
||||
|
||||
// 从水平线向下画垂直线到每个子节点
|
||||
for (int i = 0; i < childCount; i++) {
|
||||
final childCenterX = startX + i * (childWidth + spacing) + childWidth / 2;
|
||||
canvas.drawLine(
|
||||
Offset(childCenterX, midY),
|
||||
Offset(childCenterX, size.height),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
/// 节点详情弹窗
|
||||
class _NodeDetailsSheet extends StatelessWidget {
|
||||
final TeamTreeNode node;
|
||||
final VoidCallback? onExpandTap;
|
||||
|
||||
const _NodeDetailsSheet({
|
||||
required this.node,
|
||||
this.onExpandTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题栏
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'成员详情',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
color: Color(0xFF999999),
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 序列号
|
||||
_buildDetailRow('序列号', node.accountSequence),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 个人认种数
|
||||
_buildDetailRow('个人认种', '${node.personalPlantingCount} 棵'),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 团队认种数
|
||||
_buildDetailRow('团队认种', '${node.teamPlantingCount} 棵'),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 直推人数
|
||||
_buildDetailRow('直推人数', '${node.directReferralCount} 人'),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 操作按钮
|
||||
if (onExpandTap != null)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: onExpandTap,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFFF6B00),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
node.isExpanded ? '收起下级' : '展开下级 (${node.directReferralCount}人)',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 底部安全区域
|
||||
SizedBox(height: MediaQuery.of(context).padding.bottom + 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF8B5A2B),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue