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
@@ -0,0 +1,410 @@
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/api/class/api.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/providers/user_provider.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/features/class/widgets/class_update_widgets.dart';
/// ============================================================
/// 【MyUpdateDetailPage】「动态详情」页 —— 对标 Vue pages/classPackage/myInteract/myUpdateDetail/index.vue
///
/// 一、数据:从列表卡片带入初始 UserPostDto,进入后若无 id 停止;
/// 有 id 则 getUserPostDetail 增量更新评论/点赞头像/点赞状态/计数。
/// 二、展示:复用 PostCard(showCommentComposer=false,隐藏内嵌输入/预览),
/// 下方接 CommentDetail:点赞人头像 + 「全部留言」列表。
/// 三、交互:
/// - 点赞 → addUserLike(乐观更新,失败回滚)
/// - 长按留言 → 仅本人或发帖人可删 → confirm → deleteUserCommon(乐观移除)
/// ============================================================
class MyUpdateDetailPage extends ConsumerStatefulWidget {
const MyUpdateDetailPage({super.key, required this.initialPost});
final UserPostDto initialPost;
@override
ConsumerState<MyUpdateDetailPage> createState() => _MyUpdateDetailPageState();
}
class _MyUpdateDetailPageState extends ConsumerState<MyUpdateDetailPage> {
late UserPostDto _post;
@override
void initState() {
super.initState();
_post = widget.initialPost;
if (_post.id != 0) _fetchDetail();
}
/// 用详情接口增量更新评论相关字段(对标 Vue fetchDetail)
///
/// 关键:只有当 detail 确实带增量内容时才更新,且单个字段非空才覆盖。
/// 否则后端未返回评论/计数(空壳)时,会把列表带入的正确数据“清空”
/// —— 表现为初次闪现对的数据后瞬间空白。
Future<void> _fetchDetail() async {
try {
final res = await ClassApi.instance.getUserPostDetail(_post.id);
if (!mounted) return;
// 空壳判定:detail 未携带任何有效增量 → 完全信任列表带入的数据,不更新
final hasDetail =
res.commons.isNotEmpty ||
res.userLikeAvatars.isNotEmpty ||
res.replyCount > 0 ||
res.likeCount > 0 ||
res.userLikeStatus;
if (!hasDetail) return;
setState(() {
_post = _post.copyWith(
commons: res.commons.isNotEmpty ? res.commons : _post.commons,
userLikeAvatars: res.userLikeAvatars.isNotEmpty
? res.userLikeAvatars
: _post.userLikeAvatars,
userLikeStatus: res.userLikeStatus,
replyCount: res.replyCount > 0 ? res.replyCount : _post.replyCount,
likeCount: res.likeCount > 0 ? res.likeCount : _post.likeCount,
);
});
} catch (_) {
// 详情拉取失败:用列表带入的数据兜底展示
}
}
Future<void> _toggleLike() async {
final liked = !_post.userLikeStatus;
final newCount = _post.likeCount + (liked ? 1 : -1);
final old = _post;
setState(() {
_post = _post.copyWith(
userLikeStatus: liked,
likeCount: newCount < 0 ? 0 : newCount,
);
});
try {
await ClassApi.instance.addUserLike(
userPostId: _post.id,
likeStatus: liked,
);
} catch (_) {
if (!mounted) return;
setState(() => _post = old);
}
}
Future<void> _deleteComment(UserPostCommonDto c) async {
final old = _post;
// 本地新增无 id 的评论直接删,不发接口
if (c.id == 0) {
setState(() {
_post = _post.copyWith(
commons: _post.commons.where((x) => x != c).toList(),
replyCount: _post.replyCount > 0 ? _post.replyCount - 1 : 0,
);
});
return;
}
setState(() {
_post = _post.copyWith(
commons: _post.commons.where((x) => x.id != c.id).toList(),
replyCount: _post.replyCount > 0 ? _post.replyCount - 1 : 0,
);
});
try {
await ClassApi.instance.deleteUserCommon(c.id);
} catch (_) {
if (!mounted) return;
setState(() => _post = old);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F8FA),
appBar: AppBar(
backgroundColor: const Color(0xFFF5F8FA),
elevation: 0,
title: const Text(
'动态详情',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w400,
color: Color(0xFF1D2B47),
),
),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Color(0xFF1D2B47)),
onPressed: () =>
context.canPop() ? context.pop() : context.go('/class'),
),
),
body: ListView(
padding: EdgeInsets.fromLTRB(14.w, 12.h, 14.w, 24.h),
children: [
PostCard(
post: _post,
showCommentComposer: false,
onLike: _toggleLike,
onComment: (_) {},
),
SizedBox(height: 10.h),
_DetailComments(post: _post, onDelete: _deleteComment),
],
),
);
}
}
/// 详情版留言区:点赞人头像 + 全部留言(对标 LikeAndCommentDetail)
class _DetailComments extends ConsumerStatefulWidget {
const _DetailComments({required this.post, required this.onDelete});
final UserPostDto post;
final Future<void> Function(UserPostCommonDto) onDelete;
@override
ConsumerState<_DetailComments> createState() => _DetailCommentsState();
}
class _DetailCommentsState extends ConsumerState<_DetailComments> {
/// 当前长按中(背景高亮 + 显示「删除」浮层)的评论索引
int? _active;
/// 二次确认弹窗期间保持背景高亮(对标 Vue pendingDeleteIndex)
int? _pending;
UserPostDto get _post => widget.post;
/// 点浮层「删除」:先关闭浮层再弹确认框;确认弹窗期间用 _pending 保持高亮
Future<void> _handleDelete(int index, UserPostCommonDto c) async {
setState(() {
_active = null;
_pending = index;
});
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('是否删除评论'),
content: const Text('删除评论后将不可恢复,请慎重选择!'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('确定'),
),
],
),
);
if (!mounted) return;
// 弹窗关闭后无论确认/取消都移除高亮
setState(() => _pending = null);
if (ok != true) return;
await widget.onDelete(c);
}
@override
Widget build(BuildContext context) {
final me = ref.watch(currentUserProvider).value;
final myId = me?.userId ?? 0;
return Container(
width: double.infinity,
// 横向内边距改由各分区自行包裹,评论列表本身无横向内边距即可铺满卡片宽度
padding: EdgeInsets.only(top: 18.h),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15.r),
),
// 外层捕获非评论区域的点击 → 关闭「删除」浮层(对标 Vue 容器 @tap="closeDeletePopover")
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (_active != null) setState(() => _active = null);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ---------- 点赞人头像 ----------
if (_post.userLikeAvatars.isNotEmpty) ...[
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(top: 5.h),
child: Image.asset(
'assets/img/class/like-icon.png',
width: 16.w,
height: 16.w,
errorBuilder: (_, _, _) => const Icon(
Icons.favorite,
size: 16,
color: Color(0xFF3D7DF5),
),
),
),
SizedBox(width: 10.w),
Expanded(
child: Wrap(
spacing: 9.w,
runSpacing: 9.h,
children: [
for (final u in _post.userLikeAvatars)
UserCircleAvatar(url: _avatar(u), radius: 13.w),
],
),
),
],
),
),
SizedBox(height: 16.h),
],
// ---------- 全部留言 ----------
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: const Text(
'全部留言',
style: TextStyle(fontSize: 15, color: Color(0xFF1D2B47)),
),
),
SizedBox(height: 8.h),
if (_post.commons.isEmpty)
Padding(
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 12.h),
child: const Text(
'暂无留言',
style: TextStyle(fontSize: 13, color: Color(0xFFB4C2E0)),
),
)
else
for (int i = 0; i < _post.commons.length; i++)
_commentItem(_post.commons[i], i, myId),
SizedBox(height: 16.h),
],
),
),
);
}
Widget _commentItem(UserPostCommonDto c, int index, int myId) {
final active = _active == index;
final pending = _pending == index;
final canDelete = (myId == c.userId) || (myId == _post.userId);
return GestureDetector(
behavior: HitTestBehavior.opaque,
// 长按(仅可删时)→ 高亮该条 + 弹出「删除」浮层(对标 Vue @longpress)
onLongPress: canDelete ? () => setState(() => _active = index) : null,
// 单击评论本身:吸收点击、保持浮层与高亮(对标 Vue @tap.stop,避免事件冒泡到外层关闭浮层)
onTap: active ? () {} : null,
child: Container(
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: 10.h),
color: (active || pending)
? const Color(0xFFF5F8FA)
: Colors.transparent,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
UserCircleAvatar(url: _avatar(c.avatar), radius: 20.w),
SizedBox(width: 15.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
c.userName,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2B47),
),
),
Text(
_time(c.createTime),
style: const TextStyle(
fontSize: 12,
color: Color(0xFF687FAD),
),
),
],
),
SizedBox(height: 6.h),
// 文本 + 长按浮层(覆盖在文本上方居中)
Stack(
clipBehavior: Clip.none,
children: [
Text(
c.content,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF495A7A),
height: 1.5,
),
),
if (active)
Positioned(
top: -46.h,
left: 0,
right: 0,
child: Center(child: _deletePopover(index, c)),
),
],
),
],
),
),
],
),
),
);
}
/// 长按后弹出的「删除」黑色浮层(对标 Vue delete-popover)
Widget _deletePopover(int index, UserPostCommonDto c) {
const bg = Color(0xBF000000); // rgba(0,0,0,0.75)
return GestureDetector(
onTap: () => _handleDelete(index, c),
child: Container(
width: 60.w,
padding: EdgeInsets.fromLTRB(0, 8.h, 0, 0),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(8.r),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'删除',
style: TextStyle(fontSize: 14, color: Colors.white),
),
Icon(Icons.arrow_drop_down, color: bg, size: 18),
],
),
),
);
}
String _time(String t) {
if (t.isEmpty) return '';
final s = t.replaceFirst('T', ' ').replaceFirst('Z', '');
return s.length > 16 ? s.substring(0, 16) : s;
}
static String _avatar(String? raw) {
final url = ImgUrl.build(raw);
return url.isEmpty
? ImgUrl.build('/StaticResource/2026-04-03/fake-avatar.png')
: url;
}
}