flutter架 构项目基础

This commit is contained in:
崔宝新
2026-09-04 15:22:20 +08:00
parent 70967229d0
commit 0c5aee59c0
265 changed files with 35484 additions and 0 deletions
+432
View File
@@ -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;
}
}