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. 修复多处代码格式与注释细节问题
459 lines
15 KiB
Dart
459 lines
15 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: [
|
||
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;
|
||
}
|
||
}
|