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),