feat: 完成班级模块全量功能迭代

本次提交完成了班级模块的多项核心功能开发与优化:
1. 新增DELETE请求封装,支持业务删除接口且不自动重试
2. 完善UserPostCommonDto模型,新增头像字段适配详情接口
3. 重构路由架构,使用StatefulShellRoute实现Tab页面保活
4. 新增我的互动页面,包含我的动态/点赞/留言三个子页
5. 新增全部同学页面,展示班级全员信息与在线状态
6. 新增动态详情页面,支持查看完整评论与点赞头像
7. 抽离通用PostListBody组件,复用列表分页与交互逻辑
8. 优化班级列表页加载逻辑与图片预览交互
9. 新增评论删除、多图预览等细节交互功能
This commit is contained in:
崔宝新
2026-09-07 09:20:06 +08:00
parent 0c5aee59c0
commit 586c75f841
11 changed files with 1331 additions and 87 deletions
+83
View File
@@ -74,6 +74,89 @@ class ClassApi {
'content': content,
});
}
/// 我的点赞列表 —— GET /api/v1/UserLike/GetUserLike(游标分页)
///
/// 后端每项包一层 {userPost: {...}},这里统一解包成 UserPostDto,
/// 页面拿到的是与班级动态一致的结构,直接复用 PostCard。
Future<UserPostResponse> getMyLikePage({
int pageIndex = 1,
int pageSize = 10,
int prevPageId = 0,
}) async {
final data = await DioClient.instance.get(
'/api/v1/UserLike/GetUserLike',
query: {
'pageIndex': pageIndex,
'pageSize': pageSize,
'prevPageId': prevPageId,
},
);
return _unwrapUserPostResponse(data);
}
/// 我的留言列表 —— GET /api/v1/UserPost/GetUserCommon(游标分页)
Future<UserPostResponse> getMyCommentPage({
int pageIndex = 1,
int pageSize = 10,
int prevPageId = 0,
}) async {
final data = await DioClient.instance.get(
'/api/v1/UserPost/GetUserCommon',
query: {
'pageIndex': pageIndex,
'pageSize': pageSize,
'prevPageId': prevPageId,
},
);
return _unwrapUserPostResponse(data);
}
/// 动态详情(含全量留言 / 点赞头像)—— GET /api/v1/UserPost/GetUserPostDetail/{id}
Future<UserPostDto> getUserPostDetail(int id) async {
final data = await DioClient.instance.get(
'/api/v1/UserPost/GetUserPostDetail/$id',
);
return UserPostDto.fromJson(data as Map<String, dynamic>);
}
/// 删除评论 —— DELETE /api/v1/UserPost/DeleteUserCommon/{id}
Future<void> deleteUserCommon(int commentId) async {
await DioClient.instance.delete(
'/api/v1/UserPost/DeleteUserCommon/$commentId',
);
}
}
/// 解包「我的点赞/留言」列表:每项若包了 userPost 则取内层,再统一转 UserPostDto
UserPostResponse _unwrapUserPostResponse(dynamic data) {
final raw = data is Map<String, dynamic> ? data : {'data': data};
final payload = raw['data'] is Map<String, dynamic>
? raw['data'] as Map<String, dynamic>
: raw;
final list = payload['data'];
final posts = <UserPostDto>[];
if (list is List) {
for (final e in list) {
if (e is! Map<String, dynamic>) continue;
final inner = e['userPost'] is Map<String, dynamic>
? e['userPost'] as Map<String, dynamic>
: e;
posts.add(UserPostDto.fromJson(inner));
}
}
return UserPostResponse(
data: posts,
total: _toInt(payload['total']),
pageIndex: _toInt(payload['pageIndex']),
pageSize: _toInt(payload['pageSize']),
);
}
int _toInt(dynamic v) {
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
/// 积分排行榜接口
+11 -4
View File
@@ -21,6 +21,7 @@ class UserPostCommonDto {
this.content = '',
this.createTime = '',
this.id = 0,
this.avatar = '',
});
final int userId;
@@ -28,6 +29,7 @@ class UserPostCommonDto {
final String content;
final String createTime;
final int id;
final String avatar;
factory UserPostCommonDto.fromJson(Map<String, dynamic> json) =>
UserPostCommonDto(
@@ -36,6 +38,8 @@ class UserPostCommonDto {
content: json['content'] as String? ?? '',
createTime: json['createTime'] as String? ?? '',
id: _toInt(json['id']),
// 详情接口的留言项带评论者头像
avatar: json['avatar'] as String? ?? '',
);
}
@@ -248,13 +252,16 @@ class UserRankPagedList {
final ranksList = payload['userRanks'] is List
? payload['userRanks'] as List<dynamic>
: (payload['data'] is List
? payload['data'] as List<dynamic>
: const []);
? payload['data'] as List<dynamic>
: const []);
return UserRankPagedList(
myRank: payload['myRank'] is Map<String, dynamic>
? UserRank.fromJson(payload['myRank'] as Map<String, dynamic>)
: null,
userRanks: ranksList.whereType<Map<String, dynamic>>().map(UserRank.fromJson).toList(),
userRanks: ranksList
.whereType<Map<String, dynamic>>()
.map(UserRank.fromJson)
.toList(),
total: _toInt(payload['total']),
pageIndex: _toInt(payload['pageIndex']),
pageSize: _toInt(payload['pageSize']),
@@ -304,4 +311,4 @@ int? _toIntOrNull(dynamic v) {
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v);
return null;
}
}
+24 -1
View File
@@ -272,7 +272,30 @@ class DioClient {
Duration _backoff(int attempt) =>
Duration(milliseconds: 300 * (1 << (attempt - 1)));
// ---------- 4. OSS 直传(PUT) ----------
// ---------- 4. DELETE(业务删除,不做自动重试) ----------
/// DELETE 请求:删除类接口(如删除评论 DeleteUserCommon/{id})。
/// 与 get/post 模式一致,统一把错误转成 ApiException 抛给业务层。
Future<dynamic> delete(
String path, {
dynamic data,
CancelToken? cancelToken,
Map<String, dynamic>? headers,
}) async {
try {
final resp = await _dio.delete<dynamic>(
path,
data: data,
cancelToken: cancelToken,
options: headers == null ? null : Options(headers: headers),
);
return resp.data;
} on DioException catch (e) {
throw _mapError(e);
}
}
// ---------- 5. OSS 直传(PUT) ----------
/// PUT 请求:阿里云 OSS 直传专用。
///
+45 -10
View File
@@ -6,7 +6,11 @@ import 'package:new_learn/features/auth/legal_document_page.dart';
import 'package:new_learn/features/auth/legal_documents.dart';
import 'package:new_learn/features/auth/login_page.dart';
import 'package:new_learn/features/auth/privacy_consent_page.dart';
import 'package:new_learn/features/class/all_student_view.dart';
import 'package:new_learn/features/class/class_page.dart';
import 'package:new_learn/features/class/my_interact_page.dart';
import 'package:new_learn/features/class/my_update_detail_page.dart';
import 'package:new_learn/core/api/class/model.dart';
import 'package:new_learn/features/my/my_page.dart';
import 'package:new_learn/features/my/my_point/my_point_page.dart';
import 'package:new_learn/features/recite/recite_page.dart';
@@ -76,14 +80,47 @@ final GoRouter appRouter = GoRouter(
privacyAcceptedNotifier,
]), // 响应式登录守卫:监听登录态变化信号,变化时自动重算 redirect
routes: [
// ---------- 4 tab 主框架(ShellRoute:子页面共享这个壳) ----------
ShellRoute(
builder: (context, state, child) => MainTabPage(child: child),
routes: [
GoRoute(path: '/', builder: (_, _) => const TaskPage()),
GoRoute(path: '/recite', builder: (_, _) => const RecitePage()),
GoRoute(path: '/class', builder: (_, _) => const ClassPage()),
GoRoute(path: '/my', builder: (_, _) => const MyPage()),
// ---------- 4 tab 主框架(StatefulShellRoute:切 tab 用 IndexedStack 保活页面状态) ----------
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) =>
MainTabPage(navigationShell: navigationShell),
branches: [
// 任务
StatefulShellBranch(
routes: [GoRoute(path: '/', builder: (_, _) => const TaskPage())],
),
// 背诵
StatefulShellBranch(
routes: [
GoRoute(path: '/recite', builder: (_, _) => const RecitePage()),
],
),
// 班级(含班级模块子页:全部同学 / 我的互动 / 动态详情)
StatefulShellBranch(
routes: [
GoRoute(path: '/class', builder: (_, _) => const ClassPage()),
GoRoute(
path: '/class/all-student',
builder: (_, _) => const AllStudentPage(),
),
GoRoute(
path: '/class/my-interact',
builder: (_, _) => const MyInteractPage(),
),
GoRoute(
path: '/class/my-update-detail',
builder: (_, state) =>
MyUpdateDetailPage(initialPost: state.extra as UserPostDto),
),
],
),
// 我的(含我的积分页)
StatefulShellBranch(
routes: [
GoRoute(path: '/my', builder: (_, _) => const MyPage()),
GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()),
],
),
],
),
// ---------- 首次启动隐私协议页 ----------
@@ -107,8 +144,6 @@ final GoRouter appRouter = GoRouter(
),
// ---------- 登录页 ----------
GoRoute(path: '/login', builder: (_, _) => const LoginPage()),
// ---------- 业务子页(从 tab 页 push 进来,不占 tab) ----------
GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()),
// app_router.dart 的 routes 里加,不用管跳转逻辑
// GoRoute(path: '/task/:id', builder: (_, _) => const TaskDetailPage()),
// ---------- 以后新业务页在这里加,一行一个 ----------
+11 -28
View File
@@ -37,10 +37,11 @@ import 'package:new_learn/core/version/app_version_service.dart';
/// 底部导航不消失、子页面盖在内容区上——这是 ShellRoute 的价值。
/// ============================================================
class MainTabPage extends StatefulWidget {
const MainTabPage({super.key, required this.child});
const MainTabPage({super.key, required this.navigationShell});
/// 当前 tab 页面(由 go_router ShellRoute 提供)
final Widget child;
/// go_router 的导航壳(StatefulShellRoute.indexedStack 提供):
/// 内部用 IndexedStack 保活各 tab 页面,来回切 tab 不丢页面状态。
final StatefulNavigationShell navigationShell;
@override
State<MainTabPage> createState() => _MainTabPageState();
@@ -100,37 +101,19 @@ class _MainTabPageState extends State<MainTabPage> {
'assets/img/tabbar/default/myChoose.png',
];
/// 当前路由 path → tab 序号(默认第 1 个"任务")
int _indexOf(String path) {
return switch (path) {
'/recite' => 1,
'/class' => 2,
'/my' => 3,
_ => 0,
};
}
/// tab 序号 → 路由 path(点击底部导航时跳转)
String _pathOf(int index) {
return switch (index) {
1 => '/recite',
2 => '/class',
3 => '/my',
_ => '/',
};
}
@override
Widget build(BuildContext context) {
// 从 go_router 拿当前路由,反推底部高亮 tab
final path = GoRouterState.of(context).uri.path;
final currentIndex = _indexOf(path);
final navigationShell = widget.navigationShell;
final currentIndex = navigationShell.currentIndex;
return Scaffold(
body: widget.child,
body:
navigationShell, // StatefulNavigationShell 内部用 IndexedStack 保活各 tab 页
bottomNavigationBar: BottomNavigationBar(
currentIndex: currentIndex,
onTap: (i) => context.go(_pathOf(i)),
// 切 tab:进对应 branch;点当前 tab 则回到其栈顶
onTap: (i) =>
navigationShell.goBranch(i, initialLocation: i == currentIndex),
type: BottomNavigationBarType.fixed,
backgroundColor: Colors.white,
selectedItemColor: const Color(0xFF3B82F6),
+189
View File
@@ -0,0 +1,189 @@
import 'package:flutter/material.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/utils/img_url.dart';
/// ============================================================
/// 【AllStudentPage】「全部同学」页 —— 对标 Vue pages/classPackage/allStudent/index.vue
///
/// 一、入口:班级动态页右上角「全部」按钮。
/// 二、数据:getClassUsers 一次性拉全员(含在线状态),非分页。
/// 三、布局:按屏宽自适应列数铺满(对标 Vue updateStudentLayout),
/// 每格正方形,内部 头像 + 名字,头像右上角显示在线绿点。
/// 四、主题:整页渐变背景 + 透明导航栏(NavBar 叠在渐变上)。
/// ============================================================
class AllStudentPage extends StatefulWidget {
const AllStudentPage({super.key});
@override
State<AllStudentPage> createState() => _AllStudentPageState();
}
class _AllStudentPageState extends State<AllStudentPage> {
List<ClassUser> _students = [];
bool _loading = true;
bool _failed = false;
/// 手机端一行固定 4 个(对标 Vue 小屏下的 4 列网格布局)
static const int _columnCount = 4;
@override
void initState() {
super.initState();
_fetchStudents();
}
Future<void> _fetchStudents() async {
setState(() {
_loading = true;
_failed = false;
});
try {
final users = await ClassApi.instance.getClassUsers();
if (!mounted) return;
setState(() {
_students = users;
_loading = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_loading = false;
_failed = true;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFEDF4FF),
// 背景渐变与 AppBar 同色(EDF4FF),网格体置于 AppBar 之下,
// 不再 extendBodyBehindAppBar,避免内容被标题/返回键遮挡。
appBar: AppBar(
backgroundColor: const Color(0xFFEDF4FF),
elevation: 0,
title: Text(
'全部同学 (${_students.length})',
style: const TextStyle(color: Color(0xFF1D2B47)),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Color(0xFF1D2B47)),
onPressed: () =>
context.canPop() ? context.pop() : context.go('/class'),
),
),
body: Container(
width: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFEDF4FF), Color(0xFFD1E3FE)],
),
),
child: _buildBody(),
),
);
}
Widget _buildBody() {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_failed) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'加载失败,请检查网络',
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF888888)),
),
SizedBox(height: 12.h),
FilledButton(onPressed: _fetchStudents, child: const Text('重新加载')),
],
),
);
}
if (_students.isEmpty) {
return const Center(
child: Text('暂无同学', style: TextStyle(color: Color(0xFF888888))),
);
}
return GridView.builder(
padding: EdgeInsets.fromLTRB(14.w, 20.h, 14.w, 20.h),
physics: const AlwaysScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _columnCount,
crossAxisSpacing: 9.w,
mainAxisSpacing: 10.h,
childAspectRatio: 0.78,
),
itemCount: _students.length,
itemBuilder: (context, index) => _StudentItem(user: _students[index]),
);
}
}
/// 单个同学格:在线点 + 头像 + 名字
class _StudentItem extends StatelessWidget {
const _StudentItem({required this.user});
final ClassUser user;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: const Color(0xFFF6FAFF),
borderRadius: BorderRadius.circular(8.r),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 头像 + 右上角在线点
Stack(
clipBehavior: Clip.none,
children: [
UserCircleAvatar(url: _avatarUrl(), radius: 17.5.w),
if (user.isOnline)
Positioned(
right: -2.w,
top: -2.w,
child: Container(
width: 7.w,
height: 7.w,
decoration: const BoxDecoration(
color: Color(0x8000CC96),
shape: BoxShape.circle,
),
),
),
],
),
SizedBox(height: 7.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 2.w),
child: Text(
user.userName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13, color: Color(0xFF1D2B47)),
),
),
],
),
);
}
String _avatarUrl() {
final url = ImgUrl.build(user.avatar);
return url.isEmpty
? ImgUrl.build('/StaticResource/2026-04-03/fake-avatar.png')
: url;
}
}
+73 -36
View File
@@ -2,6 +2,8 @@ import 'package:cached_network_image/cached_network_image.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/components/image_preview_viewer.dart';
import 'package:new_learn/core/components/user_circle_avatar.dart';
import 'package:new_learn/core/models/user_model.dart';
import 'package:new_learn/core/providers/user_provider.dart';
@@ -121,7 +123,10 @@ class _ClassUpdateViewState extends ConsumerState<ClassUpdateView> {
..addAll(res.data);
_pageIndex = 1;
_prevPageId = res.data.isNotEmpty ? res.data.last.id : 0;
_hasMore = res.data.length >= _pageSize;
// 优先用总条数判断是否还有更多;total 不可用时退回"本页满页才继续"
_hasMore = res.total > 0
? res.data.length < res.total
: res.data.length >= _pageSize;
_loading = false;
});
} catch (_) {
@@ -147,7 +152,10 @@ class _ClassUpdateViewState extends ConsumerState<ClassUpdateView> {
_posts.addAll(res.data);
_pageIndex += 1;
if (res.data.isNotEmpty) _prevPageId = res.data.last.id;
_hasMore = res.data.length >= _pageSize;
// 累计长度 vs 总条数,即使每页都返回满页也能在到底后正确收尾
_hasMore = res.total > 0
? _posts.length < res.total
: res.data.length >= _pageSize;
_loadingMore = false;
});
} catch (_) {
@@ -258,24 +266,43 @@ class _ClassUpdateViewState extends ConsumerState<ClassUpdateView> {
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: 14.w),
itemCount: _posts.length + (_hasMore ? 1 : 0),
itemCount: _posts.length + 1,
itemBuilder: (context, index) {
if (index >= _posts.length) {
// 底部占位:加载中显示菊花,没有更多则显示提示文案
if (_hasMore) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 16.h),
child: const Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2.5),
),
),
);
}
return Padding(
padding: EdgeInsets.symmetric(vertical: 16.h),
child: const Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2.5),
padding: EdgeInsets.symmetric(vertical: 20.h),
child: Center(
child: Text(
'没有更多了',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFFB4C2E0),
),
),
),
);
}
return PostCard(
post: _posts[index],
onLike: () => _toggleLike(index),
onComment: (content) => _submitComment(index, content),
final post = _posts[index];
return GestureDetector(
onTap: () => context.push('/class/my-update-detail', extra: post),
child: PostCard(
post: post,
onLike: () => _toggleLike(index),
onComment: (content) => _submitComment(index, content),
),
);
},
),
@@ -357,39 +384,49 @@ class _ClassUpdateViewState extends ConsumerState<ClassUpdateView> {
for (int i = 0; i < urls.length; i++)
Positioned(
left: i * overlap,
child: UserCircleAvatar(url: urls[i], radius: size / 2),
child: GestureDetector(
// 点头像 → 大图预览单张
onTap: () => ImagePreviewViewer.showSingle(
context,
imageProvider: CachedNetworkImageProvider(urls[i]),
),
child: UserCircleAvatar(url: urls[i], radius: size / 2),
),
),
],
),
);
}
/// 右侧「全部」按钮
/// 右侧「全部」按钮 → 全部同学页
Widget _buildAllButton() {
return Container(
width: 62.w,
height: 34.h,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF3D7DF5), Color(0xFF3C84FF), Color(0xFF8AD1FD)],
return GestureDetector(
onTap: () => context.push('/class/all-student'),
child: Container(
width: 62.w,
height: 34.h,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF3D7DF5), Color(0xFF3C84FF), Color(0xFF8AD1FD)],
),
borderRadius: BorderRadius.only(
topRight: Radius.circular(15.r),
bottomLeft: Radius.circular(15.r),
bottomRight: Radius.circular(20.r),
),
),
borderRadius: BorderRadius.only(
topRight: Radius.circular(15.r),
bottomLeft: Radius.circular(15.r),
bottomRight: Radius.circular(20.r),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('全部', style: TextStyle(fontSize: 14, color: Colors.white)),
SizedBox(width: 2.w),
Icon(Icons.chevron_right, size: 14, color: Colors.white),
],
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('全部', style: TextStyle(fontSize: 14, color: Colors.white)),
SizedBox(width: 2.w),
Icon(Icons.chevron_right, size: 14, color: Colors.white),
],
),
);
}
+180
View File
@@ -0,0 +1,180 @@
import 'package:flutter/material.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/features/class/widgets/post_list_body.dart';
/// ============================================================
/// 【MyInteractPage】「我的互动」页 —— 对标 Vue pages/classPackage/myInteract/myInteract.vue
///
/// 一、入口:从「我的」页等进入。
/// 二、结构:NavBar("我的互动") + 3 颗胶囊 tab + 内容区
/// 「我的动态 / 我的点赞 / 我的留言」
/// 三、子列表用通用 PostListBody:
/// 动态 getUserClassPost(queryType=0);点赞 getMyLikePage;留言 getMyCommentPage。
/// 点赞页取消点赞时从列表移除该项(removeOnUnlike);其余保留。
/// 四、懒加载:默认建第一个 tab,切到再建(对标 renderedTabs)。
/// ============================================================
class MyInteractPage extends StatefulWidget {
const MyInteractPage({super.key});
@override
State<MyInteractPage> createState() => _MyInteractPageState();
}
class _MyInteractPageState extends State<MyInteractPage> {
static const List<String> _tabs = ['我的动态', '我的点赞', '我的留言'];
int _currentTab = 0;
final List<bool> _loadedTabs = [true, false, false];
void _switchTab(int index) {
if (_currentTab == index) return;
setState(() {
_loadedTabs[index] = true;
_currentTab = index;
});
}
void _openDetail(UserPostDto post) {
context.push('/class/my-update-detail', extra: post);
}
@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: Column(
children: [
_buildTabs(),
Expanded(
child: IndexedStack(
index: _currentTab,
children: [
if (_loadedTabs[0]) _UpdateTab(onTapPost: _openDetail),
if (_loadedTabs[1]) _LikeTab(onTapPost: _openDetail),
if (_loadedTabs[2]) _CommentTab(onTapPost: _openDetail),
],
),
),
],
),
);
}
Widget _buildTabs() {
return Container(
width: double.infinity,
padding: EdgeInsets.fromLTRB(14.w, 12.h, 14.w, 22.h),
child: Row(
children: [
for (int i = 0; i < _tabs.length; i++) ...[
if (i > 0) SizedBox(width: 12.w),
_buildTabItem(i),
],
],
),
);
}
Widget _buildTabItem(int index) {
final active = _currentTab == index;
return GestureDetector(
onTap: () => _switchTab(index),
child: Container(
width: 84.w,
height: 37.h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: active ? const Color(0xFF3D7DF5) : Colors.white,
borderRadius: BorderRadius.circular(20.r),
),
child: Text(
_tabs[index],
style: TextStyle(
fontSize: 15,
color: active ? Colors.white : const Color(0xFF687FAD),
),
),
),
);
}
}
class _UpdateTab extends StatelessWidget {
const _UpdateTab({required this.onTapPost});
final ValueChanged<UserPostDto> onTapPost;
@override
Widget build(BuildContext context) {
return PostListBody(
fetchPage: ({required pageIndex, required pageSize, required prevPageId}) =>
ClassApi.instance.getUserClassPost(
queryType: 0,
pageIndex: pageIndex,
pageSize: pageSize,
prevPageId: prevPageId,
),
emptyText: '暂无动态',
onTapPost: onTapPost,
);
}
}
class _LikeTab extends StatelessWidget {
const _LikeTab({required this.onTapPost});
final ValueChanged<UserPostDto> onTapPost;
@override
Widget build(BuildContext context) {
return PostListBody(
fetchPage: ({required pageIndex, required pageSize, required prevPageId}) =>
ClassApi.instance.getMyLikePage(
pageIndex: pageIndex,
pageSize: pageSize,
prevPageId: prevPageId,
),
emptyText: '暂无点赞',
removeOnUnlike: true,
onTapPost: onTapPost,
);
}
}
class _CommentTab extends StatelessWidget {
const _CommentTab({required this.onTapPost});
final ValueChanged<UserPostDto> onTapPost;
@override
Widget build(BuildContext context) {
return PostListBody(
fetchPage: ({required pageIndex, required pageSize, required prevPageId}) =>
ClassApi.instance.getMyCommentPage(
pageIndex: pageIndex,
pageSize: pageSize,
prevPageId: prevPageId,
),
emptyText: '暂无留言',
onTapPost: onTapPost,
);
}
}
@@ -0,0 +1,410 @@
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: [
PostCard(
post: _post,
showCommentComposer: false,
onLike: _toggleLike,
onComment: (_) {},
),
SizedBox(height: 10.h),
_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;
UserPostDto get _post => widget.post;
/// 点浮层「删除」:先关闭浮层再弹确认框;确认弹窗期间用 _pending 保持高亮
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;
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)),
),
),
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),
],
),
),
);
}
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,避免事件冒泡到外层关闭浮层)
onTap: active ? () {} : null,
child: Container(
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: 10.h),
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),
),
),
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)),
),
],
),
],
),
),
],
),
),
);
}
/// 长按后弹出的「删除」黑色浮层(对标 Vue delete-popover)
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(
'删除',
style: TextStyle(fontSize: 14, color: Colors.white),
),
Icon(Icons.arrow_drop_down, color: bg, size: 18),
],
),
),
);
}
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;
}
}
@@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/api/class/model.dart';
import 'package:new_learn/core/components/image_preview_viewer.dart';
import 'package:new_learn/core/components/user_circle_avatar.dart';
import 'package:new_learn/core/utils/img_url.dart';
@@ -23,12 +24,16 @@ class PostCard extends StatelessWidget {
required this.post,
required this.onLike,
required this.onComment,
this.showCommentComposer = true,
});
final UserPostDto post;
final VoidCallback onLike;
final ValueChanged<String> onComment;
/// 是否显示卡片内嵌的评论预览 + 输入框(列表页 true;详情页由全量留言接管)
final bool showCommentComposer;
String get _avatar {
final url = ImgUrl.build(post.avatar);
return url.isEmpty
@@ -106,6 +111,7 @@ class PostCard extends StatelessWidget {
post: post,
onLike: onLike,
onComment: onComment,
showComposer: showCommentComposer,
),
],
),
@@ -295,11 +301,26 @@ class AlbumGrid extends StatelessWidget {
runSpacing: 9.h,
children: [
for (int i = 0; i < displayed.length; i++)
_gridItem(displayed[i], i, urls.length),
GestureDetector(
onTap: () => _preview(context, urls, i),
child: _gridItem(displayed[i], i, urls.length),
),
],
);
}
/// 点击相册图:进入整组大图预览,定位到点中的那张
void _preview(BuildContext context, List<String> allUrls, int index) {
ImagePreviewViewer.show(
context,
items: [
for (final u in allUrls)
PreviewImageItem(imageProvider: CachedNetworkImageProvider(u)),
],
initialIndex: index,
);
}
Widget _gridItem(String url, int index, int total) {
final more = total > 9 && index == 8 ? total - 9 : 0;
return ClipRRect(
@@ -344,12 +365,16 @@ class LikeCommentBar extends StatelessWidget {
required this.post,
required this.onLike,
required this.onComment,
this.showComposer = true,
});
final UserPostDto post;
final VoidCallback onLike;
final ValueChanged<String> onComment;
/// 是否显示评论预览 + 输入框(列表页 true;详情页 false,由全量留言列表接管)
final bool showComposer;
@override
Widget build(BuildContext context) {
return Column(
@@ -370,12 +395,14 @@ class LikeCommentBar extends StatelessWidget {
MetaButton(icon: Icons.chat_bubble_outline, count: post.replyCount),
],
),
if (post.commons.isNotEmpty) ...[
if (showComposer) ...[
if (post.commons.isNotEmpty) ...[
SizedBox(height: 12.h),
CommentPreview(post: post),
],
SizedBox(height: 12.h),
CommentPreview(post: post),
CommentInput(onSubmit: onComment),
],
SizedBox(height: 12.h),
CommentInput(onSubmit: onComment),
],
);
}
@@ -517,20 +544,25 @@ class _CommentInputState extends State<CommentInput> {
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Container(
height: 30.h,
padding: EdgeInsets.symmetric(horizontal: 12.w),
alignment: Alignment.centerLeft,
constraints: BoxConstraints(minHeight: 30.h),
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 5.h),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE3ECFF)),
borderRadius: BorderRadius.circular(10.r),
),
// multiline:键盘右下角为「换行」,可换行写多条评论
child: TextField(
controller: _controller,
style: const TextStyle(fontSize: 12, color: Color(0xFF1D2B47)),
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
minLines: 1,
maxLines: 4,
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
@@ -0,0 +1,265 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/api/class/api.dart';
import 'package:new_learn/core/api/class/model.dart';
import 'package:new_learn/core/providers/user_provider.dart';
import 'package:new_learn/features/class/widgets/class_update_widgets.dart';
/// ============================================================
/// 【PostListBody】通用动态列表(游标分页 + 点赞/评论乐观更新)
///
/// 从 class_update_view.dart 的列表逻辑抽离,去掉班名/在线条,供
/// 「我的互动」三个子页(我的动态/我的点赞/我的留言)复用。
///
/// 参数:
/// fetchPage —— 单页动态加载器(业务签名统一 {pageIndex,pageSize,prevPageId})
/// emptyText —— 空态文案
/// removeOnUnlike—— 取消点赞时是否从列表移除该项(仅"我的点赞"用)
/// onTapPost —— 点卡片进详情(可选)
///
/// 为什么不用 CommonPagedList:点赞/评论需对单条动态原位更新、保留滚动位置,
/// 故用 ListView + 手动游标分页,list 由本 State 直接持有。
/// ============================================================
class PostListBody extends ConsumerStatefulWidget {
const PostListBody({
super.key,
required this.fetchPage,
required this.emptyText,
this.removeOnUnlike = false,
this.onTapPost,
});
final Future<UserPostResponse> Function({
required int pageIndex,
required int pageSize,
required int prevPageId,
}) fetchPage;
final String emptyText;
final bool removeOnUnlike;
final ValueChanged<UserPostDto>? onTapPost;
@override
ConsumerState<PostListBody> createState() => _PostListBodyState();
}
class _PostListBodyState extends ConsumerState<PostListBody> {
static const int _pageSize = 10;
final ScrollController _scrollController = ScrollController();
final List<UserPostDto> _posts = [];
int _pageIndex = 1;
int _prevPageId = 0;
bool _loading = true;
bool _loadingMore = false;
bool _hasMore = true;
bool _failed = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadFirstPage();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 80) {
_loadMore();
}
}
Future<void> _loadFirstPage() async {
setState(() {
_loading = true;
_failed = false;
});
try {
final res = await widget.fetchPage(
pageIndex: 1,
pageSize: _pageSize,
prevPageId: 0,
);
if (!mounted) return;
setState(() {
_posts
..clear()
..addAll(res.data);
_pageIndex = 1;
_prevPageId = res.data.isNotEmpty ? res.data.last.id : 0;
_hasMore = res.data.length >= _pageSize;
_loading = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_loading = false;
_failed = true;
});
}
}
Future<void> _loadMore() async {
if (_loadingMore || !_hasMore || _loading || _failed) return;
setState(() => _loadingMore = true);
try {
final res = await widget.fetchPage(
pageIndex: _pageIndex + 1,
pageSize: _pageSize,
prevPageId: _prevPageId,
);
if (!mounted) return;
setState(() {
_posts.addAll(res.data);
_pageIndex += 1;
if (res.data.isNotEmpty) _prevPageId = res.data.last.id;
_hasMore = res.data.length >= _pageSize;
_loadingMore = false;
});
} catch (_) {
if (!mounted) return;
setState(() => _loadingMore = false);
}
}
Future<void> _toggleLike(int index) async {
final post = _posts[index];
final liked = !post.userLikeStatus;
final newCount = post.likeCount + (liked ? 1 : -1);
// 我的点赞:取消点赞时直接移除该项
if (widget.removeOnUnlike && !liked) {
setState(() => _posts.removeAt(index));
} else {
_replaceAt(
index,
post.copyWith(
userLikeStatus: liked,
likeCount: newCount < 0 ? 0 : newCount,
),
);
}
try {
await ClassApi.instance.addUserLike(
userPostId: post.id,
likeStatus: liked,
);
} catch (_) {
if (!mounted) return;
// 失败回滚:移除场景回插到原位,更新场景恢复旧值
if (widget.removeOnUnlike && !liked) {
if (index <= _posts.length) {
setState(() => _posts.insert(index, post));
}
} else {
_replaceAt(index, post);
}
}
}
Future<void> _submitComment(int index, String content) async {
final text = content.trim();
if (text.isEmpty) return;
final post = _posts[index];
final me = ref.read(currentUserProvider).value;
final common = UserPostCommonDto(
userId: me?.userId ?? 0,
userName: me?.userName ?? '我',
content: text,
);
_replaceAt(
index,
post.copyWith(
replyCount: post.replyCount + 1,
commons: [common, ...post.commons],
),
);
try {
await ClassApi.instance.addUserComment(
userPostId: post.id,
content: text,
);
} catch (_) {
if (!mounted) return;
_replaceAt(index, post);
}
}
void _replaceAt(int index, UserPostDto post) {
setState(() => _posts[index] = post);
}
@override
Widget build(BuildContext context) {
return _buildBody();
}
Widget _buildBody() {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_failed) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'加载失败,请检查网络',
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)),
),
SizedBox(height: 12.h),
FilledButton(onPressed: _loadFirstPage, child: const Text('重新加载')),
],
),
);
}
if (_posts.isEmpty) {
return Center(
child: Text(
widget.emptyText,
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)),
),
);
}
return RefreshIndicator(
onRefresh: _loadFirstPage,
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: 14.w),
itemCount: _posts.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _posts.length) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 16.h),
child: const Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2.5),
),
),
);
}
final post = _posts[index];
return GestureDetector(
onTap: widget.onTapPost == null
? null
: () => widget.onTapPost!(post),
child: PostCard(
post: post,
onLike: () => _toggleLike(index),
onComment: (content) => _submitComment(index, content),
),
);
},
),
);
}
}