diff --git a/backend/services/referral-service/src/api/controllers/referral.controller.ts b/backend/services/referral-service/src/api/controllers/referral.controller.ts index 567eb207..5905c291 100644 --- a/backend/services/referral-service/src/api/controllers/referral.controller.ts +++ b/backend/services/referral-service/src/api/controllers/referral.controller.ts @@ -211,6 +211,21 @@ export class ReferralController { const query = new GetUserReferralInfoQuery(userId); // userId 已经是字符串 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 { + 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); + } } /** diff --git a/backend/services/referral-service/src/api/dto/referral.dto.ts b/backend/services/referral-service/src/api/dto/referral.dto.ts index 9b1e6745..3ecb0085 100644 --- a/backend/services/referral-service/src/api/dto/referral.dto.ts +++ b/backend/services/referral-service/src/api/dto/referral.dto.ts @@ -101,6 +101,9 @@ export class DirectReferralResponseDto { @ApiProperty({ description: '团队认种量' }) teamPlantingCount: number; + @ApiProperty({ description: '直推人数(用于判断是否有下级)' }) + directReferralCount: number; + @ApiProperty({ description: '加入时间' }) joinedAt: Date; } diff --git a/backend/services/referral-service/src/application/queries/get-direct-referrals.query.ts b/backend/services/referral-service/src/application/queries/get-direct-referrals.query.ts index 25ec6bfd..624af203 100644 --- a/backend/services/referral-service/src/application/queries/get-direct-referrals.query.ts +++ b/backend/services/referral-service/src/application/queries/get-direct-referrals.query.ts @@ -12,6 +12,7 @@ export interface DirectReferralResult { referralCode: string; personalPlantingCount: number; // 个人认种量 teamPlantingCount: number; // 团队认种量 + directReferralCount: number; // 直推人数(用于判断是否有下级) joinedAt: Date; } diff --git a/backend/services/referral-service/src/application/services/referral.service.ts b/backend/services/referral-service/src/application/services/referral.service.ts index b28f6388..f283ae66 100644 --- a/backend/services/referral-service/src/application/services/referral.service.ts +++ b/backend/services/referral-service/src/application/services/referral.service.ts @@ -185,6 +185,13 @@ export class ReferralService { } } + // 批量查询每个直推用户的直推人数(用于判断是否有下级) + const directReferralCountMap = new Map(); + 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 stats = directStatsMap.get(r.userId.toString()); return { @@ -193,6 +200,7 @@ export class ReferralService { referralCode: r.referralCode, personalPlantingCount: stats?.personal ?? 0, teamPlantingCount: stats?.team ?? 0, + directReferralCount: directReferralCountMap.get(r.userId.toString()) ?? 0, joinedAt: r.createdAt, }; }); diff --git a/frontend/mobile-app/lib/core/services/referral_service.dart b/frontend/mobile-app/lib/core/services/referral_service.dart index f5bb5c77..da1ddf24 100644 --- a/frontend/mobile-app/lib/core/services/referral_service.dart +++ b/frontend/mobile-app/lib/core/services/referral_service.dart @@ -56,6 +56,7 @@ class DirectReferralInfo { final String referralCode; final int personalPlantingCount; // 个人认种量 final int teamPlantingCount; // 团队认种量 + final int directReferralCount; // 直推人数(用于判断是否有下级) final DateTime joinedAt; DirectReferralInfo({ @@ -64,6 +65,7 @@ class DirectReferralInfo { required this.referralCode, required this.personalPlantingCount, required this.teamPlantingCount, + required this.directReferralCount, required this.joinedAt, }); @@ -74,6 +76,7 @@ class DirectReferralInfo { 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(), @@ -343,4 +346,35 @@ class ReferralService { } } } + + /// 获取指定用户的直推列表(用于伞下树懒加载) + /// + /// 调用 GET /referral/user/:accountSequence/direct-referrals (referral-service) + Future 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; + debugPrint('用户直推列表获取成功: total=${data['total']}'); + return DirectReferralsResponse.fromJson(data); + } + + throw Exception('获取用户直推列表失败'); + } catch (e) { + debugPrint('获取用户直推列表失败: $e'); + rethrow; + } + } } diff --git a/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart b/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart index 56887021..3784d4c7 100644 --- a/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart +++ b/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart @@ -12,6 +12,7 @@ import '../../../../core/services/referral_service.dart'; import '../../../../core/services/reward_service.dart'; import '../../../../routes/route_paths.dart'; import '../../../../routes/app_router.dart'; +import '../widgets/team_tree_widget.dart'; /// 个人中心页面 - 显示用户信息、社区数据、收益和设置 /// 包含用户资料、推荐信息、社区考核、收益领取等功能 @@ -1126,6 +1127,9 @@ class _ProfilePageState extends ConsumerState { // 直推列表 _buildReferralList(), const SizedBox(height: 16), + // 我的伞下 + _buildMyTeamTree(), + const SizedBox(height: 16), // 分享邀请按钮 _buildShareButton(), const SizedBox(height: 16), @@ -1930,6 +1934,67 @@ class _ProfilePageState extends ConsumerState { ); } + /// 构建我的伞下树 + 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() { return GestureDetector( diff --git a/frontend/mobile-app/lib/features/profile/presentation/widgets/team_tree_widget.dart b/frontend/mobile-app/lib/features/profile/presentation/widgets/team_tree_widget.dart new file mode 100644 index 00000000..5fdfe7d6 --- /dev/null +++ b/frontend/mobile-app/lib/features/profile/presentation/widgets/team_tree_widget.dart @@ -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? 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 createState() => _TeamTreeWidgetState(); +} + +class _TeamTreeWidgetState extends State { + // 缓存已加载的节点数据 + final Map> _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 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 visibleNodes; + List 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 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(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 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 _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 _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 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 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; +}