flutter架 构项目基础
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
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),
|
||||
),
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user