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