本次提交完成了班级模块的多项核心功能开发与优化: 1. 新增DELETE请求封装,支持业务删除接口且不自动重试 2. 完善UserPostCommonDto模型,新增头像字段适配详情接口 3. 重构路由架构,使用StatefulShellRoute实现Tab页面保活 4. 新增我的互动页面,包含我的动态/点赞/留言三个子页 5. 新增全部同学页面,展示班级全员信息与在线状态 6. 新增动态详情页面,支持查看完整评论与点赞头像 7. 抽离通用PostListBody组件,复用列表分页与交互逻辑 8. 优化班级列表页加载逻辑与图片预览交互 9. 新增评论删除、多图预览等细节交互功能
441 lines
13 KiB
Dart
441 lines
13 KiB
Dart
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;
|
||
}
|
||
}
|