Files
App.K12.Student.Miniapp_Flu…/lib/features/class/point_rank_view.dart
T
崔宝新 7175bf3054 feat: 新增全局确认弹窗、多页面UI优化与网络日志开关
1.  新增全局二次确认弹窗组件confirm_box,支持自定义参数并对标小程序实现
2.  优化班级板块多处UI:
    - 修复point_rank_view中Web端字体溢出问题,使用FittedBox自适应缩放
    - 重构class_update_widgets的PostCard,新增卡片背景控制开关并替换光晕实现
    - 优化class_update_view的在线同学栏与头像组布局,支持动态计算展示数量
    - 调整my_update_detail_page的评论区结构,修复评论高亮与删除浮层定位问题
3.  为dio_client新增网络日志总开关,可按需开启请求响应打印
4.  修复多处代码格式与注释细节问题
2026-09-07 15:01:14 +08:00

437 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<PointRankView> createState() => _PointRankViewState();
}
class _PointRankViewState extends ConsumerState<PointRankView> {
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<String> _categories = ['本班级', '同年级', '同云校'];
static const List<String> _subTabs = ['总榜', '本周', '上周', '今日', '昨天'];
@override
void initState() {
super.initState();
_fetchUserCount();
}
Future<void> _fetchUserCount() async {
try {
final res = await UserRankApi.instance.getUserCount();
if (!mounted) return;
setState(() {
_classCount = res.classUserCount;
_gradeCount = res.gradeUserCount;
_cloudCount = res.cloudSchoolUserCount;
});
} catch (_) {
// 人数统计失败静默处理
}
}
Future<PagingResult<UserRank>> _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<UserRank>(
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),
border: active ? null : Border.all(color: 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: 4.h),
Text(
'$countText人',
style: TextStyle(
fontSize: 11,
color: active ? _primary : const Color(0xFF687FAD),
),
),
],
),
),
),
if (active) _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<UserRank>(
// 分类/子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),
Icon(
_sortDesc ? Icons.arrow_downward : Icons.arrow_upward,
size: 14,
color: _primary,
),
],
),
),
],
),
);
}
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;
}
}