import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:new_learn/core/api/class/api.dart'; import 'package:new_learn/core/api/class/model.dart'; import 'package:new_learn/core/components/common_paged_list.dart'; import 'package:new_learn/core/components/user_circle_avatar.dart'; import 'package:new_learn/core/providers/user_provider.dart'; import 'package:new_learn/core/utils/img_url.dart'; import 'package:new_learn/features/class/widgets/point_rank_widgets.dart'; /// ============================================================ /// 【PointRankView】「积分排行」子页 —— 对标 Vue pointRank.vue /// /// 一、Vue 源结构: /// 海报图 → 分类(本班级/同年级/同云校) → 子 tab(总榜/本周/上周/今日/昨天) /// → 规则排序栏 → 排行列表(RankItem) → 底部「我的排名」卡 → 积分规则弹窗。 /// /// 二、数据对接(已接通): /// - 人数统计 → getUserCount /// - 榜单列表 → getUserRankPagedList(游标分页,CommonPagedList) /// - 我的排名 → 同一接口返回的 myRank /// - 分类/子tab/排序切换 → 通过 ValueKey 重建列表,自动重新拉第一页 /// /// 三、组件已抽离到 widgets/point_rank_widgets.dart(RankRow/EnergyRuleModal/ /// RuleSection/TriangleClipper),本文件只保留页面状态与数据逻辑。 /// /// 四、换算规则:rpx → /2 后接 .w/.h/.sp/.r /// ============================================================ class PointRankView extends ConsumerStatefulWidget { const PointRankView({super.key}); @override ConsumerState createState() => _PointRankViewState(); } class _PointRankViewState extends ConsumerState { static const Color _primary = Color(0xFF3D7DF5); static const int _pageSize = 20; int _activeCategory = 0; // 0 本班级 / 1 同年级 / 2 同云校 int _activeSubTab = 0; // 总榜/本周/上周/今日/昨天 bool _sortDesc = true; // true 积分倒序 / false 正序 bool _showRule = false; int? _classCount; int? _gradeCount; int? _cloudCount; UserRank? _myRank; int _prevRankPageId = 0; static const List _categories = ['本班级', '同年级', '同云校']; static const List _subTabs = ['总榜', '本周', '上周', '今日', '昨天']; @override void initState() { super.initState(); _fetchUserCount(); } Future _fetchUserCount() async { try { final res = await UserRankApi.instance.getUserCount(); if (!mounted) return; setState(() { _classCount = res.classUserCount; _gradeCount = res.gradeUserCount; _cloudCount = res.cloudSchoolUserCount; }); } catch (_) { // 人数统计失败静默处理 } } Future> _loadRank(int page) async { final prev = page == 1 ? 0 : _prevRankPageId; final res = await UserRankApi.instance.getRankPage( pageIndex: page, pageSize: _pageSize, prevPageId: prev, userRankType: _activeSubTab, userClass: _activeCategory, desc: _sortDesc, ); if (res.userRanks.isNotEmpty) { _prevRankPageId = res.userRanks.last.userId; } if (page == 1 && mounted) { setState(() => _myRank = res.myRank); } return PagingResult( data: res.userRanks, hasNext: res.userRanks.length >= _pageSize, ); } String _countText(int? count) => count == null ? '--' : '$count'; String _formatPoints(double p) { if (p == p.roundToDouble()) return p.toInt().toString(); return p.toString(); } @override Widget build(BuildContext context) { final currentUserId = ref.watch(currentUserProvider).value?.userId ?? 0; return Stack( children: [ Column( children: [ _buildPoster(), _buildCategoryBar(), Expanded(child: _buildRankListContainer(currentUserId)), ], ), if (_myRank != null) Positioned(left: 0, right: 0, bottom: 0, child: _buildMyRankCard()), if (_showRule) EnergyRuleModal(onClose: () => setState(() => _showRule = false)), ], ); } Widget _buildPoster() { return Container( margin: EdgeInsets.only(top: 22.h), alignment: Alignment.center, child: CachedNetworkImage( imageUrl: ImgUrl.build( '/StaticResource/2026-07-29/pointRankPoster1785291500821.png', ), width: 170.w, height: 30.h, fit: BoxFit.fill, errorWidget: (_, _, _) => const SizedBox(width: 170, height: 30), ), ); } Widget _buildCategoryBar() { return Container( margin: EdgeInsets.only(top: 20.h), padding: EdgeInsets.symmetric(horizontal: 14.w), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ for (int i = 0; i < _categories.length; i++) _buildCategoryItem(i), ], ), ); } Widget _buildCategoryItem(int index) { final active = _activeCategory == index; final countText = switch (index) { 0 => _countText(_classCount), 1 => _countText(_gradeCount), _ => _countText(_cloudCount), }; return GestureDetector( onTap: () { if (_activeCategory == index) return; setState(() => _activeCategory = index); }, child: Column( children: [ Container( width: 105.w, height: 45.h, decoration: BoxDecoration( color: active ? Colors.white : Colors.white.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(8.r), // 始终保留 1px border 占位,避免激活/未激活切换时 content 区域差 2px 抖动 border: Border.all( color: active ? Colors.transparent : Colors.white, width: 1, ), ), // Web 端回退字体行高偏大,固定高度可能溢出 → 内容放不下时自动缩小 child: FittedBox( fit: BoxFit.scaleDown, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( _categories[index], style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, color: active ? _primary : const Color(0xFF687FAD), ), ), // SizedBox(height: 0.5.h), Text( '$countText人', style: TextStyle( fontSize: 11, color: active ? _primary : const Color(0xFF687FAD), ), ), ], ), ), ), // 始终渲染三角形占位,visible 切换避免外层 Column 高度变化抖动 Visibility( visible: active, maintainSize: true, maintainAnimation: true, maintainState: true, child: _buildTriangle(), ), ], ), ); } Widget _buildTriangle() { return ClipPath( clipper: TriangleClipper(), child: Container(width: 14.w, height: 8.h, color: Colors.white), ); } Widget _buildRankListContainer(int currentUserId) { return Container( margin: EdgeInsets.only(top: 8.h), decoration: const BoxDecoration( color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), child: Column( children: [ _buildSubTabList(), _buildRuleSortBar(), Expanded( child: CommonPagedList( // 分类/子tab/排序变化 → 重建列表,自动重拉第一页 key: ValueKey('rank-$_activeCategory-$_activeSubTab-$_sortDesc'), load: _loadRank, padding: EdgeInsets.fromLTRB(14.w, 13.h, 14.w, 121.h), emptyWidget: Center( child: Text( '暂无积分排行', style: TextStyle( fontSize: 14.sp, color: const Color(0xFF999999), ), ), ), buildItem: (context, item, index) { final isSelf = item.userId != 0 && item.userId == currentUserId; return RankRow( item: item, category: _activeCategory, isSelf: isSelf, ); }, ), ), ], ), ); } Widget _buildSubTabList() { return Container( margin: EdgeInsets.only(top: 12.h), padding: EdgeInsets.symmetric(horizontal: 25.w), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ for (int i = 0; i < _subTabs.length; i++) _buildSubTabItem(i), ], ), ); } Widget _buildSubTabItem(int index) { final active = _activeSubTab == index; return GestureDetector( onTap: () { if (_activeSubTab == index) return; setState(() => _activeSubTab = index); }, child: Container( width: 52.w, height: 32.h, alignment: Alignment.center, decoration: BoxDecoration( color: active ? _primary : const Color(0xFFF5F8FA), borderRadius: BorderRadius.circular(20.r), ), child: Text( _subTabs[index], style: TextStyle( fontSize: 14, fontWeight: active ? FontWeight.w500 : FontWeight.w400, color: active ? Colors.white : const Color(0xFF687FAD), ), ), ), ); } Widget _buildRuleSortBar() { return Container( margin: EdgeInsets.only(top: 21.h), padding: EdgeInsets.symmetric(horizontal: 14.w), child: Row( children: [ Expanded( child: GestureDetector( onTap: () => setState(() => _showRule = true), child: Row( children: [ const Flexible( child: Text( '积分数值越高排名越靠前,同分并列同名次', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: Color(0xFF687FAD)), ), ), SizedBox(width: 4.w), const Icon( Icons.info_outline, size: 13, color: Color(0xFF687FAD), ), ], ), ), ), GestureDetector( onTap: () => setState(() => _sortDesc = !_sortDesc), child: Row( children: [ Text( _sortDesc ? '积分倒序' : '积分正序', style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: _primary, ), ), SizedBox(width: 5.w), _buildSortIcons(), ], ), ), ], ), ); } Widget _buildSortIcons() { final up = _sortDesc ? '/StaticResource/2026-07-29/reverseOrder_up_icon1785291938907.png' : '/StaticResource/2026-07-29/properOrder_up_icon1785291938906.png'; final down = _sortDesc ? '/StaticResource/2026-07-29/reverseOrder_down_icon1785291938905.png' : '/StaticResource/2026-07-29/properOrder_down_icon1785291938907.png'; return Column( mainAxisSize: MainAxisSize.min, children: [ CachedNetworkImage( imageUrl: ImgUrl.build(up), width: 12.w, height: 7.h, fit: BoxFit.contain, ), CachedNetworkImage( imageUrl: ImgUrl.build(down), width: 12.w, height: 7.h, fit: BoxFit.contain, ), ], ); } Widget _buildMyRankCard() { final rank = _myRank!; return Container( height: 70.h, padding: EdgeInsets.fromLTRB(10.w, 0, 14.w, 0), decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFFD6EEFF), Color(0xFFFFFFFF)], ), borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), child: Row( children: [ SizedBox( width: 50.w, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( '${rank.rank}', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1D2B47), ), ), SizedBox(height: 8.h), const Text( '我的排名', style: TextStyle(fontSize: 9, color: Color(0xFF687FAD)), ), ], ), ), UserCircleAvatar(url: _avatarUrl(rank.userAvatar), radius: 22.w), SizedBox(width: 10.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( rank.userName.isEmpty ? '我' : rank.userName, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15, fontWeight: FontWeight.bold, color: Color(0xFF1D2B47), ), ), if (_activeCategory != 0 && rank.userClassName.isNotEmpty) ...[ SizedBox(height: 3.h), Text( rank.userClassName, style: TextStyle(fontSize: 12, color: Color(0xFF687FAD)), ), ], ], ), ), Text( _formatPoints(rank.points), style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, color: _primary, ), ), SizedBox(width: 25.w), ], ), ); } static String _avatarUrl(String? raw) { final url = ImgUrl.build(raw); return url.isEmpty ? ImgUrl.build('/StaticResource/2026-04-11/default_avatar.png') : url; } }