Files
App.K12.Student.Miniapp_Flu…/lib/features/class/my_update_detail_page.dart
T
崔宝新 d5f73cfbb5 refactor(class): 优化接口请求与页面细节
1. 统一处理后端统一包结构,自动解析data字段
2. 新增点赞评论栏显示控制参数,详情页隐藏该栏
3. 简化详情页数据更新逻辑,对齐Vue版本实现
4. 重构确认弹窗组件,优化样式与弹窗尺寸
5. 为评论项添加水平边距,优化列表排版
2026-09-07 16:02:14 +08:00

443 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/confirm_box.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)
///
/// 简化逻辑:接口成功返回就直接用详情数据覆盖评论相关字段,
/// 和 Vue 版一致(Vue 里 res.commons !== undefined 就直接覆盖)。
Future<void> _fetchDetail() async {
try {
final res = await ClassApi.instance.getUserPostDetail(_post.id);
if (!mounted) return;
setState(() {
_post = _post.copyWith(
commons: res.commons,
userLikeAvatars: res.userLikeAvatars,
userLikeStatus: res.userLikeStatus,
replyCount: res.replyCount,
likeCount: res.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: [
Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15.r),
),
child: Column(
children: [
PostCard(
post: _post,
showCommentComposer: false,
onLike: _toggleLike,
onComment: (_) {},
hasCardBackground: false,
showLikeCommentBar: false,
),
_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;
/// 每条评论的定位锚点(懒加载,始终创建)
final Map<int, LayerLink> _links = {};
LayerLink _linkFor(int index) => _links.putIfAbsent(index, () => LayerLink());
UserPostDto get _post => widget.post;
/// 点浮层「删除」→ 关浮层 + 弹确认框
Future<void> _handleDelete(int index, UserPostCommonDto c) async {
setState(() {
_active = null;
_pending = index;
});
final ok = await showConfirmBox(
context,
title: '是否删除评论',
content: '删除评论后将不可恢复,请慎重选择!',
);
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;
// 构建评论项时顺手收集所有 LayerLink(确保全部创建好)
final items = <Widget>[];
if (_post.userLikeAvatars.isNotEmpty) {
items.add(
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),
],
),
),
],
),
),
);
items.add(SizedBox(height: 16.h));
}
items.add(
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: const Text(
'全部留言',
style: TextStyle(fontSize: 15, color: Color(0xFF1D2B47)),
),
),
);
items.add(SizedBox(height: 8.h));
if (_post.commons.isEmpty) {
items.add(
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++) {
items.add(
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: _commentItem(_post.commons[i], i, myId),
),
);
}
}
items.add(SizedBox(height: 16.h));
return Container(
width: double.infinity,
padding: EdgeInsets.only(top: 18.h),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15.r),
),
// Stack 包裹 Column + 悬浮 Follower
// Follower 放在最后绘制 → 浮层在最顶层,既能显示也能接收到点击
child: Stack(
clipBehavior: Clip.none, // 允许浮层溢出 Stack 边界(向上悬浮在评论上方)
children: [
// 主内容
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (_active != null) setState(() => _active = null);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: items,
),
),
// Follower 悬浮层:active 评论的「删除」浮层(最后绘制 → 顶层)
_buildPopoverFollower(),
],
),
);
}
/// 悬浮层:CompositedTransformFollower 定位到 active 评论文本上方
///
/// 关键:Follower 直接放在 Stack 里(不是 OverlayPortal),
/// 和 Target 在同一 widget 树 → link 一定能连上,坐标计算无跨 Overlay 问题。
Widget _buildPopoverFollower() {
final index = _active;
if (index == null || index >= _post.commons.length) {
return const SizedBox.shrink();
}
return CompositedTransformFollower(
link: _linkFor(index),
targetAnchor: Alignment.topCenter,
followerAnchor: Alignment.bottomCenter,
showWhenUnlinked: false,
child: _deletePopover(index, _post.commons[index]),
);
}
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,
onLongPress: canDelete
? () {
// 确保 link 已创建
_linkFor(index);
setState(() => _active = index);
}
: null,
// active 时吸收点击 → 不冒泡到外层关闭浮层
onTap: active ? () {} : null,
child: Container(
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: 10.h),
color: (active || pending)
? const Color(0xFFF5F8FA)
: Colors.transparent,
// 包整个 Row 作为锚点 → 浮层对准整条评论 item 的水平中心
child: CompositedTransformTarget(
link: _linkFor(index),
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),
_commentText(c.content),
],
),
),
],
),
),
),
);
}
Widget _commentText(String content) => Text(
content,
style: const TextStyle(fontSize: 14, color: Color(0xFF495A7A), height: 1.5),
);
/// 「删除」黑色浮层:黑框在上 + 三角在下,三角紧贴黑框底部指向下方文本
Widget _deletePopover(int index, UserPostCommonDto c) {
const bg = Color(0xBF000000); // rgba(0,0,0,0.75)
return GestureDetector(
onTap: () => _handleDelete(index, c),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 黑框在上
Container(
width: 60.w,
padding: EdgeInsets.symmetric(vertical: 8.h),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(8.r),
),
child: const Text(
'删除',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.white),
),
),
// 三角:往上拉 4px 紧贴黑框(Icons.arrow_drop_down 有内置留白)
Transform.translate(
offset: const Offset(0, -9.5),
child: Icon(Icons.arrow_drop_down, color: bg, size: 22),
),
],
),
);
}
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;
}
}