Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aa1927694 | ||
|
|
3a2b589e37 |
@@ -1,4 +1,5 @@
|
|||||||
# Miscellaneous
|
# Miscellaneous
|
||||||
|
.trae/
|
||||||
*.class
|
*.class
|
||||||
*.log
|
*.log
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
|
After Width: | Height: | Size: 427 B |
|
After Width: | Height: | Size: 375 B |
|
After Width: | Height: | Size: 655 B |
|
After Width: | Height: | Size: 568 B |
|
After Width: | Height: | Size: 275 B |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 328 B |
|
After Width: | Height: | Size: 492 B |
|
After Width: | Height: | Size: 424 B |
|
After Width: | Height: | Size: 727 B |
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||