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