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:
hailin 2025-12-14 10:34:56 -08:00
parent 2eda3be275
commit 2e44263834
7 changed files with 723 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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