feat: 完成班级模块全量功能迭代
本次提交完成了班级模块的多项核心功能开发与优化: 1. 新增DELETE请求封装,支持业务删除接口且不自动重试 2. 完善UserPostCommonDto模型,新增头像字段适配详情接口 3. 重构路由架构,使用StatefulShellRoute实现Tab页面保活 4. 新增我的互动页面,包含我的动态/点赞/留言三个子页 5. 新增全部同学页面,展示班级全员信息与在线状态 6. 新增动态详情页面,支持查看完整评论与点赞头像 7. 抽离通用PostListBody组件,复用列表分页与交互逻辑 8. 优化班级列表页加载逻辑与图片预览交互 9. 新增评论删除、多图预览等细节交互功能
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user