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({