2 Commits
Author SHA1 Message Date
崔宝新 2aa1927694 refactor: 重构页面路由结构与积分页面UI,优化列表交互体验
1. 重构路由结构:将班级子页移出ShellBranch,解决tabbar遮挡问题
2. 统一修改全局"没有更多了"提示文案为带品牌标识的版本
3. 重构积分排行榜UI,修复切换状态时的布局抖动问题,替换图标为CDN资源
4. 重构我的积分页面:对齐Vue端UI设计,新增展开收起详情功能,优化背景与布局
5. 优化我的互动页面布局与样式,新增背景图支持
2026-09-08 13:42:10 +08:00
崔宝新 3a2b589e37 feat: add new assets, optimize list layout and重构个人中心页面
1. 新增fork/star/close等多个业务图标并配置到静态资源路径
2. 新增静态资源CDN配置常量
3. 优化帖子列表加载更多UI,无更多数据时显示提示文本
4. 重构我的页面布局,对齐Vue端实现,替换图标为CDN资源
5. 统一退出登录弹窗为公共组件,调整代码格式与缩进
2026-09-07 17:53:32 +08:00
21 changed files with 777 additions and 323 deletions
+1
View File
@@ -1,4 +1,5 @@
# Miscellaneous # Miscellaneous
.trae/
*.class *.class
*.log *.log
*.pyc *.pyc
Binary file not shown.

After

Width:  |  Height:  |  Size: 427 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 727 B

+1 -1
View File
@@ -73,7 +73,7 @@ class CommonPagedList<T> extends StatefulWidget {
this.separatorBuilder, this.separatorBuilder,
this.padding, this.padding,
this.emptyWidget, this.emptyWidget,
this.noMoreText = '— 没有更多了 —', this.noMoreText = '- AI 爱学蝶变提醒你 没有更多了 -',
this.invisibleItemsThreshold = 0, this.invisibleItemsThreshold = 0,
this.enableRefresh = true, this.enableRefresh = true,
}); });
+7 -2
View File
@@ -22,8 +22,10 @@ class AppEnv {
/// 品牌标识:aixue / chucai / yule / ...(默认 aixue=爱学蝶变) /// 品牌标识:aixue / chucai / yule / ...(默认 aixue=爱学蝶变)
/// 品牌表见 app_brand.dart;主题/接口/App 名都随它走(AppConfig 聚合) /// 品牌表见 app_brand.dart;主题/接口/App 名都随它走(AppConfig 聚合)
static const String brand = static const String brand = String.fromEnvironment(
String.fromEnvironment('BRAND', defaultValue: 'aixue'); 'BRAND',
defaultValue: 'aixue',
);
/// 三套环境地址表(与 uni-app envList 完全对齐) /// 三套环境地址表(与 uni-app envList 完全对齐)
static const Map<String, Map<String, String>> _envTable = { static const Map<String, Map<String, String>> _envTable = {
@@ -58,6 +60,9 @@ class AppEnv {
/// 当前环境学习报告地址(预留,对标 envList.reportUrl) /// 当前环境学习报告地址(预留,对标 envList.reportUrl)
static String get reportUrl => value('reportUrl'); static String get reportUrl => value('reportUrl');
/// 静态资源 CDN 域名(对标 uni-app utils/image.js 的 IMG_URL)
static const String imgBaseUrl = 'https://hx.cnzhyx.cn';
/// 是否正式环境(组合规则里判断用:prod 才走品牌专属地址) /// 是否正式环境(组合规则里判断用:prod 才走品牌专属地址)
static bool get isProd => env == 'prod'; static bool get isProd => env == 'prod';
} }
+18 -19
View File
@@ -95,34 +95,33 @@ final GoRouter appRouter = GoRouter(
GoRoute(path: '/recite', builder: (_, _) => const RecitePage()), GoRoute(path: '/recite', builder: (_, _) => const RecitePage()),
], ],
), ),
// 班级(含班级模块子页:全部同学 / 我的互动 / 动态详情) // 班级(只有主 tab 页在 ShellBranch 内,子页放顶层避免 tabbar 遮挡)
StatefulShellBranch( StatefulShellBranch(
routes: [ routes: [
GoRoute(path: '/class', builder: (_, _) => const ClassPage()), 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( StatefulShellBranch(
routes: [ routes: [GoRoute(path: '/my', builder: (_, _) => const MyPage())],
GoRoute(path: '/my', builder: (_, _) => const MyPage()),
GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()),
],
), ),
], ],
), ),
// ---------- 二级业务页(不在 ShellBranch 内,push 时不显示 tabbar) ----------
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),
),
GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()),
// ---------- 首次启动隐私协议页 ---------- // ---------- 首次启动隐私协议页 ----------
GoRoute( GoRoute(
path: '/privacy-consent', path: '/privacy-consent',
+1 -1
View File
@@ -286,7 +286,7 @@ class _ClassUpdateViewState extends ConsumerState<ClassUpdateView> {
padding: EdgeInsets.symmetric(vertical: 20.h), padding: EdgeInsets.symmetric(vertical: 20.h),
child: Center( child: Center(
child: Text( child: Text(
'没有更多了', '- AI 爱学蝶变提醒你 没有更多了 -',
style: TextStyle( style: TextStyle(
fontSize: 12.sp, fontSize: 12.sp,
color: const Color(0xFFB4C2E0), color: const Color(0xFFB4C2E0),
+64 -28
View File
@@ -1,8 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:new_learn/core/api/class/api.dart'; import 'package:new_learn/core/api/class/api.dart';
import 'package:new_learn/core/api/class/model.dart'; import 'package:new_learn/core/api/class/model.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/features/class/widgets/post_list_body.dart'; import 'package:new_learn/features/class/widgets/post_list_body.dart';
/// ============================================================ /// ============================================================
@@ -45,8 +47,10 @@ class _MyInteractPageState extends State<MyInteractPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF5F8FA), backgroundColor: const Color(0xFFF5F8FA),
extendBodyBehindAppBar: true,
appBar: AppBar( appBar: AppBar(
backgroundColor: const Color(0xFFF5F8FA), backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
elevation: 0, elevation: 0,
title: const Text( title: const Text(
'我的互动', '我的互动',
@@ -63,16 +67,45 @@ class _MyInteractPageState extends State<MyInteractPage> {
context.canPop() ? context.pop() : context.go('/class'), context.canPop() ? context.pop() : context.go('/class'),
), ),
), ),
body: Column( // 对齐 Vue .activity-box:背景图 + #F5F8FA 底色
// background-image: url(/StaticResource/2026-04-07/myInteract-bg.png)
// background-size: 100% 540rpx → height: 270.h + BoxFit.fitWidth
body: Stack(
children: [ children: [
_buildTabs(), Positioned(
Expanded( top: 0,
child: IndexedStack( left: 0,
index: _currentTab, right: 0,
child: SizedBox(
height: 270.h, // Vue 540rpx
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-07/myInteract-bg.png',
),
fit: BoxFit.fitWidth,
placeholder: (_, __) => const SizedBox.shrink(),
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
),
),
// 内容区:顶 padding = 系统状态栏 + AppBar 高度,对齐 Vue NavBar 后的起点
Padding(
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + kToolbarHeight,
),
child: Column(
children: [ children: [
if (_loadedTabs[0]) _UpdateTab(onTapPost: _openDetail), _buildTabs(),
if (_loadedTabs[1]) _LikeTab(onTapPost: _openDetail), Expanded(
if (_loadedTabs[2]) _CommentTab(onTapPost: _openDetail), child: IndexedStack(
index: _currentTab,
children: [
if (_loadedTabs[0]) _UpdateTab(onTapPost: _openDetail),
if (_loadedTabs[1]) _LikeTab(onTapPost: _openDetail),
if (_loadedTabs[2]) _CommentTab(onTapPost: _openDetail),
],
),
),
], ],
), ),
), ),
@@ -127,13 +160,14 @@ class _UpdateTab extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PostListBody( return PostListBody(
fetchPage: ({required pageIndex, required pageSize, required prevPageId}) => fetchPage:
ClassApi.instance.getUserClassPost( ({required pageIndex, required pageSize, required prevPageId}) =>
queryType: 0, ClassApi.instance.getUserClassPost(
pageIndex: pageIndex, queryType: 0,
pageSize: pageSize, pageIndex: pageIndex,
prevPageId: prevPageId, pageSize: pageSize,
), prevPageId: prevPageId,
),
emptyText: '暂无动态', emptyText: '暂无动态',
onTapPost: onTapPost, onTapPost: onTapPost,
); );
@@ -147,12 +181,13 @@ class _LikeTab extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PostListBody( return PostListBody(
fetchPage: ({required pageIndex, required pageSize, required prevPageId}) => fetchPage:
ClassApi.instance.getMyLikePage( ({required pageIndex, required pageSize, required prevPageId}) =>
pageIndex: pageIndex, ClassApi.instance.getMyLikePage(
pageSize: pageSize, pageIndex: pageIndex,
prevPageId: prevPageId, pageSize: pageSize,
), prevPageId: prevPageId,
),
emptyText: '暂无点赞', emptyText: '暂无点赞',
removeOnUnlike: true, removeOnUnlike: true,
onTapPost: onTapPost, onTapPost: onTapPost,
@@ -167,12 +202,13 @@ class _CommentTab extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PostListBody( return PostListBody(
fetchPage: ({required pageIndex, required pageSize, required prevPageId}) => fetchPage:
ClassApi.instance.getMyCommentPage( ({required pageIndex, required pageSize, required prevPageId}) =>
pageIndex: pageIndex, ClassApi.instance.getMyCommentPage(
pageSize: pageSize, pageIndex: pageIndex,
prevPageId: prevPageId, pageSize: pageSize,
), prevPageId: prevPageId,
),
emptyText: '暂无留言', emptyText: '暂无留言',
onTapPost: onTapPost, onTapPost: onTapPost,
); );
+41 -8
View File
@@ -174,7 +174,11 @@ class _PointRankViewState extends ConsumerState<PointRankView> {
? Colors.white ? Colors.white
: Colors.white.withValues(alpha: 0.5), : Colors.white.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8.r), borderRadius: BorderRadius.circular(8.r),
border: active ? null : Border.all(color: Colors.white, width: 1), // 始终保留 1px border 占位,避免激活/未激活切换时 content 区域差 2px 抖动
border: Border.all(
color: active ? Colors.transparent : Colors.white,
width: 1,
),
), ),
// Web 端回退字体行高偏大,固定高度可能溢出 → 内容放不下时自动缩小 // Web 端回退字体行高偏大,固定高度可能溢出 → 内容放不下时自动缩小
child: FittedBox( child: FittedBox(
@@ -190,7 +194,7 @@ class _PointRankViewState extends ConsumerState<PointRankView> {
color: active ? _primary : const Color(0xFF687FAD), color: active ? _primary : const Color(0xFF687FAD),
), ),
), ),
SizedBox(height: 4.h), // SizedBox(height: 0.5.h),
Text( Text(
'$countText人', '$countText人',
style: TextStyle( style: TextStyle(
@@ -202,7 +206,14 @@ class _PointRankViewState extends ConsumerState<PointRankView> {
), ),
), ),
), ),
if (active) _buildTriangle(), // 始终渲染三角形占位,visible 切换避免外层 Column 高度变化抖动
Visibility(
visible: active,
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: _buildTriangle(),
),
], ],
), ),
); );
@@ -338,11 +349,7 @@ class _PointRankViewState extends ConsumerState<PointRankView> {
), ),
), ),
SizedBox(width: 5.w), SizedBox(width: 5.w),
Icon( _buildSortIcons(),
_sortDesc ? Icons.arrow_downward : Icons.arrow_upward,
size: 14,
color: _primary,
),
], ],
), ),
), ),
@@ -351,6 +358,32 @@ class _PointRankViewState extends ConsumerState<PointRankView> {
); );
} }
Widget _buildSortIcons() {
final up = _sortDesc
? '/StaticResource/2026-07-29/reverseOrder_up_icon1785291938907.png'
: '/StaticResource/2026-07-29/properOrder_up_icon1785291938906.png';
final down = _sortDesc
? '/StaticResource/2026-07-29/reverseOrder_down_icon1785291938905.png'
: '/StaticResource/2026-07-29/properOrder_down_icon1785291938907.png';
return Column(
mainAxisSize: MainAxisSize.min,
children: [
CachedNetworkImage(
imageUrl: ImgUrl.build(up),
width: 12.w,
height: 7.h,
fit: BoxFit.contain,
),
CachedNetworkImage(
imageUrl: ImgUrl.build(down),
width: 12.w,
height: 7.h,
fit: BoxFit.contain,
),
],
);
}
Widget _buildMyRankCard() { Widget _buildMyRankCard() {
final rank = _myRank!; final rank = _myRank!;
return Container( return Container(
+23 -8
View File
@@ -34,7 +34,8 @@ class PostListBody extends ConsumerStatefulWidget {
required int pageIndex, required int pageIndex,
required int pageSize, required int pageSize,
required int prevPageId, required int prevPageId,
}) fetchPage; })
fetchPage;
final String emptyText; final String emptyText;
final bool removeOnUnlike; final bool removeOnUnlike;
@@ -233,16 +234,30 @@ class _PostListBodyState extends ConsumerState<PostListBody> {
controller: _scrollController, controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: 14.w), padding: EdgeInsets.symmetric(horizontal: 14.w),
itemCount: _posts.length + (_hasMore ? 1 : 0), itemCount: _posts.length + 1,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index >= _posts.length) { 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( return Padding(
padding: EdgeInsets.symmetric(vertical: 16.h), padding: EdgeInsets.symmetric(vertical: 20.h),
child: const Center( child: Center(
child: SizedBox( child: Text(
width: 22, '- AI 爱学蝶变提醒你 没有更多了 -',
height: 22, style: TextStyle(
child: CircularProgressIndicator(strokeWidth: 2.5), fontSize: 12.sp,
color: const Color(0xFFB4C2E0),
),
), ),
), ),
); );
+208 -124
View File
@@ -1,16 +1,15 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/components/confirm_box.dart';
import 'package:new_learn/core/components/image_preview_viewer.dart'; import 'package:new_learn/core/components/image_preview_viewer.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:new_learn/core/logger/app_talker.dart'; import 'package:new_learn/core/config/app_env.dart';
import 'package:new_learn/core/network/dio_client.dart'; import 'package:new_learn/core/network/dio_client.dart';
import 'package:new_learn/core/network/oss_api.dart'; import 'package:new_learn/core/network/oss_api.dart';
import 'package:new_learn/core/network/oss_uploader.dart'; import 'package:new_learn/core/network/oss_uploader.dart';
import 'package:new_learn/core/providers/user_provider.dart'; import 'package:new_learn/core/providers/user_provider.dart';
import 'package:new_learn/core/services/image_processor.dart'; import 'package:new_learn/core/services/image_processor.dart';
import 'package:new_learn/core/services/local_notification_service.dart';
import 'package:new_learn/core/services/picker_service.dart'; import 'package:new_learn/core/services/picker_service.dart';
import 'package:new_learn/core/storage/user_storage.dart'; import 'package:new_learn/core/storage/user_storage.dart';
import 'package:new_learn/core/api/auth/api.dart'; import 'package:new_learn/core/api/auth/api.dart';
@@ -47,103 +46,140 @@ class MyPage extends ConsumerWidget {
final user = ref.watch(currentUserProvider).value; final user = ref.watch(currentUserProvider).value;
return Scaffold( return Scaffold(
body: SafeArea( backgroundColor: const Color(0xFFF5F8FA),
child: Column( // 整个 body 用 SingleChildScrollView 包 —— 完全对齐 Vue 的 scroll-view flex:1 结构
children: [ // Vue: <scroll-view scroll-y style="flex:1"> 内部先放 HeaderBac 再放卡片区
// 顶部渐变背景区域(用户信息头部) body: LayoutBuilder(
Container( builder: (context, constraints) {
width: double.infinity, return SingleChildScrollView(
decoration: const BoxDecoration( child: ConstrainedBox(
gradient: LinearGradient( constraints: BoxConstraints(minHeight: constraints.maxHeight),
begin: Alignment.topCenter, child: Column(
end: Alignment.bottomCenter, children: [
colors: [Color(0xFFFFE2C2), Color(0xFFFFFFFF)], // 头部背景:和 Vue HeaderBac 完全一致
), // Stack 高度由内容决定,Positioned 背景图溢出不裁(Clip.none)
), // → 渐变尾巴"飘"到下方,卡片紧贴 Stack 底部 = 浮在渐变上
child: Padding( Stack(
padding: EdgeInsets.only(bottom: 20.h), clipBehavior: Clip.none,
child: _UserHeader( children: [
name: user?.userName ?? '未登录', // 底层:skyBlue 主题渐变背景图(Positioned 不绑 bottom,不撑 Stack 高度)
className: user?.className ?? '-', Positioned(
avatar: user?.avatar ?? '', top: 0,
), left: 0,
right: 0,
child: Image.network(
'${AppEnv.imgBaseUrl}/StaticResource/2026-05-26/my-header-bg-main-skyBlue1779778426425.png',
width: double.infinity,
fit: BoxFit.fitWidth,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
),
// 上层:用户信息(SafeArea 避开状态栏),决定 Stack 高度
SafeArea(
bottom: false,
child: Column(
children: [
_UserHeader(
name: user?.userName ?? '未登录',
className: user?.className ?? '-',
avatar: user?.avatar ?? '',
onLogout: () => _confirmLogout(context, ref),
),
// Vue my.vue 第 30 行:<view style="height: 80rpx;"></view>
SizedBox(height: 40.h),
],
),
),
],
),
// 菜单卡片区:紧贴头部(Stack clipBehavior:none 让渐变尾巴飘到这里)
SizedBox(height: 8.h),
// 第一组:学习功能菜单
_MenuCard(
items: [
_MenuItem(
imageUrl:
'${AppEnv.imgBaseUrl}/StaticResource/2026-06-10/exam1781070655873.png',
title: '我的报告',
onTap: () {
// TODO: 跳转到我的报告页
},
),
_MenuItem(
imageUrl:
'${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/note.png',
title: '我的笔记',
onTap: () {
// TODO: 跳转到我的笔记页
},
),
_MenuItem(
imageUrl:
'${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/error.png',
title: '错题本',
onTap: () {
// TODO: 跳转到错题本页
},
),
_MenuItem(
imageUrl:
'${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/collect.png',
title: '我的收藏',
onTap: () {
// TODO: 跳转到我的收藏页
},
),
_MenuItem(
imageUrl:
'${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/word.png',
title: '生词本',
onTap: () {
// TODO: 跳转到生词本页
},
),
],
),
SizedBox(height: 16.h),
// 第二组:个人数据菜单
_MenuCard(
items: [
_MenuItem(
imageUrl:
'${AppEnv.imgBaseUrl}/StaticResource/2026-04-10/point.png',
title: '我的积分',
trailingText: '${user?.userPoints ?? 0}积分',
onTap: () => context.push('/myPoint'),
),
_MenuItem(
imageUrl:
'${AppEnv.imgBaseUrl}/StaticResource/2026-04-10/interact.png',
title: '我的互动',
onTap: () => context.push('/class/my-interact'),
),
],
),
SizedBox(height: 24.h),
],
), ),
), ),
// 菜单列表区域(可滚动) );
Expanded( },
child: SingleChildScrollView(
child: Column(
children: [
SizedBox(height: 12.h),
// 功能菜单(先只留"我的积分",其余后补)
_MenuCard(
items: [
_MenuItem(
color: const Color(0xFFFF9800),
title: '我的积分',
iconData: Icons.stars_rounded,
trailingText: '${user?.userPoints ?? 0}积分',
onTap: () => context.push('/myPoint'),
),
if (!kReleaseMode)
_MenuItem(
color: const Color(0xFF2563EB),
title: '测试通知(15秒)',
iconData: Icons.notifications_active_outlined,
onTap: () async {
talker.debug('触发测试通知:15 秒后弹出,点击进入我的积分');
await LocalNotificationService.instance
.showMyPointNow();
await LocalNotificationService.instance
.scheduleMyPointAfter15Seconds();
},
),
],
),
SizedBox(height: 24.h),
// 退出登录(守卫自动踢回登录页,见 app_router.dart)
_MenuCard(
items: [
_MenuItem(
color: const Color(0xFFEF4444),
title: '退出登录',
iconData: Icons.logout_rounded,
onTap: () => _confirmLogout(context, ref),
),
],
),
SizedBox(height: 24.h),
],
),
),
),
],
),
), ),
); );
} }
} }
/// 退出登录:确认弹窗 → 清 token → 清全局登录态 → 守卫自动踢回登录页 /// 退出登录:用公共 ConfirmBox 二次确认 → 清 token → 清全局登录态 → 守卫自动踢回登录页
///
/// 对齐 Vue my.vue: `proxy.$confirm({ title: '是否退出登录', confirmText: '确定', cancelText: '取消' })`
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async { Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
final ok = await showDialog<bool>( final ok = await showConfirmBox(
context: context, context,
builder: (dialogContext) => AlertDialog( title: '是否退出登录',
title: const Text('退出登录'), confirmText: '确定',
content: const Text('确定要退出当前账号吗?'), cancelText: '取消',
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('退出'),
),
],
),
); );
if (ok != true) return; if (!ok) return;
// ① 清内存 Token:Dio 拦截器立刻停止携带 Authorization // ① 清内存 Token:Dio 拦截器立刻停止携带 Authorization
DioClient.instance.accessToken = null; DioClient.instance.accessToken = null;
@@ -169,11 +205,13 @@ class _UserHeader extends ConsumerStatefulWidget {
required this.name, required this.name,
required this.className, required this.className,
required this.avatar, required this.avatar,
required this.onLogout,
}); });
final String name; final String name;
final String className; final String className;
final String avatar; final String avatar;
final VoidCallback onLogout;
@override @override
ConsumerState<_UserHeader> createState() => _UserHeaderState(); ConsumerState<_UserHeader> createState() => _UserHeaderState();
@@ -209,7 +247,6 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
title: const Text('预览头像'), title: const Text('预览头像'),
onTap: () => Navigator.of(dialogContext).pop('preview'), onTap: () => Navigator.of(dialogContext).pop('preview'),
), ),
const Divider(height: 1),
ListTile( ListTile(
leading: const Icon(Icons.photo_camera_outlined), leading: const Icon(Icons.photo_camera_outlined),
title: const Text('更换头像'), title: const Text('更换头像'),
@@ -271,7 +308,10 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
/// 头像上传成功后,本次只有 avatar 这一个字段发生变化, /// 头像上传成功后,本次只有 avatar 这一个字段发生变化,
/// 所以前端直接做一次局部更新就够了,没必要再额外请求整份用户资料。 /// 所以前端直接做一次局部更新就够了,没必要再额外请求整份用户资料。
Future<void> _pickAndUploadAvatar() async { Future<void> _pickAndUploadAvatar() async {
final picked = await PickerService.instance.showPickSheet(context, maxCount: 1); final picked = await PickerService.instance.showPickSheet(
context,
maxCount: 1,
);
if (picked == null || picked.isEmpty) return; if (picked == null || picked.isEmpty) return;
final image = picked.first; final image = picked.first;
@@ -386,8 +426,9 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
/// 默认头像:用户名首字圆形色块 /// 默认头像:用户名首字圆形色块
Widget _buildDefaultAvatar() { Widget _buildDefaultAvatar() {
final avatarChar = final avatarChar = widget.name.isNotEmpty
widget.name.isNotEmpty ? widget.name.characters.first : '?'; ? widget.name.characters.first
: '?';
return Container( return Container(
width: 62.w, width: 62.w,
height: 62.h, height: 62.h,
@@ -459,11 +500,16 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
SizedBox(height: 4.h), SizedBox(height: 4.h),
Text( Text(
widget.className, widget.className,
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF8A8A8A)), style: TextStyle(
fontSize: 13.sp,
color: const Color(0xFF8A8A8A),
),
), ),
], ],
), ),
), ),
// 右上角退出按钮(和 Vue my.vue 的 logout-btn 对齐)
_LogoutButton(onTap: widget.onLogout),
], ],
), ),
); );
@@ -477,20 +523,70 @@ String _replaceExtension(String fileName, String extension) {
return '${fileName.substring(0, dotIndex)}.$safeExtension'; return '${fileName.substring(0, dotIndex)}.$safeExtension';
} }
/// 头部右上角的退出按钮(和 Vue my.vue 的 logout-btn 对齐)
///
/// 样式:半透明白色渐变 + 白色边框 + 圆角胶囊,内含"退出"文字+logout 图标。
class _LogoutButton extends StatelessWidget {
const _LogoutButton({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
const iconUrl = '${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/logout.png';
return GestureDetector(
onTap: onTap,
child: Container(
width: 72.w,
height: 32.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.r),
border: Border.all(color: Colors.white, width: 1),
gradient: const LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Color(0x00FFFFFF), Color(0x80FFFFFF)],
),
boxShadow: const [
BoxShadow(
color: Color(0x0D000000),
blurRadius: 10,
offset: Offset(0, 5),
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'退出',
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF687FAD)),
),
SizedBox(width: 3.w),
Image.network(
iconUrl,
width: 18.w,
height: 18.h,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
],
),
),
);
}
}
/// 菜单列表项数据 /// 菜单列表项数据
class _MenuItem { class _MenuItem {
const _MenuItem({ const _MenuItem({
required this.color, required this.imageUrl,
required this.title, required this.title,
required this.onTap, required this.onTap,
this.iconData,
this.trailingText, this.trailingText,
}); });
final Color color; final String imageUrl; // 图标(CDN 图片 URL,和 Vue 端保持一致)
final String title; final String title;
final VoidCallback onTap; // 点击跳转 final VoidCallback onTap; // 点击跳转
final IconData? iconData; // 普通图标
final String? trailingText; // 右侧文字,如 "382积分" final String? trailingText; // 右侧文字,如 "382积分"
} }
@@ -509,19 +605,7 @@ class _MenuCard extends StatelessWidget {
borderRadius: BorderRadius.circular(16.r), borderRadius: BorderRadius.circular(16.r),
), ),
child: Column( child: Column(
children: [ children: [for (int i = 0; i < items.length; i++) _buildRow(items[i])],
for (int i = 0; i < items.length; i++) ...[
if (i > 0)
Divider(
height: 1,
thickness: 1,
indent: 64.w,
endIndent: 16.w,
color: const Color(0xFFF0F2F5),
),
_buildRow(items[i]),
],
],
), ),
); );
} }
@@ -534,16 +618,13 @@ class _MenuCard extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
SizedBox(width: 16.w), SizedBox(width: 16.w),
// 左侧彩色方形图标 // 左侧图标(和 Vue 端一致的 CDN 图片)
Container( Image.network(
width: 36.w, item.imageUrl,
height: 36.h, width: 34.w,
decoration: BoxDecoration( height: 34.h,
color: item.color, fit: BoxFit.contain,
borderRadius: BorderRadius.circular(10.r), errorBuilder: (_, __, ___) => SizedBox(width: 34.w, height: 34.h),
),
alignment: Alignment.center,
child: Icon(item.iconData, color: Colors.white, size: 20.w),
), ),
SizedBox(width: 12.w), SizedBox(width: 12.w),
// 中间标题 // 中间标题
@@ -561,7 +642,10 @@ class _MenuCard extends StatelessWidget {
if (item.trailingText != null) ...[ if (item.trailingText != null) ...[
Text( Text(
item.trailingText!, item.trailingText!,
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF999999)), style: TextStyle(
fontSize: 13.sp,
color: const Color(0xFF999999),
),
), ),
SizedBox(width: 2.w), SizedBox(width: 2.w),
], ],
+410 -130
View File
@@ -1,39 +1,35 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:new_learn/core/api/my_point/api.dart'; import 'package:new_learn/core/api/my_point/api.dart';
import 'package:new_learn/core/api/my_point/model.dart'; import 'package:new_learn/core/api/my_point/model.dart';
import 'package:new_learn/core/logger/app_talker.dart'; import 'package:new_learn/core/logger/app_talker.dart';
import 'package:new_learn/core/providers/user_provider.dart'; import 'package:new_learn/core/providers/user_provider.dart';
import 'package:new_learn/core/components/common_paged_list.dart'; import 'package:new_learn/core/components/common_paged_list.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/core/utils/screen_adapt_template.dart';
/// ============================================================ /// ============================================================
/// 【MyPointPage】我的积分页(点「我的」tab 的"我的积分"进入) /// 【MyPointPage】我的积分页 —— 对标 Vue pages/myPackage/myPoint/index.vue
/// ///
/// 一、数据来源(对齐 Vue3 端 api/userPoint/index.ts): /// 一、数据来源(不动,保持现有实现):
/// 1. 顶部积分卡:当前总积分来自 Riverpod 全局用户 /// 1. 顶部积分数字 → currentUser.userPoints(登录时已拉取)
/// currentUser.userPoints(登录时已拉取,无需再请求接口) /// 2. 积分记录分页 → UserPointApi.getRecordPage → CommonPagedList
/// 2. 积分明细列表:GetUserPointRecordPagedList(游标分页,见下方)
/// ///
/// 二、列表为什么用 CommonPagedList: /// 二、UI 对齐 Vue(本次改动):
/// 积分记录可能几千条,必须懒加载(只渲染屏幕可见行)+ 上拉分页。 /// 1. 背景 #F5F8FA + AppBar 透明(extendBodyBehindAppBar)
/// 组件基于 infinite_scroll_pagination 封装(Flutter Favorite 认证), /// 2. 顶部大号积分数字 + 装饰色带 + "积分" 小标签(代替原橙色渐变卡)
/// 已内置:下拉刷新 / 触底加载 / 防重入 / 失败重试 / 空状态 / 没有更多。 /// 3. 列表标题栏:左侧主题色竖条 + "积分获取记录"
/// 页面只做两件事:load(调接口)+ buildItem(画每一行)。 /// 4. 列表项:SubjectTag("积分") + 消息 + 时间 + 积分值(去掉圆形加减图标)
/// 5. 加分 #1D2B47 深色 / 扣分 #687FAD 浅灰蓝(对齐 Vue CSS)
/// ///
/// 三、页码分页: /// 三、字段类型确认:
/// CommonPagedList 的 load 只给 pageKey(首页 1,之后 +1),业务把 /// UserPointRecord.points = int(固定整数)
/// pageKey 当 pageIndex 用。hasNext 用 total 判断:总条数 > 已请求条数。 /// UserModel.userPoints = double(总积分可能有小数)
/// 对应 Vue3 端 type.ts 的 UserPointRecordRequest{pageIndex,pageSize}。
///
/// 四、高频坑:
/// 1. 列表要包在 Expanded 里(Column 里无限高度会报 unbounded height);
/// 2. 积分正负号决定颜色:points>0 绿色加分,<0 橙色扣分;
/// 3. bizTime 后端直接给字符串,不需要自己格式化;
/// 4. go_router ShellRoute push 出来的页面不自动显示返回箭头
/// (issue #144687),必须手动加 leading,见 build 里 AppBar。
/// ============================================================ /// ============================================================
class MyPointPage extends ConsumerStatefulWidget { class MyPointPage extends ConsumerStatefulWidget {
const MyPointPage({super.key}); const MyPointPage({super.key});
@@ -42,8 +38,10 @@ class MyPointPage extends ConsumerStatefulWidget {
} }
class _MyPointPageState extends ConsumerState<MyPointPage> { class _MyPointPageState extends ConsumerState<MyPointPage> {
/// 每页条数(CommonPagedList 无 pageSize 概念,由业务在 load 里自定) static const int _pageSize = 15;
static const int _pageSize = 10;
/// 展开/收起控制:key = 记录在当前页内的 index
final Set<int> _expandedSet = {};
@override @override
void initState() { void initState() {
@@ -53,42 +51,84 @@ class _MyPointPageState extends ConsumerState<MyPointPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 全局用户:当前总积分(登录后才有值;未登录 0)
final user = ref.watch(currentUserProvider).value; final user = ref.watch(currentUserProvider).value;
// 对齐 Vue isPadDevice():宽度 >= 600dp 视为 Pad
final isPad = MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint;
return Scaffold( return Scaffold(
// Vue 端整体背景 #F5F8FA + 主题化背景图(Flutter 无图资源,用纯色)
backgroundColor: const Color(0xFFF5F8FA),
extendBodyBehindAppBar: true,
appBar: AppBar( appBar: AppBar(
title: const Text('我的积分'), // Vue 端 NavBar 是透明背景
// go_router 已知 issue #144687:从 ShellRoute(4 tab)内 push 出来的 backgroundColor: Colors.transparent,
// 页面,AppBar 不自动显示返回箭头。这里手动加: surfaceTintColor: Colors.transparent, // 滚动时不叠加半透明色,背景保持不变
// 能 pop(正常从"我的"进入)→ pop 回上一页; elevation: 0,
// 不能 pop(如 Web 深链/刷新直达)→ 兜底回"我的"tab。 title: const Text('我的积分', style: TextStyle(color: Color(0xFF1D2B47))),
iconTheme: const IconThemeData(color: Color(0xFF1D2B47)),
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => onPressed: () => context.canPop() ? context.pop() : context.go('/my'),
context.canPop() ? context.pop() : context.go('/my'),
), ),
), ),
body: Column( // 对齐 Vue .score-page:背景图 + #F5F8FA 底色
// background-size: 100% auto → BoxFit.fitWidth
body: Stack(
children: [ children: [
// ---------- 顶部积分卡 ---------- // 底层:主题化背景图(Vue themes/default.json → backgroundImage / backgroundImage_pad)
_PointHeaderCard(totalPoints: user?.userPoints ?? 0), // 用 CachedNetworkImage + ImgUrl.build 加载远端 CDN 图片
// ---------- 积分明细列表(分页) ---------- Positioned(
Expanded( top: 0,
child: CommonPagedList<UserPointRecord>( left: 0,
load: (page) async { right: 0,
talker.debug('加载积分记录:pageIndex=$page pageSize=$_pageSize'); child: SizedBox(
// pageKey 当 pageIndex;CommonPagedList 从 1 开始递增 height: isPad
final res = await UserPointApi.instance.getRecordPage( ? 345.h
pageIndex: page, : 260.h, // Vue topSpaceHeight: 690rpx/360rpx
pageSize: _pageSize, child: CachedNetworkImage(
); imageUrl: ImgUrl.build(
return PagingResult<UserPointRecord>( isPad
data: res.data, ? '/StaticResource/2026-06-02/myScore-bg-pad1780381071330.png'
hasNext: res.total > page * _pageSize, : '/StaticResource/2026-04-07/myScore-bg.png',
); ),
fit: BoxFit.fitWidth,
alignment: Alignment.topCenter,
placeholder: (_, __) => const SizedBox.shrink(),
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
),
),
// 积分数字行(大号数字 + 装饰色带 + "积分" 标签)
// Vue: score-summary-position top: 80rpx(pad: 260rpx)
Positioned(
top: isPad ? 145.h : 125.h,
left: 16.w,
right: 0,
child: _PointHeaderBlock(
totalPoints: user?.userPoints ?? 0.0,
isPad: isPad,
),
),
// 白色卡片(叠到渐变背景上 ~40.h)
// Vue: record-section-position margin-top: -100rpx(pad: -140rpx)
Positioned(
top: isPad ? 255.h : 230.h,
left: 14.w,
right: 14.w,
bottom: 0,
child: _RecordListSection(
pageSize: _pageSize,
expandedSet: _expandedSet,
onToggleExpand: (index) {
setState(() {
if (_expandedSet.contains(index)) {
_expandedSet.remove(index);
} else {
_expandedSet.add(index);
}
});
}, },
buildItem: (context, item, index) => _RecordRow(record: item),
), ),
), ),
], ],
@@ -97,49 +137,237 @@ class _MyPointPageState extends ConsumerState<MyPointPage> {
} }
} }
/// 顶部积分卡:当前总积分 /// 顶部积分数字行:大号数字 + 装饰色带 + "积分" 标签
class _PointHeaderCard extends StatelessWidget { /// 对齐 Vue .score-summary(Pad/手机 双尺寸)
const _PointHeaderCard({required this.totalPoints}); class _PointHeaderBlock extends StatelessWidget {
const _PointHeaderBlock({required this.totalPoints, required this.isPad});
final double totalPoints; final double totalPoints;
final bool isPad;
@override
Widget build(BuildContext context) {
// Vue 端字号:pad 132rpx/手机 88rpx → sp;label pad 42rpx/手机 28rpx → sp
final scoreFontSize = isPad ? 66.sp : 44.sp;
final scoreLineHeight = isPad ? 132 / 132 : 103 / 88;
final labelFontSize = isPad ? 21.sp : 14.sp;
final labelMarginBottom = isPad ? 3.h : 3.h; // margin-bottom: 6rpx
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// 积分数字 + 装饰色带(Stack 叠)
Stack(
clipBehavior: Clip.none,
children: [
// 装饰色带:Vue themes/default.json → score-summary__decor-background
// linear-gradient(90deg, rgba(61,125,245,0) 0%, rgba(29,156,255,0.2) 35.78%, rgba(138,236,253,0.5) 100%)
Positioned(
left: 0,
right: 0,
bottom: isPad ? -10.h : -5.h, // Vue bottom: -10rpx
child: Container(
height: isPad ? 12.h : 9.h, // height: 18rpx
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(50.r),
gradient: const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color(0x003D7DF5), // rgba(61,125,245,0)
Color(0x331D9CFF), // rgba(29,156,255,0.2)
Color(0x808AECFD), // rgba(138,236,253,0.5)
],
stops: [0, 0.3578, 1],
),
),
),
),
// 积分数字
Text(
_formatTotal(totalPoints),
style: TextStyle(
fontSize: scoreFontSize,
fontWeight: FontWeight.w500,
color: const Color(0xFF1D2B47),
height: scoreLineHeight,
),
),
],
),
SizedBox(width: 8.w),
// "积分" 小标签
Padding(
padding: EdgeInsets.only(bottom: labelMarginBottom),
child: Text(
'积分',
style: TextStyle(
fontSize: labelFontSize,
fontWeight: FontWeight.w400,
color: const Color(0xFF687FAD),
),
),
),
],
);
}
/// 总积分格式化:整数不显示 .0,有小数才显示
String _formatTotal(double v) {
if (v.truncateToDouble() == v) {
return v.toInt().toString();
}
return v.toString();
}
}
/// 积分记录列表区(带标题栏 + 白色圆角卡片)
class _RecordListSection extends StatefulWidget {
const _RecordListSection({
required this.pageSize,
required this.expandedSet,
required this.onToggleExpand,
});
final int pageSize;
final Set<int> expandedSet;
final ValueChanged<int> onToggleExpand;
@override
State<_RecordListSection> createState() => _RecordListSectionState();
}
class _RecordListSectionState extends State<_RecordListSection> {
@override
Widget build(BuildContext context) {
// 对齐 Vue 叠层:header 有顶部圆角,list 有全圆角 + 向上叠 10.h
// list 的顶部圆角从 header 底部露出来(Vue margin-top: -18rpx)
// 整个卡片的底部圆角由 list Container 自己提供(撑到屏幕底)
return Column(
children: [
// header:仅顶部圆角(Vue border-radius 32rpx 32rpx 0 0)
Container(
width: double.infinity,
height: 71.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16.r),
topRight: Radius.circular(16.r),
),
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFFFFFF), Color(0xFFFFFFFF), Color(0xFFEDF3F7)],
stops: [0, 0.5, 1],
),
),
padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 16.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 3.w,
height: 14.h,
decoration: BoxDecoration(
color: const Color(0xFF3D7DF5),
borderRadius: BorderRadius.circular(10.r),
),
),
SizedBox(width: 8.w),
Text(
'积分获取记录',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w400,
color: const Color(0xFF1D2B47),
),
),
],
),
),
// list:向上叠 10.h,自己有全圆角(Vue record-list 全圆角 + margin-top: -18rpx)
Expanded(
child: Transform.translate(
offset: Offset(0, -10.h),
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16.r),
),
child: CommonPagedList<UserPointRecord>(
padding: EdgeInsets.zero,
load: (page) async {
talker.debug(
'加载积分记录:pageIndex=$page pageSize=${widget.pageSize}',
);
final res = await UserPointApi.instance.getRecordPage(
pageIndex: page,
pageSize: widget.pageSize,
);
return PagingResult<UserPointRecord>(
data: res.data,
hasNext: res.total > page * widget.pageSize,
);
},
separatorBuilder: (_, __) => Divider(
height: 1,
thickness: 1,
color: const Color(0xFFEDF3F7),
indent: 14.w,
endIndent: 14.w,
),
buildItem: (context, item, index) => _RecordRow(
record: item,
expanded: widget.expandedSet.contains(index),
onToggle: () => widget.onToggleExpand(index),
),
),
),
),
),
],
);
}
}
/// 列表标题栏:左侧竖条 + "积分获取记录"
/// 对齐 Vue .record-header(渐变背景 + 顶部圆角 + 白色边框)
class _RecordHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
width: double.infinity, width: double.infinity,
margin: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 12.h), height: 71.h,
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 18.h), // 仅渐变背景,不设圆角 → 由外层 Container 的 clipBehavior 统一裁剪
decoration: BoxDecoration( decoration: const BoxDecoration(
gradient: const LinearGradient( gradient: LinearGradient(
colors: [Color(0xFFFF9800), Color(0xFFFFB74D)], begin: Alignment.topCenter,
begin: Alignment.topLeft, end: Alignment.bottomCenter,
end: Alignment.bottomRight, colors: [Color(0xFFFFFFFF), Color(0xFFFFFFFF), Color(0xFFEDF3F7)],
stops: [0, 0.5, 1],
), ),
borderRadius: BorderRadius.circular(16.r),
), ),
child: Column( padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 16.h),
crossAxisAlignment: CrossAxisAlignment.start, child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Text( // 左侧主题色竖条
'当前积分', Container(
style: TextStyle(fontSize: 13.sp, color: Colors.white.withValues(alpha: 0.9)), width: 3.w,
), height: 14.h,
SizedBox(height: 6.h), decoration: BoxDecoration(
Text( color: const Color(0xFF3D7DF5),
// 积分可能是小数(接口返回 105.5) borderRadius: BorderRadius.circular(10.r),
totalPoints.toString(),
style: TextStyle(
fontSize: 34.sp,
color: Colors.white,
fontWeight: FontWeight.bold,
), ),
), ),
SizedBox(height: 8.h), SizedBox(width: 8.w),
Text( Text(
'积分明细 · 变动随时记录', '积分获取记录',
style: TextStyle( style: TextStyle(
fontSize: 12.sp, fontSize: 16.sp,
color: Colors.white70, fontWeight: FontWeight.w400,
color: const Color(0xFF1D2B47),
), ),
), ),
], ],
@@ -148,83 +376,135 @@ class _PointHeaderCard extends StatelessWidget {
} }
} }
/// 单条积分记录行 /// 单条积分记录行(对标 Vue .record-item)
class _RecordRow extends StatelessWidget { class _RecordRow extends StatelessWidget {
const _RecordRow({required this.record}); const _RecordRow({
required this.record,
required this.expanded,
required this.onToggle,
});
final UserPointRecord record; final UserPointRecord record;
final bool expanded;
final VoidCallback onToggle;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 正负号决定颜色:加分绿 / 扣分橙 / 0 灰 // Vue 端:加分 #1D2B47 深色 / 扣分 #687FAD 浅灰蓝
final isAdd = record.points > 0; final bool isAdd = record.points > 0;
final isSub = record.points < 0; final Color scoreColor = isAdd
final Color signColor = isAdd ? const Color(0xFF1D2B47)
? const Color(0xFF2E7D32) : const Color(0xFF687FAD);
: (isSub ? const Color(0xFFE65100) : const Color(0xFF9E9E9E)); // points 是 int:加分加 "+",扣分直接显示负数(带 "-")
final Color iconBg = isAdd final String scoreText = isAdd ? '+${record.points}' : '${record.points}';
? const Color(0xFFE8F5E9)
: (isSub ? const Color(0xFFFFF3E0) : const Color(0xFFF5F5F5)); // 简化溢出判断:Vue 用隐藏 DOM 精确测量,Flutter 用字符长度近似
final String sign = isAdd ? '+' : (isSub ? '-' : ''); final bool overflow = record.pointMessage.length > 15;
return Padding( return Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), padding: EdgeInsets.symmetric(vertical: 15.h, horizontal: 14.w),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 左侧圆形图标(加减符号) // 左侧:SubjectTag + 消息 + 时间
Container(
width: 40.w,
height: 40.h,
decoration: BoxDecoration(
color: iconBg,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
sign,
style: TextStyle(
fontSize: 22.sp,
color: signColor,
fontWeight: FontWeight.bold,
),
),
),
SizedBox(width: 12.w),
// 中间:说明 + 时间
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
record.pointMessage, crossAxisAlignment: CrossAxisAlignment.start,
maxLines: 1, children: [
overflow: TextOverflow.ellipsis, // SubjectTag("积分"):对标 Vue <SubjectTag subject="积分" />
style: TextStyle( Container(
fontSize: 15.sp, margin: EdgeInsets.only(top: 3.h),
fontWeight: FontWeight.w500, padding: EdgeInsets.symmetric(
color: const Color(0xFF333333), horizontal: 6.w,
), vertical: 1.h,
),
decoration: BoxDecoration(
color: const Color(0xFFFFF6E3),
borderRadius: BorderRadius.circular(4.r),
),
child: Text(
'积分',
style: TextStyle(
fontSize: 11.sp,
color: const Color(0xFFE5A301),
),
),
),
SizedBox(width: 8.w),
// 消息文字(可展开/收起)
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
record.pointMessage,
maxLines: expanded ? null : 1,
overflow: expanded
? TextOverflow.visible
: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.sp,
color: const Color(0xFF1D2B47),
height: 1.6,
),
),
if (overflow)
GestureDetector(
onTap: onToggle,
child: Padding(
padding: EdgeInsets.only(top: 5.h),
child: Text(
expanded ? '收起' : '全文',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF3D7DF5),
),
),
),
),
],
),
),
],
), ),
SizedBox(height: 4.h), SizedBox(height: 10.h),
// 时间
Text( Text(
record.bizTime, _formatBizTime(record.bizTime),
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)), style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFFB4C2E0),
),
), ),
], ],
), ),
), ),
SizedBox(width: 8.w), SizedBox(width: 9.w),
// 右侧:积分变动值 // 右侧:积分变动值
Text( Padding(
'$sign${record.points.abs()}', padding: EdgeInsets.only(top: 9.h),
style: TextStyle( child: Text(
fontSize: 16.sp, scoreText,
fontWeight: FontWeight.bold, style: TextStyle(
color: signColor, fontSize: 18.sp,
fontWeight: FontWeight.w400,
color: scoreColor,
),
), ),
), ),
], ],
), ),
); );
} }
/// Vue 端 formatBizTime:替换 T→空格、去掉 Z、截前 19 位
String _formatBizTime(String bizTime) {
if (bizTime.isEmpty) return '';
final s = bizTime.replaceAll('T', ' ').replaceAll('Z', '');
// Dart String.substring 不越界,手动判断长度
return s.length > 19 ? s.substring(0, 19) : s;
}
} }
+1
View File
@@ -236,6 +236,7 @@ flutter:
- assets/img/tabbar/default/ - assets/img/tabbar/default/
- assets/audio/ - assets/audio/
- assets/img/class/ - assets/img/class/
- assets/img/my/
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
# assets: # assets: