Files
App.K12.Student.Miniapp_Flu…/lib/features/class/my_update_detail_page.dart
T
崔宝新 7175bf3054 feat: 新增全局确认弹窗、多页面UI优化与网络日志开关
1.  新增全局二次确认弹窗组件confirm_box,支持自定义参数并对标小程序实现
2.  优化班级板块多处UI:
    - 修复point_rank_view中Web端字体溢出问题,使用FittedBox自适应缩放
    - 重构class_update_widgets的PostCard,新增卡片背景控制开关并替换光晕实现
    - 优化class_update_view的在线同学栏与头像组布局,支持动态计算展示数量
    - 调整my_update_detail_page的评论区结构,修复评论高亮与删除浮层定位问题
3.  为dio_client新增网络日志总开关,可按需开启请求响应打印
4.  修复多处代码格式与注释细节问题
2026-09-07 15:01:14 +08:00

459 lines
15 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: [
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,
),
_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 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;
// 构建评论项时顺手收集所有 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(_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;
}
}