Files
App.K12.Student.Miniapp_Flu…/lib/features/class/my_update_detail_page.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

411 lines
14 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: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;
}
}