diff --git a/lib/core/components/confirm_box.dart b/lib/core/components/confirm_box.dart new file mode 100644 index 0000000..139bb35 --- /dev/null +++ b/lib/core/components/confirm_box.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import '../utils/img_url.dart'; + +/// ============================================================ +/// 【ConfirmBox】全局二次确认弹窗 —— 对标小程序 components/ConfirmBox +/// +/// 一、与 Vue3 版的架构差异: +/// 小程序不支持动态插入 DOM,所以 Vue 版需要每个页面手动放置 +/// ``,并在 service.js 里按路由注册 controller。 +/// Flutter 的 showDialog 可以从任意 context 直接弹出(等价于 Web +/// 动态挂载),因此这里无需注册机制,单文件即可全局使用。 +/// +/// 二、用法(对标 Vue 的 `await proxy.$confirm({...})`): +/// final ok = await showConfirmBox( +/// context, +/// title: '提示', +/// content: '确定要删除吗?', +/// ); +/// 点"确定"返回 true;点"取消"、点遮罩、按返回键返回 false。 +/// ============================================================ +Future showConfirmBox( + BuildContext context, { + String title = '提示', + String content = '', + String cancelText = '取消', + String confirmText = '确定', + bool showCancel = true, + double? minWidth, // 弹窗最小宽度(逻辑像素,调用方自行 .w 换算) +}) { + return showDialog( + context: context, + barrierDismissible: true, // 点遮罩关闭 → false(对标 onPopupClose) + barrierColor: Colors.black54, + builder: (_) => ConfirmBox( + title: title, + content: content, + cancelText: cancelText, + confirmText: confirmText, + showCancel: showCancel, + minWidth: minWidth, + ), + ).then((result) => result ?? false); +} + +class ConfirmBox extends StatelessWidget { + const ConfirmBox({ + super.key, + required this.title, + required this.content, + required this.cancelText, + required this.confirmText, + required this.showCancel, + this.minWidth, + }); + + final String title; + final String content; + final String cancelText; + final String confirmText; + final bool showCancel; + final double? minWidth; + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, + child: Container( + padding: EdgeInsets.symmetric(vertical: 30.w, horizontal: 15.w), // 60rpx 30rpx + constraints: + minWidth != null ? BoxConstraints(minWidth: minWidth!) : null, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20.w), // 40rpx + image: DecorationImage( + image: NetworkImage( + ImgUrl.build('/StaticResource/2026-03-31/header-bac.png')), + fit: BoxFit.fill, // background-size: 100% 100% + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (title.isNotEmpty) + Text( + title, + style: TextStyle( + fontSize: 16.sp, // 32rpx + fontWeight: FontWeight.w600, + color: const Color(0xFF1D2B47), + ), + ), + if (content.isNotEmpty) + Padding( + padding: EdgeInsets.only(top: 18.w), // 36rpx + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 263.w), // max-width 526rpx + child: Text( + content, + style: TextStyle( + fontSize: 14.sp, // 28rpx + color: const Color(0xFF1D2B47), + ), + ), + ), + ), + Padding( + padding: EdgeInsets.only(top: 23.w), // 46rpx + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (showCancel) ...[ + _buildButton( + text: cancelText, + backgroundColor: const Color(0xFFFDFFFF), + textColor: const Color(0xFF6195F6), + onTap: () => Navigator.of(context).pop(false), + ), + SizedBox(width: 10.w), // 20rpx + ], + _buildButton( + text: confirmText, + gradient: const LinearGradient( + // CSS: linear-gradient(360deg, #8AD1FD -11.22%, #3C84FF 60.2%, #3D7DF5 100%) + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Color(0xFF8AD1FD), + Color(0xFF3C84FF), + Color(0xFF3D7DF5), + ], + stops: [0, 0.602, 1], + ), + textColor: Colors.white, + onTap: () => Navigator.of(context).pop(true), + ), + ], + ), + ), + ], + ), + ), + ); + } + + /// 胶囊按钮(对标 default-button5 渐变确定键 / default-button6 白底取消键) + Widget _buildButton({ + required String text, + required Color textColor, + required VoidCallback onTap, + Color? backgroundColor, + Gradient? gradient, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + height: 46.w, // 92rpx + padding: EdgeInsets.symmetric(horizontal: 20.w), + constraints: BoxConstraints(minWidth: 135.w), // min-width 270rpx + alignment: Alignment.center, + decoration: BoxDecoration( + color: backgroundColor, + gradient: gradient, + borderRadius: BorderRadius.circular(100), // border-radius: 100px + boxShadow: gradient != null + ? [ + // CSS: 0px 15px 11.3px rgba(61,125,245,0.08) + // (inset 内发光 Flutter 不支持,省略) + BoxShadow( + color: const Color(0x143D7DF5), + offset: Offset(0, 15.w), + blurRadius: 11.w, + ), + ] + : null, + ), + child: Text( + text, + style: TextStyle(fontSize: 16.sp, color: textColor), // 32rpx + ), + ), + ); + } +} diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart index ec0cd35..f928674 100644 --- a/lib/core/network/dio_client.dart +++ b/lib/core/network/dio_client.dart @@ -37,31 +37,32 @@ import 'package:talker_dio_logger/talker_dio_logger.dart'; /// 5. Android 真机请求网络必须加 INTERNET 权限(本工程 Manifest 已加)。 /// ============================================================ class DioClient { + /// 网络日志总开关:true = 控制台打印请求/响应数据,false = 静默 + static const bool enableNetworkLog = false; + // ---------- 1. 单例:整个 App 只有一个 Dio ---------- DioClient._() - : _dio = Dio( - BaseOptions( - // 正式环境接口:统一从 ApiConfig 取,换环境只改 api_config.dart - baseUrl: ApiConfig.baseUrl, - connectTimeout: const Duration(seconds: 10), // 连接超时 - sendTimeout: const Duration(seconds: 10), // 发送超时 - receiveTimeout: const Duration(seconds: 10), // 接收超时 - headers: { - 'Content-Type': 'application/json; charset=utf-8', - }, - ), - ) { - _dio.interceptors.add( - TalkerDioLogger( - talker: talker, - settings: const TalkerDioLoggerSettings( - printResponseTime: true, - hiddenHeaders: { - 'authorization', - }, + : _dio = Dio( + BaseOptions( + // 正式环境接口:统一从 ApiConfig 取,换环境只改 api_config.dart + baseUrl: ApiConfig.baseUrl, + connectTimeout: const Duration(seconds: 10), // 连接超时 + sendTimeout: const Duration(seconds: 10), // 发送超时 + receiveTimeout: const Duration(seconds: 10), // 接收超时 + headers: {'Content-Type': 'application/json; charset=utf-8'}, ), - ), - ); + ) { + if (enableNetworkLog) { + _dio.interceptors.add( + TalkerDioLogger( + talker: talker, + settings: const TalkerDioLoggerSettings( + printResponseTime: true, + hiddenHeaders: {'authorization'}, + ), + ), + ); + } _dio.interceptors.add(_buildInterceptors()); } @@ -89,21 +90,26 @@ class DioClient { onRequest: (options, handler) { // OSS 直传会带自己的 authorization 头(OSS4-HMAC-SHA256 签名), // 任何形式存在 authorization 都不再加 Bearer,否则会覆盖 OSS 签名 - final hasAuth = options.headers['Authorization'] != null || + final hasAuth = + options.headers['Authorization'] != null || options.headers['authorization'] != null; if (accessToken != null && !hasAuth) { options.headers['Authorization'] = 'Bearer $accessToken'; } - debugPrint('▶ 【请求】${options.method} ${options.uri}'); + if (enableNetworkLog) { + debugPrint('▶ 【请求】${options.method} ${options.uri}'); + } handler.next(options); // 放行 }, // ② 收到响应后:打印日志 onResponse: (response, handler) { - debugPrint( - '◀ 【响应】${response.statusCode} ' - '${response.requestOptions.method} ${response.requestOptions.uri}', - ); + if (enableNetworkLog) { + debugPrint( + '◀ 【响应】${response.statusCode} ' + '${response.requestOptions.method} ${response.requestOptions.uri}', + ); + } handler.next(response); // 放行 }, @@ -143,10 +149,8 @@ class DioClient { final message = switch (e.type) { DioExceptionType.connectionTimeout || DioExceptionType.sendTimeout || - DioExceptionType.receiveTimeout => - '网络连接超时,请稍后重试', - DioExceptionType.badResponse => - '服务器开小差了(${e.response?.statusCode})', + DioExceptionType.receiveTimeout => '网络连接超时,请稍后重试', + DioExceptionType.badResponse => '服务器开小差了(${e.response?.statusCode})', DioExceptionType.cancel => '请求已取消', DioExceptionType.connectionError => '网络异常,请检查网络连接', _ => '未知错误(${e.type.name})', @@ -262,8 +266,7 @@ class DioClient { DioExceptionType.connectionError || DioExceptionType.connectionTimeout || DioExceptionType.sendTimeout || - DioExceptionType.receiveTimeout => - true, + DioExceptionType.receiveTimeout => true, _ => false, }; } diff --git a/lib/features/class/class_update_view.dart b/lib/features/class/class_update_view.dart index 98b1cb6..7fc4b0f 100644 --- a/lib/features/class/class_update_view.dart +++ b/lib/features/class/class_update_view.dart @@ -330,7 +330,7 @@ class _ClassUpdateViewState extends ConsumerState { return Container( height: 58.h, margin: EdgeInsets.fromLTRB(14.w, 12.h, 14.w, 15.h), - padding: EdgeInsets.fromLTRB(2.w, 2.h, 5.w, 2.h), + // padding: EdgeInsets.fromLTRB(2.w, 2.h, 5.w, 2.h), decoration: BoxDecoration( image: DecorationImage( image: CachedNetworkImageProvider( @@ -339,12 +339,30 @@ class _ClassUpdateViewState extends ConsumerState { fit: BoxFit.fill, ), ), - child: Row( + child: Stack( + fit: StackFit.expand, children: [ - _buildOnlineLeft(), - const Spacer(), - _buildAvatarStack(), - _buildAllButton(), + LayoutBuilder( + builder: (context, rowConstraints) { + final rowWidth = rowConstraints.maxWidth; + return Row( + children: [ + _buildOnlineLeft(), + Expanded( + // 占据剩余空间,把真实可用宽度传给头像组动态计算数量 + child: LayoutBuilder( + builder: (context, constraints) => + _buildAvatarStack(constraints.maxWidth, rowWidth), + ), + ), + // 预留「全部」按钮宽度(按钮已悬浮于右上角,不在 Row 内占位) + SizedBox(width: 62.w), + ], + ); + }, + ), + // 「全部」按钮:悬浮右上角(对标 Vue position: absolute; top/right: 2rpx) + Positioned(top: 1, right: 1, child: _buildAllButton()), ], ), ); @@ -354,7 +372,38 @@ class _ClassUpdateViewState extends ConsumerState { return Row( children: [ SizedBox(width: 15.w), - const Text('在线同学', style: TextStyle(fontSize: 14, color: _titleColor)), + // 「在线同学」底部渐变高亮条(对标 Vue 的 linear-gradient 下划线) + // clipBehavior: Clip.none 允许渐变条溢出 Stack 边界(bottom 为负值时不被裁剪) + Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 0, + bottom: -4, + child: Container( + width: 59.5.w, + height: 9.h, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50.r), + gradient: const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + stops: [0, 0.3578, 1], + colors: [ + Color(0x808AECFD), // rgba(138,236,253,0.5) + Color(0x331D9CFF), // rgba(29,156,255,0.2) + Color(0x003D7DF5), // rgba(61,125,245,0) + ], + ), + ), + ), + ), + const Text( + '在线同学', + style: TextStyle(fontSize: 14, color: _titleColor), + ), + ], + ), SizedBox(width: 5.w), Text( '$_onlineCount', @@ -370,30 +419,40 @@ class _ClassUpdateViewState extends ConsumerState { ); } - /// 头像组:在线在前、离线在后,最多展示 6 个 - Widget _buildAvatarStack() { - final urls = _avatarUrls.take(6).toList(); - if (urls.isEmpty) return const SizedBox.shrink(); + /// 头像组:在线在前、离线在后,数量按剩余宽度动态计算(对标 Vue updateAvatarMaxCount,最多 20) + /// 可用宽度限制在右侧背景块内(对标 Vue 左区 40% + 44rpx 内边距): + /// 左界 = 行宽 40% + 22px,右界 = 「全部」按钮左缘;同时不超过文字右侧空间,避免压住文字 + Widget _buildAvatarStack(double maxWidth, double rowWidth) { final size = 25.w; final overlap = size * 0.94; - return SizedBox( - width: size + (urls.length - 1) * overlap, - height: size, - child: Stack( - children: [ - for (int i = 0; i < urls.length; i++) - Positioned( - left: i * overlap, - child: GestureDetector( - // 点头像 → 大图预览单张 - onTap: () => ImagePreviewViewer.showSingle( - context, - imageProvider: CachedNetworkImageProvider(urls[i]), + // 0.6 行宽 − 22px 左间距 − 62px 按钮宽 + final rightBlockAvail = rowWidth * 0.6 - 84.w; + final avail = maxWidth < rightBlockAvail ? maxWidth : rightBlockAvail; + // 首个占满 size,后续每个只前进 overlap + final fitCount = avail >= size ? ((avail - size) / overlap).floor() + 1 : 0; + final urls = _avatarUrls.take(fitCount > 20 ? 20 : fitCount).toList(); + if (urls.isEmpty) return const SizedBox.shrink(); + return Align( + alignment: Alignment.centerRight, + child: SizedBox( + width: size + (urls.length - 1) * overlap, + height: size, + child: Stack( + children: [ + for (int i = 0; i < urls.length; i++) + Positioned( + left: i * overlap, + child: GestureDetector( + // 点头像 → 大图预览单张 + onTap: () => ImagePreviewViewer.showSingle( + context, + imageProvider: CachedNetworkImageProvider(urls[i]), + ), + child: UserCircleAvatar(url: urls[i], radius: size / 2), ), - child: UserCircleAvatar(url: urls[i], radius: size / 2), ), - ), - ], + ], + ), ), ); } @@ -404,7 +463,7 @@ class _ClassUpdateViewState extends ConsumerState { onTap: () => context.push('/class/all-student'), child: Container( width: 62.w, - height: 34.h, + height: 33.5.h, alignment: Alignment.center, decoration: BoxDecoration( gradient: const LinearGradient( @@ -412,10 +471,32 @@ class _ClassUpdateViewState extends ConsumerState { end: Alignment.bottomCenter, colors: [Color(0xFF3D7DF5), Color(0xFF3C84FF), Color(0xFF8AD1FD)], ), + // 对标 Vue border-radius: 0 30rpx 0 40rpx(右上 15 / 左下 20,其余直角) borderRadius: BorderRadius.only( topRight: Radius.circular(15.r), - bottomLeft: Radius.circular(15.r), - bottomRight: Radius.circular(20.r), + bottomLeft: Radius.circular(20.r), + ), + // 对标 Vue box-shadow 外层:0 15rpx 11.3rpx rgba(61,125,245,0.08) + boxShadow: const [ + BoxShadow( + color: Color(0x143D7DF5), + offset: Offset(0, 7.5), + blurRadius: 5.65, + ), + ], + ), + // 对标 Vue box-shadow 内层 inset 0 2rpx 18.4rpx rgba(183,236,255,0.56) + // Flutter 无内阴影,用顶部渐变近似(浅蓝光晕从顶边向下淡出) + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.only( + topRight: Radius.circular(15.r), + bottomLeft: Radius.circular(20.r), + ), + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0, 0.3], + colors: [Color(0x8FB7ECFF), Color(0x00B7ECFF)], ), ), child: Row( diff --git a/lib/features/class/my_update_detail_page.dart b/lib/features/class/my_update_detail_page.dart index 3a247f1..5c2dbe6 100644 --- a/lib/features/class/my_update_detail_page.dart +++ b/lib/features/class/my_update_detail_page.dart @@ -42,7 +42,7 @@ class _MyUpdateDetailPageState extends ConsumerState { /// 用详情接口增量更新评论相关字段(对标 Vue fetchDetail) /// /// 关键:只有当 detail 确实带增量内容时才更新,且单个字段非空才覆盖。 - /// 否则后端未返回评论/计数(空壳)时,会把列表带入的正确数据“清空” + /// 否则后端未返回评论/计数(空壳)时,会把列表带入的正确数据"清空" /// —— 表现为初次闪现对的数据后瞬间空白。 Future _fetchDetail() async { try { @@ -144,14 +144,25 @@ class _MyUpdateDetailPageState extends ConsumerState { 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 _links = {}; + LayerLink _linkFor(int index) => _links.putIfAbsent(index, () => LayerLink()); + UserPostDto get _post => widget.post; - /// 点浮层「删除」:先关闭浮层再弹确认框;确认弹窗期间用 _pending 保持高亮 + /// 点浮层「删除」→ 关浮层 + 弹确认框 Future _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 = []; + 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), + ), + ], ), ); } diff --git a/lib/features/class/point_rank_view.dart b/lib/features/class/point_rank_view.dart index 19bb46c..92de141 100644 --- a/lib/features/class/point_rank_view.dart +++ b/lib/features/class/point_rank_view.dart @@ -176,26 +176,30 @@ class _PointRankViewState extends ConsumerState { borderRadius: BorderRadius.circular(8.r), border: active ? null : Border.all(color: Colors.white, width: 1), ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - _categories[index], - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: active ? _primary : const Color(0xFF687FAD), + // Web 端回退字体行高偏大,固定高度可能溢出 → 内容放不下时自动缩小 + child: FittedBox( + fit: BoxFit.scaleDown, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _categories[index], + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: active ? _primary : const Color(0xFF687FAD), + ), ), - ), - SizedBox(height: 4.h), - Text( - '$countText人', - style: TextStyle( - fontSize: 11, - color: active ? _primary : const Color(0xFF687FAD), + SizedBox(height: 4.h), + Text( + '$countText人', + style: TextStyle( + fontSize: 11, + color: active ? _primary : const Color(0xFF687FAD), + ), ), - ), - ], + ], + ), ), ), if (active) _buildTriangle(), diff --git a/lib/features/class/widgets/class_update_widgets.dart b/lib/features/class/widgets/class_update_widgets.dart index cdc89c2..84cb257 100644 --- a/lib/features/class/widgets/class_update_widgets.dart +++ b/lib/features/class/widgets/class_update_widgets.dart @@ -1,3 +1,5 @@ +import 'dart:ui' as ui; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -25,6 +27,7 @@ class PostCard extends StatelessWidget { required this.onLike, required this.onComment, this.showCommentComposer = true, + this.hasCardBackground = true, }); final UserPostDto post; @@ -34,6 +37,9 @@ class PostCard extends StatelessWidget { /// 是否显示卡片内嵌的评论预览 + 输入框(列表页 true;详情页由全量留言接管) final bool showCommentComposer; + /// 是否显示白色卡片背景(列表页 true;详情页与留言区共用一张大卡片 → false) + final bool hasCardBackground; + String get _avatar { final url = ImgUrl.build(post.avatar); return url.isEmpty @@ -54,6 +60,54 @@ class PostCard extends StatelessWidget { @override Widget build(BuildContext context) { + final card = Stack( + children: [ + Positioned( + top: 0, + left: 0, + right: 0, + height: 85.h, + child: IgnorePointer( + // 卡片顶部右上角光晕,对标 Vue SubjectCard::before 的双层 radial-gradient + child: CustomPaint(painter: _CardTopGlowPainter()), + ), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 21.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CardHeader( + avatar: _avatar, + name: _name, + subject: _subject, + time: _time, + ), + if (post.type == 1) ...[ + SizedBox(height: 5.h), + ResolveProblemBox(desc: post.content), + ], + if (post.type == 2 || post.type == 3) ...[ + SizedBox(height: 15.h), + AlbumDesc(text: post.content), + ], + if (post.urls.isNotEmpty) ...[ + SizedBox(height: 15.h), + AlbumGrid(urls: post.urls.map(ImgUrl.build).toList()), + ], + SizedBox(height: 10.h), + LikeCommentBar( + post: post, + onLike: onLike, + onComment: onComment, + showComposer: showCommentComposer, + ), + ], + ), + ), + ], + ); + if (!hasCardBackground) return card; return Container( margin: EdgeInsets.only(bottom: 10.h), clipBehavior: Clip.antiAlias, @@ -61,67 +115,65 @@ class PostCard extends StatelessWidget { color: Colors.white, borderRadius: BorderRadius.circular(15.r), ), - child: Stack( - children: [ - Positioned( - top: 0, - left: 0, - right: 0, - height: 85.h, - child: IgnorePointer( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topRight, - end: Alignment.bottomLeft, - colors: [ - const Color(0xFFCEE9FF).withValues(alpha: 0.9), - const Color(0xFFF6FBFF).withValues(alpha: 0.0), - ], - ), - ), - ), - ), - ), - Padding( - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 21.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CardHeader( - avatar: _avatar, - name: _name, - subject: _subject, - time: _time, - ), - if (post.type == 1) ...[ - SizedBox(height: 5.h), - ResolveProblemBox(desc: post.content), - ], - if (post.type == 2 || post.type == 3) ...[ - SizedBox(height: 15.h), - AlbumDesc(text: post.content), - ], - if (post.urls.isNotEmpty) ...[ - SizedBox(height: 15.h), - AlbumGrid(urls: post.urls.map(ImgUrl.build).toList()), - ], - SizedBox(height: 10.h), - LikeCommentBar( - post: post, - onLike: onLike, - onComment: onComment, - showComposer: showCommentComposer, - ), - ], - ), - ), - ], - ), + child: card, ); } } +/// 卡片顶部光晕:对标 Vue `SubjectCard::before` 的两层 radial-gradient +/// (椭圆均以右上角为圆心,CSS 先写的在上层): +/// radial-gradient(120% 85% at 100% 0%, +/// rgba(206,233,255,1) 0%, rgba(206,233,255,.26) 10%, rgba(255,255,255,0) 58%) +/// radial-gradient(75% 55% at 100% 0%, +/// rgba(196,226,255,.88) 0%, rgba(255,255,255,1) 64%) +class _CardTopGlowPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + // 先画下层(更小、偏白的光晕),再画上层(更大的浅蓝光晕) + _paintGlow( + canvas, + size, + rx: size.width * 0.75, + ry: size.height * 0.55, + colors: [const Color(0xFFC4E2FF).withValues(alpha: 0.88), Colors.white], + stops: const [0, 0.64], + ); + _paintGlow( + canvas, + size, + rx: size.width * 1.20, + ry: size.height * 0.85, + colors: [ + const Color(0xFFCEE9FF), + const Color(0xFFCEE9FF).withValues(alpha: 0.26), + Colors.white.withValues(alpha: 0.0), + ], + stops: const [0, 0.10, 0.58], + ); + } + + /// 用 canvas.scale 把圆形径向渐变拉成 CSS 的椭圆(圆心 = 右上角) + void _paintGlow( + Canvas canvas, + Size size, { + required double rx, + required double ry, + required List colors, + required List stops, + }) { + canvas.save(); + canvas.translate(size.width, 0); + canvas.scale(rx, ry); + final Paint paint = Paint() + ..shader = ui.Gradient.radial(Offset.zero, 1.0, colors, stops); + canvas.drawRect(const Rect.fromLTWH(-1, 0, 1, 1), paint); + canvas.restore(); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + /// 卡片标题:头像 + 姓名 + 学科标签 + 时间 class CardHeader extends StatelessWidget { const CardHeader({