feat: 完成班级模块全量功能迭代

本次提交完成了班级模块的多项核心功能开发与优化:
1. 新增DELETE请求封装,支持业务删除接口且不自动重试
2. 完善UserPostCommonDto模型,新增头像字段适配详情接口
3. 重构路由架构,使用StatefulShellRoute实现Tab页面保活
4. 新增我的互动页面,包含我的动态/点赞/留言三个子页
5. 新增全部同学页面,展示班级全员信息与在线状态
6. 新增动态详情页面,支持查看完整评论与点赞头像
7. 抽离通用PostListBody组件,复用列表分页与交互逻辑
8. 优化班级列表页加载逻辑与图片预览交互
9. 新增评论删除、多图预览等细节交互功能
This commit is contained in:
崔宝新
2026-09-07 09:20:06 +08:00
parent 0c5aee59c0
commit 586c75f841
11 changed files with 1331 additions and 87 deletions
@@ -2,6 +2,7 @@ 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/image_preview_viewer.dart';
import 'package:new_learn/core/components/user_circle_avatar.dart';
import 'package:new_learn/core/utils/img_url.dart';
@@ -23,12 +24,16 @@ class PostCard extends StatelessWidget {
required this.post,
required this.onLike,
required this.onComment,
this.showCommentComposer = true,
});
final UserPostDto post;
final VoidCallback onLike;
final ValueChanged<String> onComment;
/// 是否显示卡片内嵌的评论预览 + 输入框(列表页 true;详情页由全量留言接管)
final bool showCommentComposer;
String get _avatar {
final url = ImgUrl.build(post.avatar);
return url.isEmpty
@@ -106,6 +111,7 @@ class PostCard extends StatelessWidget {
post: post,
onLike: onLike,
onComment: onComment,
showComposer: showCommentComposer,
),
],
),
@@ -295,11 +301,26 @@ class AlbumGrid extends StatelessWidget {
runSpacing: 9.h,
children: [
for (int i = 0; i < displayed.length; i++)
_gridItem(displayed[i], i, urls.length),
GestureDetector(
onTap: () => _preview(context, urls, i),
child: _gridItem(displayed[i], i, urls.length),
),
],
);
}
/// 点击相册图:进入整组大图预览,定位到点中的那张
void _preview(BuildContext context, List<String> allUrls, int index) {
ImagePreviewViewer.show(
context,
items: [
for (final u in allUrls)
PreviewImageItem(imageProvider: CachedNetworkImageProvider(u)),
],
initialIndex: index,
);
}
Widget _gridItem(String url, int index, int total) {
final more = total > 9 && index == 8 ? total - 9 : 0;
return ClipRRect(
@@ -344,12 +365,16 @@ class LikeCommentBar extends StatelessWidget {
required this.post,
required this.onLike,
required this.onComment,
this.showComposer = true,
});
final UserPostDto post;
final VoidCallback onLike;
final ValueChanged<String> onComment;
/// 是否显示评论预览 + 输入框(列表页 true;详情页 false,由全量留言列表接管)
final bool showComposer;
@override
Widget build(BuildContext context) {
return Column(
@@ -370,12 +395,14 @@ class LikeCommentBar extends StatelessWidget {
MetaButton(icon: Icons.chat_bubble_outline, count: post.replyCount),
],
),
if (post.commons.isNotEmpty) ...[
if (showComposer) ...[
if (post.commons.isNotEmpty) ...[
SizedBox(height: 12.h),
CommentPreview(post: post),
],
SizedBox(height: 12.h),
CommentPreview(post: post),
CommentInput(onSubmit: onComment),
],
SizedBox(height: 12.h),
CommentInput(onSubmit: onComment),
],
);
}
@@ -517,20 +544,25 @@ class _CommentInputState extends State<CommentInput> {
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Container(
height: 30.h,
padding: EdgeInsets.symmetric(horizontal: 12.w),
alignment: Alignment.centerLeft,
constraints: BoxConstraints(minHeight: 30.h),
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 5.h),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE3ECFF)),
borderRadius: BorderRadius.circular(10.r),
),
// multiline:键盘右下角为「换行」,可换行写多条评论
child: TextField(
controller: _controller,
style: const TextStyle(fontSize: 12, color: Color(0xFF1D2B47)),
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
minLines: 1,
maxLines: 4,
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
@@ -0,0 +1,265 @@
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/providers/user_provider.dart';
import 'package:new_learn/features/class/widgets/class_update_widgets.dart';
/// ============================================================
/// 【PostListBody】通用动态列表(游标分页 + 点赞/评论乐观更新)
///
/// 从 class_update_view.dart 的列表逻辑抽离,去掉班名/在线条,供
/// 「我的互动」三个子页(我的动态/我的点赞/我的留言)复用。
///
/// 参数:
/// fetchPage —— 单页动态加载器(业务签名统一 {pageIndex,pageSize,prevPageId})
/// emptyText —— 空态文案
/// removeOnUnlike—— 取消点赞时是否从列表移除该项(仅"我的点赞"用)
/// onTapPost —— 点卡片进详情(可选)
///
/// 为什么不用 CommonPagedList:点赞/评论需对单条动态原位更新、保留滚动位置,
/// 故用 ListView + 手动游标分页,list 由本 State 直接持有。
/// ============================================================
class PostListBody extends ConsumerStatefulWidget {
const PostListBody({
super.key,
required this.fetchPage,
required this.emptyText,
this.removeOnUnlike = false,
this.onTapPost,
});
final Future<UserPostResponse> Function({
required int pageIndex,
required int pageSize,
required int prevPageId,
}) fetchPage;
final String emptyText;
final bool removeOnUnlike;
final ValueChanged<UserPostDto>? onTapPost;
@override
ConsumerState<PostListBody> createState() => _PostListBodyState();
}
class _PostListBodyState extends ConsumerState<PostListBody> {
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;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadFirstPage();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 80) {
_loadMore();
}
}
Future<void> _loadFirstPage() async {
setState(() {
_loading = true;
_failed = false;
});
try {
final res = await widget.fetchPage(
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 widget.fetchPage(
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);
// 我的点赞:取消点赞时直接移除该项
if (widget.removeOnUnlike && !liked) {
setState(() => _posts.removeAt(index));
} else {
_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;
// 失败回滚:移除场景回插到原位,更新场景恢复旧值
if (widget.removeOnUnlike && !liked) {
if (index <= _posts.length) {
setState(() => _posts.insert(index, post));
}
} else {
_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) {
return _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(
widget.emptyText,
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),
),
),
);
}
final post = _posts[index];
return GestureDetector(
onTap: widget.onTapPost == null
? null
: () => widget.onTapPost!(post),
child: PostCard(
post: post,
onLike: () => _toggleLike(index),
onComment: (content) => _submitComment(index, content),
),
);
},
),
);
}
}