diff --git a/lib/core/api/class/api.dart b/lib/core/api/class/api.dart index 64b2302..28e2714 100644 --- a/lib/core/api/class/api.dart +++ b/lib/core/api/class/api.dart @@ -74,6 +74,89 @@ class ClassApi { 'content': content, }); } + + /// 我的点赞列表 —— GET /api/v1/UserLike/GetUserLike(游标分页) + /// + /// 后端每项包一层 {userPost: {...}},这里统一解包成 UserPostDto, + /// 页面拿到的是与班级动态一致的结构,直接复用 PostCard。 + Future 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 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 getUserPostDetail(int id) async { + final data = await DioClient.instance.get( + '/api/v1/UserPost/GetUserPostDetail/$id', + ); + return UserPostDto.fromJson(data as Map); + } + + /// 删除评论 —— DELETE /api/v1/UserPost/DeleteUserCommon/{id} + Future deleteUserCommon(int commentId) async { + await DioClient.instance.delete( + '/api/v1/UserPost/DeleteUserCommon/$commentId', + ); + } +} + +/// 解包「我的点赞/留言」列表:每项若包了 userPost 则取内层,再统一转 UserPostDto +UserPostResponse _unwrapUserPostResponse(dynamic data) { + final raw = data is Map ? data : {'data': data}; + final payload = raw['data'] is Map + ? raw['data'] as Map + : raw; + final list = payload['data']; + final posts = []; + if (list is List) { + for (final e in list) { + if (e is! Map) continue; + final inner = e['userPost'] is Map + ? e['userPost'] as Map + : 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; } /// 积分排行榜接口 diff --git a/lib/core/api/class/model.dart b/lib/core/api/class/model.dart index fb3d614..511b2c7 100644 --- a/lib/core/api/class/model.dart +++ b/lib/core/api/class/model.dart @@ -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 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 : (payload['data'] is List - ? payload['data'] as List - : const []); + ? payload['data'] as List + : const []); return UserRankPagedList( myRank: payload['myRank'] is Map ? UserRank.fromJson(payload['myRank'] as Map) : null, - userRanks: ranksList.whereType>().map(UserRank.fromJson).toList(), + userRanks: ranksList + .whereType>() + .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; -} \ No newline at end of file +} diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart index 58b43b3..ec0cd35 100644 --- a/lib/core/network/dio_client.dart +++ b/lib/core/network/dio_client.dart @@ -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 delete( + String path, { + dynamic data, + CancelToken? cancelToken, + Map? headers, + }) async { + try { + final resp = await _dio.delete( + 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 直传专用。 /// diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 53c1e35..1efa595 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -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()), // ---------- 以后新业务页在这里加,一行一个 ---------- diff --git a/lib/core/router/main_tab_page.dart b/lib/core/router/main_tab_page.dart index be0812b..4e70aad 100644 --- a/lib/core/router/main_tab_page.dart +++ b/lib/core/router/main_tab_page.dart @@ -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 createState() => _MainTabPageState(); @@ -100,37 +101,19 @@ class _MainTabPageState extends State { '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), diff --git a/lib/features/class/all_student_view.dart b/lib/features/class/all_student_view.dart new file mode 100644 index 0000000..3ffb4c8 --- /dev/null +++ b/lib/features/class/all_student_view.dart @@ -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 createState() => _AllStudentPageState(); +} + +class _AllStudentPageState extends State { + List _students = []; + bool _loading = true; + bool _failed = false; + + /// 手机端一行固定 4 个(对标 Vue 小屏下的 4 列网格布局) + static const int _columnCount = 4; + + @override + void initState() { + super.initState(); + _fetchStudents(); + } + + Future _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; + } +} diff --git a/lib/features/class/class_update_view.dart b/lib/features/class/class_update_view.dart index 1aaec94..98b1cb6 100644 --- a/lib/features/class/class_update_view.dart +++ b/lib/features/class/class_update_view.dart @@ -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 { ..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 { _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 { 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 { 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), - ], - ), ); } diff --git a/lib/features/class/my_interact_page.dart b/lib/features/class/my_interact_page.dart new file mode 100644 index 0000000..7698fde --- /dev/null +++ b/lib/features/class/my_interact_page.dart @@ -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 createState() => _MyInteractPageState(); +} + +class _MyInteractPageState extends State { + static const List _tabs = ['我的动态', '我的点赞', '我的留言']; + + int _currentTab = 0; + final List _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 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 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 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, + ); + } +} \ No newline at end of file diff --git a/lib/features/class/my_update_detail_page.dart b/lib/features/class/my_update_detail_page.dart new file mode 100644 index 0000000..3a247f1 --- /dev/null +++ b/lib/features/class/my_update_detail_page.dart @@ -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 createState() => _MyUpdateDetailPageState(); +} + +class _MyUpdateDetailPageState extends ConsumerState { + late UserPostDto _post; + + @override + void initState() { + super.initState(); + _post = widget.initialPost; + if (_post.id != 0) _fetchDetail(); + } + + /// 用详情接口增量更新评论相关字段(对标 Vue fetchDetail) + /// + /// 关键:只有当 detail 确实带增量内容时才更新,且单个字段非空才覆盖。 + /// 否则后端未返回评论/计数(空壳)时,会把列表带入的正确数据“清空” + /// —— 表现为初次闪现对的数据后瞬间空白。 + Future _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 _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 _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 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 _handleDelete(int index, UserPostCommonDto c) async { + setState(() { + _active = null; + _pending = index; + }); + final ok = await showDialog( + 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; + } +} diff --git a/lib/features/class/widgets/class_update_widgets.dart b/lib/features/class/widgets/class_update_widgets.dart index c7095e7..cdc89c2 100644 --- a/lib/features/class/widgets/class_update_widgets.dart +++ b/lib/features/class/widgets/class_update_widgets.dart @@ -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 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 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 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 { @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, diff --git a/lib/features/class/widgets/post_list_body.dart b/lib/features/class/widgets/post_list_body.dart new file mode 100644 index 0000000..9f87e2b --- /dev/null +++ b/lib/features/class/widgets/post_list_body.dart @@ -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 Function({ + required int pageIndex, + required int pageSize, + required int prevPageId, + }) fetchPage; + + final String emptyText; + final bool removeOnUnlike; + final ValueChanged? onTapPost; + + @override + ConsumerState createState() => _PostListBodyState(); +} + +class _PostListBodyState extends ConsumerState { + static const int _pageSize = 10; + + final ScrollController _scrollController = ScrollController(); + final List _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 _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 _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 _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 _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), + ), + ); + }, + ), + ); + } +} \ No newline at end of file