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.  修复多处代码格式与注释细节问题
This commit is contained in:
崔宝新
2026-09-07 15:01:14 +08:00
parent 586c75f841
commit 7175bf3054
6 changed files with 659 additions and 287 deletions
+196 -148
View File
@@ -42,7 +42,7 @@ class _MyUpdateDetailPageState extends ConsumerState<MyUpdateDetailPage> {
/// 用详情接口增量更新评论相关字段(对标 Vue fetchDetail)
///
/// 关键:只有当 detail 确实带增量内容时才更新,且单个字段非空才覆盖。
/// 否则后端未返回评论/计数(空壳)时,会把列表带入的正确数据“清空”
/// 否则后端未返回评论/计数(空壳)时,会把列表带入的正确数据"清空"
/// —— 表现为初次闪现对的数据后瞬间空白。
Future<void> _fetchDetail() async {
try {
@@ -144,14 +144,25 @@ class _MyUpdateDetailPageState extends ConsumerState<MyUpdateDetailPage> {
body: ListView(
padding: EdgeInsets.fromLTRB(14.w, 12.h, 14.w, 24.h),
children: [
PostCard(
post: _post,
showCommentComposer: false,
onLike: _toggleLike,
onComment: (_) {},
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),
],
),
),
SizedBox(height: 10.h),
_DetailComments(post: _post, onDelete: _deleteComment),
],
),
);
@@ -176,9 +187,13 @@ class _DetailCommentsState extends ConsumerState<_DetailComments> {
/// 二次确认弹窗期间保持背景高亮(对标 Vue pendingDeleteIndex)
int? _pending;
/// 每条评论的定位锚点(懒加载,始终创建)
final Map<int, LayerLink> _links = {};
LayerLink _linkFor(int index) => _links.putIfAbsent(index, () => LayerLink());
UserPostDto get _post => widget.post;
/// 点浮层「删除」:先关闭浮层再弹确认框;确认弹窗期间用 _pending 保持高亮
/// 点浮层「删除」→ 关浮层 + 弹确认框
Future<void> _handleDelete(int index, UserPostCommonDto c) async {
setState(() {
_active = null;
@@ -202,7 +217,6 @@ class _DetailCommentsState extends ConsumerState<_DetailComments> {
),
);
if (!mounted) return;
// 弹窗关闭后无论确认/取消都移除高亮
setState(() => _pending = null);
if (ok != true) return;
await widget.onDelete(c);
@@ -212,96 +226,135 @@ class _DetailCommentsState extends ConsumerState<_DetailComments> {
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),
),
// 外层捕获非评论区域的点击 → 关闭「删除」浮层(对标 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)),
),
// 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,
),
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),
],
),
),
// 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,
// 长按(仅可删时)→ 高亮该条 + 弹出「删除」浮层(对标 Vue @longpress)
onLongPress: canDelete ? () => setState(() => _active = index) : null,
// 单击评论本身:吸收点击、保持浮层与高亮(对标 Vue @tap.stop,避免事件冒泡到外层关闭浮层)
onLongPress: canDelete
? () {
// 确保 link 已创建
_linkFor(index);
setState(() => _active = index);
}
: null,
// active 时吸收点击 → 不冒泡到外层关闭浮层
onTap: active ? () {} : null,
child: Container(
width: double.infinity,
@@ -309,88 +362,83 @@ class _DetailCommentsState extends ConsumerState<_DetailComments> {
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),
// 包整个 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),
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)),
),
],
),
],
],
),
SizedBox(height: 6.h),
_commentText(c.content),
],
),
),
),
],
],
),
),
),
);
}
/// 长按后弹出的「删除」黑色浮层(对标 Vue delete-popover)
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: 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(
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),
),
Icon(Icons.arrow_drop_down, color: bg, size: 18),
],
),
),
// 三角:往上拉 4px 紧贴黑框(Icons.arrow_drop_down 有内置留白)
Transform.translate(
offset: const Offset(0, -9.5),
child: Icon(Icons.arrow_drop_down, color: bg, size: 22),
),
],
),
);
}