flutter架 构项目基础
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
import 'package:new_learn/features/class/class_update_view.dart';
|
||||
import 'package:new_learn/features/class/point_rank_view.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【ClassPage】「班级」tab 页 —— 对标 Vue pages/tabbar/class/class.vue
|
||||
///
|
||||
/// 一、Vue 源结构(壳 + 两个子页):
|
||||
/// NavBar(透明、无返回)的 title 插槽 = 顶部 tab「班级动态 / 积分排行」
|
||||
/// content-body = ClassUpdate(tab0) + PointRank(tab1),v-show 切换
|
||||
///
|
||||
/// 二、换算规则(本工程统一):Vue 的 `N rpx` → Flutter 的 `(N/2).w/.h/.sp/.r`
|
||||
/// 例:间距 20rpx → 10.w;卡片圆角 30rpx → 15.r;字号 28rpx → 14.sp
|
||||
///
|
||||
/// 三、对应关系:
|
||||
/// v-show 切换保留状态 → IndexedStack(保持两子页状态)
|
||||
/// v-if 懒加载 → _loadedTabs 首次切到才 build
|
||||
/// switchTab 后 handleShow → TODO(业务逻辑): 切回时刷新子页数据
|
||||
///
|
||||
/// 四、底部 Tabbar 由 MainTabPage 承载,此处不重复实现。
|
||||
/// ============================================================
|
||||
class ClassPage extends StatefulWidget {
|
||||
const ClassPage({super.key});
|
||||
|
||||
@override
|
||||
State<ClassPage> createState() => _ClassPageState();
|
||||
}
|
||||
|
||||
class _ClassPageState extends State<ClassPage> {
|
||||
static const Color _titleColor = Color(0xFF1D2B47);
|
||||
static const Color _subtitleColor = Color(0xFF495A7A);
|
||||
static const Color _primary = Color(0xFF3D7DF5);
|
||||
|
||||
static const List<String> _tabs = ['班级动态', '积分排行'];
|
||||
|
||||
int _currentTab = 0;
|
||||
|
||||
/// 懒加载标记(对标 Vue 的 loadedTabs):首次切到才创建对应子页
|
||||
final List<bool> _loadedTabs = [true, false];
|
||||
|
||||
void _switchTab(int index) {
|
||||
if (_currentTab == index) return;
|
||||
setState(() {
|
||||
_loadedTabs[index] = true;
|
||||
_currentTab = index;
|
||||
});
|
||||
// TODO(业务逻辑): 对应 Vue switchTab 的 nextTick 后调用子组件 handleShow 刷新。
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
// 页面底色(class-container 的 background-color: #F5F8FA)
|
||||
const Positioned.fill(child: ColoredBox(color: Color(0xFFF5F8FA))),
|
||||
// 顶部背景图:background-size 100% 512rpx → 高 256.h
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 256.h,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(
|
||||
'/StaticResource/2026-04-03/class-header-background-small.png',
|
||||
),
|
||||
fit: BoxFit.fill,
|
||||
width: double.infinity,
|
||||
),
|
||||
),
|
||||
// 积分排行左上角装饰图(仅 tab1 显示;280rpx → 140.w)
|
||||
if (_currentTab == 1)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 140.w,
|
||||
height: 140.h,
|
||||
child: IgnorePointer(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(
|
||||
'/StaticResource/2026-07-31/img_points1785468064641.png',
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildTabBar(),
|
||||
Expanded(
|
||||
child: IndexedStack(
|
||||
index: _currentTab,
|
||||
children: [
|
||||
if (_loadedTabs[0])
|
||||
const ClassUpdateView()
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
if (_loadedTabs[1])
|
||||
const PointRankView()
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 顶部 tab 栏(对标 NavBar title 插槽)
|
||||
Widget _buildTabBar() {
|
||||
return SizedBox(
|
||||
height: 44.h,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (int i = 0; i < _tabs.length; i++) ...[
|
||||
if (i > 0) SizedBox(width: 20.w), // 两个 tab 各 margin 20rpx
|
||||
_buildTabItem(i),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabItem(int index) {
|
||||
final active = _currentTab == index;
|
||||
return GestureDetector(
|
||||
onTap: () => _switchTab(index),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: SizedBox(
|
||||
height: 44.h,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
_tabs[index],
|
||||
style: TextStyle(
|
||||
fontSize: active ? 16.sp : 14.sp,
|
||||
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
|
||||
color: active ? _titleColor : _subtitleColor,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
// 下划线指示(36rpx × 6rpx)
|
||||
Container(
|
||||
width: 18.w,
|
||||
height: 3.h,
|
||||
decoration: BoxDecoration(
|
||||
color: active ? _primary : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(2.r),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
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/components/user_circle_avatar.dart';
|
||||
import 'package:new_learn/core/models/user_model.dart';
|
||||
import 'package:new_learn/core/providers/user_provider.dart';
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
import 'package:new_learn/core/api/class/api.dart';
|
||||
import 'package:new_learn/core/api/class/model.dart';
|
||||
import 'package:new_learn/features/class/widgets/class_update_widgets.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【ClassUpdateView】「班级动态」子页 —— 对标 Vue classUpdate.vue
|
||||
///
|
||||
/// 一、Vue 源结构:
|
||||
/// 班名标题 → OnlineStatus 在线同学条 → 动态列表
|
||||
/// 列表项 SubjectCard 内按 type 分发:1=攻克任务 / 2、3=文字笔记,
|
||||
/// 再叠九宫格相册 + LikeAndComment 点赞评论栏。
|
||||
///
|
||||
/// 二、数据对接(已接通):
|
||||
/// - 班名 → currentUser(gradeLevel + graduationYear + className)
|
||||
/// - 在线同学 → getClassUsers
|
||||
/// - 动态列表 → getUserClassPost(游标分页)
|
||||
/// - 点赞/评论 → addUserLike / addUserComment(乐观更新,失败回滚)
|
||||
///
|
||||
/// 三、为什么列表不用 CommonPagedList:
|
||||
/// 点赞/评论需要对单条动态做原位更新(不重建整个列表、不丢滚动位置),
|
||||
/// CommonPagedList 把 PagingController/列表藏死在内部拿不到,无法改单条;
|
||||
/// 故这里用 ListView + 手动游标分页,list 由本 State 直接持有,改单条即 setState。
|
||||
///
|
||||
/// 四、换算规则:rpx → /2 后接 .w/.h/.sp/.r
|
||||
/// ============================================================
|
||||
class ClassUpdateView extends ConsumerStatefulWidget {
|
||||
const ClassUpdateView({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ClassUpdateView> createState() => _ClassUpdateViewState();
|
||||
}
|
||||
|
||||
class _ClassUpdateViewState extends ConsumerState<ClassUpdateView> {
|
||||
static const Color _titleColor = Color(0xFF1D2B47);
|
||||
static const Color _primary = Color(0xFF3D7DF5);
|
||||
|
||||
static const int _pageSize = 10;
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final List<UserPostDto> _posts = [];
|
||||
int _pageIndex = 1;
|
||||
int _prevPageId = 0;
|
||||
bool _loading = true; // 首屏加载中
|
||||
bool _loadingMore = false;
|
||||
bool _hasMore = true;
|
||||
bool _failed = false;
|
||||
|
||||
int _onlineCount = 0;
|
||||
List<String> _avatarUrls = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_loadFirstPage();
|
||||
_fetchClassUsers();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 80) {
|
||||
_loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
/// 班名:gradeLevel + graduationYear + "届" + 班级(1班 → (1)班)
|
||||
String _buildClassName(CurrentUserExtension? user) {
|
||||
if (user == null) return '';
|
||||
final className = user.className.trim().replaceAllMapped(
|
||||
RegExp(r'\d+'),
|
||||
(m) => '(${m.group(0)})',
|
||||
);
|
||||
return '${user.gradeLevel}${user.graduationYear}届$className';
|
||||
}
|
||||
|
||||
Future<void> _fetchClassUsers() async {
|
||||
try {
|
||||
final users = await ClassApi.instance.getClassUsers();
|
||||
final online = users.where((u) => u.isOnline).toList();
|
||||
final offline = users.where((u) => !u.isOnline).toList();
|
||||
final ordered = [...online, ...offline];
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_onlineCount = online.length;
|
||||
_avatarUrls = ordered.map((u) => _avatarUrl(u.avatar)).toList();
|
||||
});
|
||||
} catch (_) {
|
||||
// 在线状态拉取失败静默处理,不影响动态列表
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadFirstPage() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_failed = false;
|
||||
});
|
||||
try {
|
||||
final res = await ClassApi.instance.getUserClassPost(
|
||||
pageIndex: 1,
|
||||
pageSize: _pageSize,
|
||||
prevPageId: 0,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_posts
|
||||
..clear()
|
||||
..addAll(res.data);
|
||||
_pageIndex = 1;
|
||||
_prevPageId = res.data.isNotEmpty ? res.data.last.id : 0;
|
||||
_hasMore = res.data.length >= _pageSize;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_failed = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
if (_loadingMore || !_hasMore || _loading || _failed) return;
|
||||
setState(() => _loadingMore = true);
|
||||
try {
|
||||
final res = await ClassApi.instance.getUserClassPost(
|
||||
pageIndex: _pageIndex + 1,
|
||||
pageSize: _pageSize,
|
||||
prevPageId: _prevPageId,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_posts.addAll(res.data);
|
||||
_pageIndex += 1;
|
||||
if (res.data.isNotEmpty) _prevPageId = res.data.last.id;
|
||||
_hasMore = res.data.length >= _pageSize;
|
||||
_loadingMore = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loadingMore = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleLike(int index) async {
|
||||
final post = _posts[index];
|
||||
final liked = !post.userLikeStatus;
|
||||
final newCount = post.likeCount + (liked ? 1 : -1);
|
||||
_replaceAt(
|
||||
index,
|
||||
post.copyWith(
|
||||
userLikeStatus: liked,
|
||||
likeCount: newCount < 0 ? 0 : newCount,
|
||||
),
|
||||
);
|
||||
try {
|
||||
await ClassApi.instance.addUserLike(
|
||||
userPostId: post.id,
|
||||
likeStatus: liked,
|
||||
);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
_replaceAt(index, post);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submitComment(int index, String content) async {
|
||||
final text = content.trim();
|
||||
if (text.isEmpty) return;
|
||||
final post = _posts[index];
|
||||
final me = ref.read(currentUserProvider).value;
|
||||
final common = UserPostCommonDto(
|
||||
userId: me?.userId ?? 0,
|
||||
userName: me?.userName ?? '我',
|
||||
content: text,
|
||||
);
|
||||
_replaceAt(
|
||||
index,
|
||||
post.copyWith(
|
||||
replyCount: post.replyCount + 1,
|
||||
commons: [common, ...post.commons],
|
||||
),
|
||||
);
|
||||
try {
|
||||
await ClassApi.instance.addUserComment(
|
||||
userPostId: post.id,
|
||||
content: text,
|
||||
);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
_replaceAt(index, post);
|
||||
}
|
||||
}
|
||||
|
||||
void _replaceAt(int index, UserPostDto post) {
|
||||
setState(() => _posts[index] = post);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = ref.watch(currentUserProvider).value;
|
||||
final className = _buildClassName(user);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildTitle(className),
|
||||
_buildOnlineStatus(),
|
||||
Expanded(child: _buildBody()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 列表区:首屏加载 / 失败 / 空态 / 内容(含下拉刷新 + 上拉加载)
|
||||
Widget _buildBody() {
|
||||
if (_loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_failed) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'加载失败,请检查网络',
|
||||
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
FilledButton(onPressed: _loadFirstPage, child: const Text('重新加载')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_posts.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'暂无班级动态',
|
||||
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadFirstPage,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w),
|
||||
itemCount: _posts.length + (_hasMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= _posts.length) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16.h),
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return PostCard(
|
||||
post: _posts[index],
|
||||
onLike: () => _toggleLike(index),
|
||||
onComment: (content) => _submitComment(index, content),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 班名标题(class-title-section)
|
||||
Widget _buildTitle(String className) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 0),
|
||||
child: Text(
|
||||
className,
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _titleColor,
|
||||
height: 50 / 36,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 在线同学条(OnlineStatus)
|
||||
Widget _buildOnlineStatus() {
|
||||
return Container(
|
||||
height: 58.h,
|
||||
margin: EdgeInsets.fromLTRB(14.w, 12.h, 14.w, 15.h),
|
||||
padding: EdgeInsets.fromLTRB(2.w, 2.h, 5.w, 2.h),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
ImgUrl.build('/StaticResource/2026-04-06/onlineStatus-bg.png'),
|
||||
),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildOnlineLeft(),
|
||||
const Spacer(),
|
||||
_buildAvatarStack(),
|
||||
_buildAllButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOnlineLeft() {
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(width: 15.w),
|
||||
const Text('在线同学', style: TextStyle(fontSize: 14, color: _titleColor)),
|
||||
SizedBox(width: 5.w),
|
||||
Text(
|
||||
'$_onlineCount',
|
||||
style: const TextStyle(
|
||||
fontSize: 23,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _primary,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
const Text('人', style: TextStyle(fontSize: 14, color: _titleColor)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 头像组:在线在前、离线在后,最多展示 6 个
|
||||
Widget _buildAvatarStack() {
|
||||
final urls = _avatarUrls.take(6).toList();
|
||||
if (urls.isEmpty) return const SizedBox.shrink();
|
||||
final size = 25.w;
|
||||
final overlap = size * 0.94;
|
||||
return SizedBox(
|
||||
width: size + (urls.length - 1) * overlap,
|
||||
height: size,
|
||||
child: Stack(
|
||||
children: [
|
||||
for (int i = 0; i < urls.length; i++)
|
||||
Positioned(
|
||||
left: i * overlap,
|
||||
child: UserCircleAvatar(url: urls[i], radius: size / 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 右侧「全部」按钮
|
||||
Widget _buildAllButton() {
|
||||
return Container(
|
||||
width: 62.w,
|
||||
height: 34.h,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF3D7DF5), Color(0xFF3C84FF), Color(0xFF8AD1FD)],
|
||||
),
|
||||
borderRadius: BorderRadius.only(
|
||||
topRight: Radius.circular(15.r),
|
||||
bottomLeft: Radius.circular(15.r),
|
||||
bottomRight: Radius.circular(20.r),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('全部', style: TextStyle(fontSize: 14, color: Colors.white)),
|
||||
SizedBox(width: 2.w),
|
||||
Icon(Icons.chevron_right, size: 14, color: Colors.white),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 头像兜底:空 avatar 用伪造头像
|
||||
static String _avatarUrl(String? raw) {
|
||||
final url = ImgUrl.build(raw);
|
||||
return url.isEmpty
|
||||
? ImgUrl.build('/StaticResource/2026-04-03/fake-avatar.png')
|
||||
: url;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:new_learn/core/api/class/model.dart';
|
||||
import 'package:new_learn/core/components/user_circle_avatar.dart';
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【班级动态组件】由 class_update_view.dart 抽离的展示组件
|
||||
///
|
||||
/// PostCard(动态卡片,按 type 分发内容 + 相册 + 点赞评论栏)
|
||||
/// CardHeader / SubjectTag / ResolveProblemBox / AlbumDesc / AlbumGrid
|
||||
/// LikeCommentBar / MetaButton / CommentPreview / CommentInput
|
||||
///
|
||||
/// 原为 class_update_view.dart 内的私有组件,重命名去下划线后放到本文件。
|
||||
/// 头像复用 core/components/user_circle_avatar.dart。
|
||||
/// ============================================================
|
||||
|
||||
/// 动态卡片(对标 SubjectCard + ResolveProblem/文字 + Album + LikeAndComment)
|
||||
class PostCard extends StatelessWidget {
|
||||
const PostCard({
|
||||
super.key,
|
||||
required this.post,
|
||||
required this.onLike,
|
||||
required this.onComment,
|
||||
});
|
||||
|
||||
final UserPostDto post;
|
||||
final VoidCallback onLike;
|
||||
final ValueChanged<String> onComment;
|
||||
|
||||
String get _avatar {
|
||||
final url = ImgUrl.build(post.avatar);
|
||||
return url.isEmpty
|
||||
? ImgUrl.build('/StaticResource/2026-04-03/fake-avatar.png')
|
||||
: url;
|
||||
}
|
||||
|
||||
String get _name => post.userName.isEmpty ? '默认姓名' : post.userName;
|
||||
|
||||
String get _subject => post.subjectName.isEmpty ? '数学' : post.subjectName;
|
||||
|
||||
String get _time {
|
||||
final t = post.createTime;
|
||||
if (t.isEmpty) return '';
|
||||
final s = t.replaceFirst('T', ' ').replaceFirst('Z', '');
|
||||
return s.length > 16 ? s.substring(0, 16) : s;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: EdgeInsets.only(bottom: 10.h),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(15.r),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 85.h,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topRight,
|
||||
end: Alignment.bottomLeft,
|
||||
colors: [
|
||||
const Color(0xFFCEE9FF).withValues(alpha: 0.9),
|
||||
const Color(0xFFF6FBFF).withValues(alpha: 0.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 21.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CardHeader(
|
||||
avatar: _avatar,
|
||||
name: _name,
|
||||
subject: _subject,
|
||||
time: _time,
|
||||
),
|
||||
if (post.type == 1) ...[
|
||||
SizedBox(height: 5.h),
|
||||
ResolveProblemBox(desc: post.content),
|
||||
],
|
||||
if (post.type == 2 || post.type == 3) ...[
|
||||
SizedBox(height: 15.h),
|
||||
AlbumDesc(text: post.content),
|
||||
],
|
||||
if (post.urls.isNotEmpty) ...[
|
||||
SizedBox(height: 15.h),
|
||||
AlbumGrid(urls: post.urls.map(ImgUrl.build).toList()),
|
||||
],
|
||||
SizedBox(height: 10.h),
|
||||
LikeCommentBar(
|
||||
post: post,
|
||||
onLike: onLike,
|
||||
onComment: onComment,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 卡片标题:头像 + 姓名 + 学科标签 + 时间
|
||||
class CardHeader extends StatelessWidget {
|
||||
const CardHeader({
|
||||
super.key,
|
||||
required this.avatar,
|
||||
required this.name,
|
||||
required this.subject,
|
||||
required this.time,
|
||||
});
|
||||
|
||||
final String avatar;
|
||||
final String name;
|
||||
final String subject;
|
||||
final String time;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
UserCircleAvatar(url: avatar, radius: 20.w),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1D2B47),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
SubjectTag(subject: subject),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 2.h),
|
||||
Text(
|
||||
time,
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF495A7A)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 学科标签(对标 SubjectTag)
|
||||
class SubjectTag extends StatelessWidget {
|
||||
const SubjectTag({super.key, required this.subject});
|
||||
|
||||
final String subject;
|
||||
|
||||
static const Map<String, _SubjectColor> _map = {
|
||||
'数学': _SubjectColor(0xFF3D7DF5, 0xFFE5F0FF),
|
||||
'物理': _SubjectColor(0xFFA45CFF, 0xFFF5ECFF),
|
||||
'英语': _SubjectColor(0xFFE5A301, 0xFFFFF6E3),
|
||||
'化学': _SubjectColor(0xFF2DB985, 0xFFE5FAF3),
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = subject.trim();
|
||||
if (t.isEmpty) return const SizedBox.shrink();
|
||||
final c = _map[t] ?? const _SubjectColor(0xFF3D7DF5, 0xFFE5F0FF);
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 2.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(c.bg),
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
child: Text(t, style: TextStyle(fontSize: 11, color: Color(c.fg))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 攻克任务卡(对标 ResolveProblem)
|
||||
class ResolveProblemBox extends StatelessWidget {
|
||||
const ResolveProblemBox({super.key, required this.desc});
|
||||
|
||||
final String desc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.fromLTRB(19.w, 25.h, 19.w, 11.h),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFEAF4FF), Color(0xFFFFFFFF)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'刚刚攻克了',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Color(0xFF1D2B47),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 3.h),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '「$desc」',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF1D2B47),
|
||||
),
|
||||
),
|
||||
const TextSpan(
|
||||
text: ' 的所有高难考点!',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF45597E),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 纯文字笔记(album-desc,最多 2 行)
|
||||
class AlbumDesc extends StatelessWidget {
|
||||
const AlbumDesc({super.key, required this.text});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
text,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Color(0xFF495A7A),
|
||||
height: 42 / 30,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 九宫格相册(最多展示 9 张,第 9 张叠加 +N)
|
||||
class AlbumGrid extends StatelessWidget {
|
||||
const AlbumGrid({super.key, required this.urls});
|
||||
|
||||
final List<String> urls;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final displayed = urls.take(9).toList();
|
||||
return Wrap(
|
||||
spacing: 9.w,
|
||||
runSpacing: 9.h,
|
||||
children: [
|
||||
for (int i = 0; i < displayed.length; i++)
|
||||
_gridItem(displayed[i], i, urls.length),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _gridItem(String url, int index, int total) {
|
||||
final more = total > 9 && index == 8 ? total - 9 : 0;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
child: SizedBox(
|
||||
width: 99.w,
|
||||
height: 99.h,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: url,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, _, _) =>
|
||||
const ColoredBox(color: Color(0xFFF0F2F5)),
|
||||
),
|
||||
if (more > 0)
|
||||
ColoredBox(
|
||||
color: const Color(0x66000000),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'+$more',
|
||||
style: const TextStyle(
|
||||
fontSize: 25,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 点赞 / 评论栏(对标 LikeAndComment)
|
||||
class LikeCommentBar extends StatelessWidget {
|
||||
const LikeCommentBar({
|
||||
super.key,
|
||||
required this.post,
|
||||
required this.onLike,
|
||||
required this.onComment,
|
||||
});
|
||||
|
||||
final UserPostDto post;
|
||||
final VoidCallback onLike;
|
||||
final ValueChanged<String> onComment;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
MetaButton(
|
||||
icon: post.userLikeStatus
|
||||
? Icons.favorite
|
||||
: Icons.favorite_border,
|
||||
count: post.likeCount,
|
||||
active: post.userLikeStatus,
|
||||
onTap: onLike,
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
MetaButton(icon: Icons.chat_bubble_outline, count: post.replyCount),
|
||||
],
|
||||
),
|
||||
if (post.commons.isNotEmpty) ...[
|
||||
SizedBox(height: 12.h),
|
||||
CommentPreview(post: post),
|
||||
],
|
||||
SizedBox(height: 12.h),
|
||||
CommentInput(onSubmit: onComment),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MetaButton extends StatelessWidget {
|
||||
const MetaButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.count,
|
||||
this.active = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final int count;
|
||||
final bool active;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = active ? const Color(0xFF3D7DF5) : const Color(0xFFB4C2E0);
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 51.w,
|
||||
height: 22.h,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFB3C2E0).withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 13.w, color: color),
|
||||
SizedBox(width: 4.w),
|
||||
Text('$count', style: TextStyle(fontSize: 13, color: color)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 评论预览框(前 3 条 + 共 N 条)
|
||||
class CommentPreview extends StatelessWidget {
|
||||
const CommentPreview({super.key, required this.post});
|
||||
|
||||
final UserPostDto post;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.symmetric(horizontal: 19.w, vertical: 12.h),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F8FA),
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final c in post.commons.take(3))
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 8.h),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${c.userName}:',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Color(0xFF1D2B47),
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: c.content,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF495A7A),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (post.replyCount > 3)
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'共${post.replyCount}条评论',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF687FAD),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
size: 14,
|
||||
color: Color(0xFF687FAD),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 评论输入行
|
||||
class CommentInput extends StatefulWidget {
|
||||
const CommentInput({super.key, required this.onSubmit});
|
||||
|
||||
final ValueChanged<String> onSubmit;
|
||||
|
||||
@override
|
||||
State<CommentInput> createState() => _CommentInputState();
|
||||
}
|
||||
|
||||
class _CommentInputState extends State<CommentInput> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _send() {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
widget.onSubmit(text);
|
||||
_controller.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 30.h,
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
alignment: Alignment.centerLeft,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: const Color(0xFFE3ECFF)),
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF1D2B47)),
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
hintText: '写下你的评论',
|
||||
hintStyle: TextStyle(fontSize: 12, color: Color(0xFFB4C2E0)),
|
||||
),
|
||||
onSubmitted: (_) => _send(),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
GestureDetector(
|
||||
onTap: _send,
|
||||
child: Container(
|
||||
width: 51.w,
|
||||
height: 30.h,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFF8AD1FD),
|
||||
Color(0xFF3C84FF),
|
||||
Color(0xFF3D7DF5),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
child: const Text(
|
||||
'发送',
|
||||
style: TextStyle(fontSize: 13, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SubjectColor {
|
||||
const _SubjectColor(this.fg, this.bg);
|
||||
final int fg;
|
||||
final int bg;
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:new_learn/core/api/class/model.dart';
|
||||
import 'package:new_learn/core/components/user_circle_avatar.dart';
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【积分排行组件】由 point_rank_view.dart 抽离的展示组件
|
||||
///
|
||||
/// RankRow(排行榜单行)/ EnergyRuleModal(积分规则弹窗)/
|
||||
/// RuleSection(规则段落)/ TriangleClipper(白三角裁剪)
|
||||
/// 头像复用 core/components/user_circle_avatar.dart。
|
||||
/// ============================================================
|
||||
|
||||
/// 排行榜单行(对标 rankItem.vue)
|
||||
class RankRow extends StatelessWidget {
|
||||
const RankRow({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.category,
|
||||
required this.isSelf,
|
||||
});
|
||||
|
||||
final UserRank item;
|
||||
final int category;
|
||||
final bool isSelf;
|
||||
|
||||
bool get _isTop => item.rank >= 1 && item.rank <= 3;
|
||||
|
||||
String _formatPoints(double p) {
|
||||
if (p == p.roundToDouble()) return p.toInt().toString();
|
||||
return p.toString();
|
||||
}
|
||||
|
||||
String get _avatar {
|
||||
final url = ImgUrl.build(item.userAvatar);
|
||||
return url.isEmpty
|
||||
? ImgUrl.build('/StaticResource/2026-04-11/default_avatar.png')
|
||||
: url;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final subInfo = (category == 1 || category == 2) ? item.userClassName : '';
|
||||
return Container(
|
||||
height: 60.h,
|
||||
margin: EdgeInsets.only(bottom: _isTop ? 10.h : 0),
|
||||
padding: EdgeInsets.symmetric(horizontal: 10.w),
|
||||
decoration: _isTop
|
||||
? BoxDecoration(
|
||||
gradient: LinearGradient(colors: _topColors(item.rank)),
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
if (_isTop)
|
||||
CachedNetworkImage(
|
||||
imageUrl: _medalUrl(item.rank),
|
||||
width: 39.w,
|
||||
height: 60.h,
|
||||
fit: BoxFit.fill,
|
||||
errorWidget: (_, _, _) => const SizedBox(width: 39, height: 60),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
width: 36.w,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${item.rank}',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFB4C2E0),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
UserCircleAvatar(url: _avatar, radius: 22.w),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
item.userName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2B47),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isSelf) ...[
|
||||
SizedBox(width: 8.w),
|
||||
Container(
|
||||
width: 16.w,
|
||||
height: 16.h,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFB4C2E0),
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
child: const Text(
|
||||
'我',
|
||||
style: TextStyle(fontSize: 10, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (subInfo.isNotEmpty) ...[
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
subInfo,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF687FAD),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatPoints(item.points),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF3D7DF5),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 20.w),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Color> _topColors(int rank) {
|
||||
switch (rank) {
|
||||
case 1:
|
||||
return [const Color(0xFFFCF4DE), Colors.white];
|
||||
case 2:
|
||||
return [const Color(0xFFE8F0F7), Colors.white];
|
||||
default:
|
||||
return [const Color(0xFFFAEAE6), Colors.white];
|
||||
}
|
||||
}
|
||||
|
||||
String _medalUrl(int rank) {
|
||||
final name = switch (rank) {
|
||||
1 => 'ranking1Position1785292048368.png',
|
||||
2 => 'ranking2Position1785292048369.png',
|
||||
_ => 'ranking3Position1785292048369.png',
|
||||
};
|
||||
return ImgUrl.build('/StaticResource/2026-07-29/$name');
|
||||
}
|
||||
}
|
||||
|
||||
/// 积分规则弹窗(对标 EnergyRuleModal)
|
||||
class EnergyRuleModal extends StatelessWidget {
|
||||
const EnergyRuleModal({super.key, required this.onClose});
|
||||
|
||||
final VoidCallback onClose;
|
||||
|
||||
static const List<String> _rule1 = [
|
||||
'1.每日登录+1分每日提交作业+3分',
|
||||
'2.连续打卡奖励连续打卡3天+5分;连续打卡7天+12分;连续打卡14天+25分;连续打卡30天+50分',
|
||||
'3.答题正确率奖励 正确率≥80%额外+2分每攻克一个知识点+5分',
|
||||
'4.每日上传学习图片 图片>1张+2分',
|
||||
'5.笔记被设为精选+10分(每日可叠加)',
|
||||
'6.每日点赞(非同一个人同一个笔记/攻克知识点)单条+0.5分(单日上限5分)每日评论(同上)单条+1分(单日上限5分)',
|
||||
];
|
||||
|
||||
static const List<String> _rule2 = [
|
||||
'1.初始等级:0 级,0 积分',
|
||||
'2.0 升 1 级:累计积攒 100 积分',
|
||||
'3.1 升 2 级:再积攒 100 积分(累计 200)',
|
||||
'4.2 升 3 级:再积攒 100 积分(累计 300)',
|
||||
'5.3 升 4 级:再积攒 100 积分(累计 400)',
|
||||
'6.4 升 5 级:再积攒 100 积分(累计 500)',
|
||||
'7.5 升 6 级:再积攒 100 积分(累计 600)',
|
||||
'...',
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(child: ColoredBox(color: const Color(0x99000000))),
|
||||
Center(
|
||||
child: SizedBox(
|
||||
width: 301.5.w,
|
||||
height: 530.5.h,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(
|
||||
'/StaticResource/2026-06-26/pointsRule_bg1782467735027.png',
|
||||
),
|
||||
fit: BoxFit.fill,
|
||||
errorWidget: (_, _, _) =>
|
||||
const ColoredBox(color: Colors.white),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 25.w,
|
||||
right: 25.w,
|
||||
top: 198.h,
|
||||
bottom: 35.h,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
RuleSection(title: '一、积分获取规则', items: _rule1),
|
||||
SizedBox(height: 10.h),
|
||||
RuleSection(title: '二、等级晋升规则', items: _rule2),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: 90.h),
|
||||
child: GestureDetector(
|
||||
onTap: onClose,
|
||||
child: Container(
|
||||
width: 150.w,
|
||||
height: 49.h,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFF8AD1FD),
|
||||
Color(0xFF3C84FF),
|
||||
Color(0xFF3D7DF5),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(49.h),
|
||||
),
|
||||
child: const Text(
|
||||
'我知道了',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RuleSection extends StatelessWidget {
|
||||
const RuleSection({super.key, required this.title, required this.items});
|
||||
|
||||
final String title;
|
||||
final List<String> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1D2B47),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
for (final item in items)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 10.h),
|
||||
child: Text(
|
||||
item,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1D2B47),
|
||||
height: 36 / 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 白色向下小三角
|
||||
class TriangleClipper extends CustomClipper<Path> {
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
return Path()
|
||||
..moveTo(0, 0)
|
||||
..lineTo(size.width, 0)
|
||||
..lineTo(size.width / 2, size.height)
|
||||
..close();
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(covariant CustomClipper<Path> oldClipper) => false;
|
||||
}
|
||||
Reference in New Issue
Block a user