Files
App.K12.Student.Miniapp_Flu…/lib/features/class/class_update_view.dart
T
崔宝新 586c75f841 feat: 完成班级模块全量功能迭代
本次提交完成了班级模块的多项核心功能开发与优化:
1. 新增DELETE请求封装,支持业务删除接口且不自动重试
2. 完善UserPostCommonDto模型,新增头像字段适配详情接口
3. 重构路由架构,使用StatefulShellRoute实现Tab页面保活
4. 新增我的互动页面,包含我的动态/点赞/留言三个子页
5. 新增全部同学页面,展示班级全员信息与在线状态
6. 新增动态详情页面,支持查看完整评论与点赞头像
7. 抽离通用PostListBody组件,复用列表分页与交互逻辑
8. 优化班级列表页加载逻辑与图片预览交互
9. 新增评论删除、多图预览等细节交互功能
2026-09-07 09:20:06 +08:00

441 lines
13 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:go_router/go_router.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/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;
// 优先用总条数判断是否还有更多;total 不可用时退回"本页满页才继续"
_hasMore = res.total > 0
? res.data.length < res.total
: 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;
// 累计长度 vs 总条数,即使每页都返回满页也能在到底后正确收尾
_hasMore = res.total > 0
? _posts.length < res.total
: 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 + 1,
itemBuilder: (context, index) {
if (index >= _posts.length) {
// 底部占位:加载中显示菊花,没有更多则显示提示文案
if (_hasMore) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 16.h),
child: const Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2.5),
),
),
);
}
return Padding(
padding: EdgeInsets.symmetric(vertical: 20.h),
child: Center(
child: Text(
'没有更多了',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFFB4C2E0),
),
),
),
);
}
final post = _posts[index];
return GestureDetector(
onTap: () => context.push('/class/my-update-detail', extra: post),
child: PostCard(
post: post,
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: GestureDetector(
// 点头像 → 大图预览单张
onTap: () => ImagePreviewViewer.showSingle(
context,
imageProvider: CachedNetworkImageProvider(urls[i]),
),
child: UserCircleAvatar(url: urls[i], radius: size / 2),
),
),
],
),
);
}
/// 右侧「全部」按钮 → 全部同学页
Widget _buildAllButton() {
return GestureDetector(
onTap: () => context.push('/class/all-student'),
child: 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;
}
}