feat(profile): 添加我的伞下功能 - 展示下级用户树形结构
- 后端新增 GET /referral/user/:accountSequence/direct-referrals API - 前端新增伞下树组件,支持懒加载、缓存、展开/收起 - 使用 CustomPaint 绘制父子节点连接线 - 超出屏幕宽度时显示省略号,点击弹出底部列表 🤖 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
2eda3be275
commit
2e44263834
|
|
@ -211,6 +211,21 @@ export class ReferralController {
|
||||||
const query = new GetUserReferralInfoQuery(userId); // userId 已经是字符串
|
const query = new GetUserReferralInfoQuery(userId); // userId 已经是字符串
|
||||||
return this.referralService.getUserReferralInfo(query);
|
return this.referralService.getUserReferralInfo(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('user/:accountSequence/direct-referrals')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: '获取指定用户的直推列表(用于伞下树懒加载)' })
|
||||||
|
@ApiParam({ name: 'accountSequence', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
|
||||||
|
@ApiResponse({ status: 200, type: DirectReferralsResponseDto })
|
||||||
|
async getUserDirectReferrals(
|
||||||
|
@Param('accountSequence') accountSequence: string,
|
||||||
|
@Query() dto: GetDirectReferralsDto,
|
||||||
|
): Promise<DirectReferralsResponseDto> {
|
||||||
|
this.logger.log(`[getUserDirectReferrals] accountSequence=${accountSequence}, limit=${dto.limit}, offset=${dto.offset}`);
|
||||||
|
const query = new GetDirectReferralsQuery(accountSequence, dto.limit, dto.offset);
|
||||||
|
return this.referralService.getDirectReferrals(query);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,9 @@ export class DirectReferralResponseDto {
|
||||||
@ApiProperty({ description: '团队认种量' })
|
@ApiProperty({ description: '团队认种量' })
|
||||||
teamPlantingCount: number;
|
teamPlantingCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '直推人数(用于判断是否有下级)' })
|
||||||
|
directReferralCount: number;
|
||||||
|
|
||||||
@ApiProperty({ description: '加入时间' })
|
@ApiProperty({ description: '加入时间' })
|
||||||
joinedAt: Date;
|
joinedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export interface DirectReferralResult {
|
||||||
referralCode: string;
|
referralCode: string;
|
||||||
personalPlantingCount: number; // 个人认种量
|
personalPlantingCount: number; // 个人认种量
|
||||||
teamPlantingCount: number; // 团队认种量
|
teamPlantingCount: number; // 团队认种量
|
||||||
|
directReferralCount: number; // 直推人数(用于判断是否有下级)
|
||||||
joinedAt: Date;
|
joinedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,13 @@ export class ReferralService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 批量查询每个直推用户的直推人数(用于判断是否有下级)
|
||||||
|
const directReferralCountMap = new Map<string, number>();
|
||||||
|
for (const r of paginated) {
|
||||||
|
const subReferrals = await this.referralRepo.findDirectReferrals(r.userId);
|
||||||
|
directReferralCountMap.set(r.userId.toString(), subReferrals.length);
|
||||||
|
}
|
||||||
|
|
||||||
const referrals = paginated.map((r) => {
|
const referrals = paginated.map((r) => {
|
||||||
const stats = directStatsMap.get(r.userId.toString());
|
const stats = directStatsMap.get(r.userId.toString());
|
||||||
return {
|
return {
|
||||||
|
|
@ -193,6 +200,7 @@ export class ReferralService {
|
||||||
referralCode: r.referralCode,
|
referralCode: r.referralCode,
|
||||||
personalPlantingCount: stats?.personal ?? 0,
|
personalPlantingCount: stats?.personal ?? 0,
|
||||||
teamPlantingCount: stats?.team ?? 0,
|
teamPlantingCount: stats?.team ?? 0,
|
||||||
|
directReferralCount: directReferralCountMap.get(r.userId.toString()) ?? 0,
|
||||||
joinedAt: r.createdAt,
|
joinedAt: r.createdAt,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ class DirectReferralInfo {
|
||||||
final String referralCode;
|
final String referralCode;
|
||||||
final int personalPlantingCount; // 个人认种量
|
final int personalPlantingCount; // 个人认种量
|
||||||
final int teamPlantingCount; // 团队认种量
|
final int teamPlantingCount; // 团队认种量
|
||||||
|
final int directReferralCount; // 直推人数(用于判断是否有下级)
|
||||||
final DateTime joinedAt;
|
final DateTime joinedAt;
|
||||||
|
|
||||||
DirectReferralInfo({
|
DirectReferralInfo({
|
||||||
|
|
@ -64,6 +65,7 @@ class DirectReferralInfo {
|
||||||
required this.referralCode,
|
required this.referralCode,
|
||||||
required this.personalPlantingCount,
|
required this.personalPlantingCount,
|
||||||
required this.teamPlantingCount,
|
required this.teamPlantingCount,
|
||||||
|
required this.directReferralCount,
|
||||||
required this.joinedAt,
|
required this.joinedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -74,6 +76,7 @@ class DirectReferralInfo {
|
||||||
referralCode: json['referralCode'] ?? '',
|
referralCode: json['referralCode'] ?? '',
|
||||||
personalPlantingCount: json['personalPlantingCount'] ?? 0,
|
personalPlantingCount: json['personalPlantingCount'] ?? 0,
|
||||||
teamPlantingCount: json['teamPlantingCount'] ?? 0,
|
teamPlantingCount: json['teamPlantingCount'] ?? 0,
|
||||||
|
directReferralCount: json['directReferralCount'] ?? 0,
|
||||||
joinedAt: json['joinedAt'] != null
|
joinedAt: json['joinedAt'] != null
|
||||||
? DateTime.parse(json['joinedAt'])
|
? DateTime.parse(json['joinedAt'])
|
||||||
: DateTime.now(),
|
: DateTime.now(),
|
||||||
|
|
@ -343,4 +346,35 @@ class ReferralService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取指定用户的直推列表(用于伞下树懒加载)
|
||||||
|
///
|
||||||
|
/// 调用 GET /referral/user/:accountSequence/direct-referrals (referral-service)
|
||||||
|
Future<DirectReferralsResponse> getUserDirectReferrals({
|
||||||
|
required String accountSequence,
|
||||||
|
int limit = 100,
|
||||||
|
int offset = 0,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
debugPrint('获取用户直推列表: accountSequence=$accountSequence');
|
||||||
|
final response = await _apiClient.get(
|
||||||
|
'/referral/user/$accountSequence/direct-referrals',
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import '../../../../core/services/referral_service.dart';
|
||||||
import '../../../../core/services/reward_service.dart';
|
import '../../../../core/services/reward_service.dart';
|
||||||
import '../../../../routes/route_paths.dart';
|
import '../../../../routes/route_paths.dart';
|
||||||
import '../../../../routes/app_router.dart';
|
import '../../../../routes/app_router.dart';
|
||||||
|
import '../widgets/team_tree_widget.dart';
|
||||||
|
|
||||||
/// 个人中心页面 - 显示用户信息、社区数据、收益和设置
|
/// 个人中心页面 - 显示用户信息、社区数据、收益和设置
|
||||||
/// 包含用户资料、推荐信息、社区考核、收益领取等功能
|
/// 包含用户资料、推荐信息、社区考核、收益领取等功能
|
||||||
|
|
@ -1126,6 +1127,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||||
// 直推列表
|
// 直推列表
|
||||||
_buildReferralList(),
|
_buildReferralList(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
// 我的伞下
|
||||||
|
_buildMyTeamTree(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
// 分享邀请按钮
|
// 分享邀请按钮
|
||||||
_buildShareButton(),
|
_buildShareButton(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
@ -1930,6 +1934,67 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 构建我的伞下树
|
||||||
|
Widget _buildMyTeamTree() {
|
||||||
|
// 创建根节点(自己)
|
||||||
|
final rootNode = TeamTreeNode.createRoot(
|
||||||
|
accountSequence: _serialNumber,
|
||||||
|
personalPlantingCount: _personalPlantingCount,
|
||||||
|
teamPlantingCount: _teamPlantingCount,
|
||||||
|
directReferralCount: _directReferralCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 8),
|
||||||
|
child: Text(
|
||||||
|
'我的伞下',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.5,
|
||||||
|
color: Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xCCFFF5E6),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: const Color(0x33D4AF37),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _directReferralCount == 0
|
||||||
|
? const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(20),
|
||||||
|
child: Text(
|
||||||
|
'暂无下级成员',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
color: Color(0x995D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: TeamTreeWidget(
|
||||||
|
rootNode: rootNode,
|
||||||
|
referralService: ref.read(referralServiceProvider),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 构建分享按钮
|
/// 构建分享按钮
|
||||||
Widget _buildShareButton() {
|
Widget _buildShareButton() {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,597 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../core/services/referral_service.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 ReferralService referralService;
|
||||||
|
|
||||||
|
const TeamTreeWidget({
|
||||||
|
super.key,
|
||||||
|
required this.rootNode,
|
||||||
|
required this.referralService,
|
||||||
|
});
|
||||||
|
|
||||||
|
@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;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: _buildTreeLevel([widget.rootNode], 0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建树的一层
|
||||||
|
Widget _buildTreeLevel(List<TeamTreeNode> nodes, int level) {
|
||||||
|
if (nodes.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
// 计算屏幕可以显示的最大节点数
|
||||||
|
final screenWidth = MediaQuery.of(context).size.width - 32; // 减去左右padding
|
||||||
|
final maxVisibleNodes = ((screenWidth + nodeHorizontalSpacing) / (nodeWidth + nodeHorizontalSpacing)).floor();
|
||||||
|
|
||||||
|
// 分离需要显示的节点和隐藏的节点
|
||||||
|
List<TeamTreeNode> visibleNodes;
|
||||||
|
List<TeamTreeNode> hiddenNodes;
|
||||||
|
|
||||||
|
if (nodes.length <= maxVisibleNodes) {
|
||||||
|
visibleNodes = nodes;
|
||||||
|
hiddenNodes = [];
|
||||||
|
} else {
|
||||||
|
// 显示前几个和最后一个,中间显示省略号
|
||||||
|
final showCount = maxVisibleNodes - 1; // 留一个位置给省略号
|
||||||
|
final frontCount = (showCount / 2).ceil();
|
||||||
|
final backCount = showCount - frontCount;
|
||||||
|
|
||||||
|
visibleNodes = [
|
||||||
|
...nodes.sublist(0, frontCount),
|
||||||
|
...nodes.sublist(nodes.length - backCount),
|
||||||
|
];
|
||||||
|
hiddenNodes = nodes.sublist(frontCount, nodes.length - backCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// 当前层的节点
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < visibleNodes.length; i++) ...[
|
||||||
|
if (i == (maxVisibleNodes - 1) ~/ 2 && hiddenNodes.isNotEmpty) ...[
|
||||||
|
// 在中间位置插入省略号节点
|
||||||
|
_buildEllipsisNode(hiddenNodes),
|
||||||
|
SizedBox(width: nodeHorizontalSpacing),
|
||||||
|
],
|
||||||
|
_buildNodeWithChildren(visibleNodes[i]),
|
||||||
|
if (i < visibleNodes.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),
|
||||||
|
child: Container(
|
||||||
|
width: nodeWidth,
|
||||||
|
height: nodeHeight,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFFF5E6),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: const Color(0xFFD4AF37),
|
||||||
|
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(0xFFD4AF37)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// 序列号(简化显示)
|
||||||
|
Text(
|
||||||
|
_formatAccountSequence(node.accountSequence),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
// 个人/团队认种数
|
||||||
|
Text(
|
||||||
|
'${node.personalPlantingCount}/${node.teamPlantingCount}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Color(0xCC5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
// 展开/收起按钮
|
||||||
|
if (node.hasChildren)
|
||||||
|
Container(
|
||||||
|
width: 20,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFD4AF37),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
node.isExpanded ? '−' : '+',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
height: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建省略号节点
|
||||||
|
Widget _buildEllipsisNode(List<TeamTreeNode> hiddenNodes) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _showHiddenNodesSheet(hiddenNodes),
|
||||||
|
child: Container(
|
||||||
|
width: nodeWidth,
|
||||||
|
height: nodeHeight,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFE8E8E8),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: const Color(0xFFBBBBBB),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'...',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Color(0xFF666666),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'+${hiddenNodes.length}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Color(0xFF666666),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 格式化账户序列号(简化显示)
|
||||||
|
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.referralService.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 _showHiddenNodesSheet(List<TeamTreeNode> hiddenNodes) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
builder: (context) => _HiddenNodesSheet(
|
||||||
|
nodes: hiddenNodes,
|
||||||
|
onNodeTap: (node) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_handleNodeTap(node);
|
||||||
|
},
|
||||||
|
formatAccountSequence: _formatAccountSequence,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 隐藏节点列表弹窗
|
||||||
|
class _HiddenNodesSheet extends StatelessWidget {
|
||||||
|
final List<TeamTreeNode> nodes;
|
||||||
|
final Function(TeamTreeNode) onNodeTap;
|
||||||
|
final String Function(String) formatAccountSequence;
|
||||||
|
|
||||||
|
const _HiddenNodesSheet({
|
||||||
|
required this.nodes,
|
||||||
|
required this.onNodeTap,
|
||||||
|
required this.formatAccountSequence,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: MediaQuery.of(context).size.height * 0.6,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// 标题栏
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(color: Color(0xFFEEEEEE)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'更多成员 (${nodes.length}人)',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => Navigator.pop(context),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.close,
|
||||||
|
color: Color(0xFF999999),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 列表
|
||||||
|
Flexible(
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: nodes.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final node = nodes[index];
|
||||||
|
return ListTile(
|
||||||
|
onTap: () => onNodeTap(node),
|
||||||
|
leading: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFFF5E6),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: const Color(0xFFD4AF37),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
node.hasChildren ? '+' : '-',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: node.hasChildren
|
||||||
|
? const Color(0xFFD4AF37)
|
||||||
|
: const Color(0xFFCCCCCC),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
node.accountSequence,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'个人/团队: ${node.personalPlantingCount}/${node.teamPlantingCount}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
color: Color(0xCC5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: node.hasChildren
|
||||||
|
? Text(
|
||||||
|
'${node.directReferralCount}人',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
color: Color(0xFF999999),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: 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(0xFFD4AF37)
|
||||||
|
..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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue