refactor: 重构页面路由结构与积分页面UI,优化列表交互体验

1. 重构路由结构:将班级子页移出ShellBranch,解决tabbar遮挡问题
2. 统一修改全局"没有更多了"提示文案为带品牌标识的版本
3. 重构积分排行榜UI,修复切换状态时的布局抖动问题,替换图标为CDN资源
4. 重构我的积分页面:对齐Vue端UI设计,新增展开收起详情功能,优化背景与布局
5. 优化我的互动页面布局与样式,新增背景图支持
This commit is contained in:
崔宝新
2026-09-08 13:42:10 +08:00
parent 3a2b589e37
commit 2aa1927694
9 changed files with 515 additions and 170 deletions
+1 -1
View File
@@ -286,7 +286,7 @@ class _ClassUpdateViewState extends ConsumerState<ClassUpdateView> {
padding: EdgeInsets.symmetric(vertical: 20.h),
child: Center(
child: Text(
'没有更多了',
'- AI 爱学蝶变提醒你 没有更多了 -',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFFB4C2E0),
+42 -10
View File
@@ -1,8 +1,10 @@
import 'package:flutter/material.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:new_learn/core/api/class/api.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';
/// ============================================================
@@ -45,9 +47,10 @@ class _MyInteractPageState extends State<MyInteractPage> {
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F8FA),
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: const Color(0xFFF5F8FA),
surfaceTintColor: Colors.transparent, // 滚动时不叠加半透明色,背景保持不变
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
elevation: 0,
title: const Text(
'我的互动',
@@ -64,16 +67,45 @@ class _MyInteractPageState extends State<MyInteractPage> {
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: [
_buildTabs(),
Expanded(
child: IndexedStack(
index: _currentTab,
Positioned(
top: 0,
left: 0,
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: [
if (_loadedTabs[0]) _UpdateTab(onTapPost: _openDetail),
if (_loadedTabs[1]) _LikeTab(onTapPost: _openDetail),
if (_loadedTabs[2]) _CommentTab(onTapPost: _openDetail),
_buildTabs(),
Expanded(
child: IndexedStack(
index: _currentTab,
children: [
if (_loadedTabs[0]) _UpdateTab(onTapPost: _openDetail),
if (_loadedTabs[1]) _LikeTab(onTapPost: _openDetail),
if (_loadedTabs[2]) _CommentTab(onTapPost: _openDetail),
],
),
),
],
),
),
+41 -8
View File
@@ -174,7 +174,11 @@ class _PointRankViewState extends ConsumerState<PointRankView> {
? Colors.white
: Colors.white.withValues(alpha: 0.5),
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 端回退字体行高偏大,固定高度可能溢出 → 内容放不下时自动缩小
child: FittedBox(
@@ -190,7 +194,7 @@ class _PointRankViewState extends ConsumerState<PointRankView> {
color: active ? _primary : const Color(0xFF687FAD),
),
),
SizedBox(height: 4.h),
// SizedBox(height: 0.5.h),
Text(
'$countText人',
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),
Icon(
_sortDesc ? Icons.arrow_downward : Icons.arrow_upward,
size: 14,
color: _primary,
),
_buildSortIcons(),
],
),
),
@@ -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() {
final rank = _myRank!;
return Container(
@@ -253,7 +253,7 @@ class _PostListBodyState extends ConsumerState<PostListBody> {
padding: EdgeInsets.symmetric(vertical: 20.h),
child: Center(
child: Text(
'没有更多了',
'- AI 爱学蝶变提醒你 没有更多了 -',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFFB4C2E0),
+410 -130
View File
@@ -1,39 +1,35 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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:new_learn/core/api/my_point/api.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/providers/user_provider.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 全局用户
/// currentUser.userPoints(登录时已拉取,无需再请求接口)
/// 2. 积分明细列表:GetUserPointRecordPagedList(游标分页,见下方)
/// 一、数据来源(不动,保持现有实现):
/// 1. 顶部积分数字 → currentUser.userPoints(登录时已拉取)
/// 2. 积分记录分页 → UserPointApi.getRecordPage → CommonPagedList
///
/// 二、列表为什么用 CommonPagedList:
/// 积分记录可能几千条,必须懒加载(只渲染屏幕可见行)+ 上拉分页。
/// 组件基于 infinite_scroll_pagination 封装(Flutter Favorite 认证),
/// 已内置:下拉刷新 / 触底加载 / 防重入 / 失败重试 / 空状态 / 没有更多。
/// 页面只做两件事:load(调接口)+ buildItem(画每一行)。
/// 二、UI 对齐 Vue(本次改动):
/// 1. 背景 #F5F8FA + AppBar 透明(extendBodyBehindAppBar)
/// 2. 顶部大号积分数字 + 装饰色带 + "积分" 小标签(代替原橙色渐变卡)
/// 3. 列表标题栏:左侧主题色竖条 + "积分获取记录"
/// 4. 列表项:SubjectTag("积分") + 消息 + 时间 + 积分值(去掉圆形加减图标)
/// 5. 加分 #1D2B47 深色 / 扣分 #687FAD 浅灰蓝(对齐 Vue CSS)
///
/// 三、页码分页:
/// CommonPagedList 的 load 只给 pageKey(首页 1,之后 +1),业务把
/// pageKey 当 pageIndex 用。hasNext 用 total 判断:总条数 > 已请求条数。
/// 对应 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。
/// 三、字段类型确认:
/// UserPointRecord.points = int(固定整数)
/// UserModel.userPoints = double(总积分可能有小数)
/// ============================================================
class MyPointPage extends ConsumerStatefulWidget {
const MyPointPage({super.key});
@@ -42,8 +38,10 @@ class MyPointPage extends ConsumerStatefulWidget {
}
class _MyPointPageState extends ConsumerState<MyPointPage> {
/// 每页条数(CommonPagedList 无 pageSize 概念,由业务在 load 里自定)
static const int _pageSize = 10;
static const int _pageSize = 15;
/// 展开/收起控制:key = 记录在当前页内的 index
final Set<int> _expandedSet = {};
@override
void initState() {
@@ -53,42 +51,84 @@ class _MyPointPageState extends ConsumerState<MyPointPage> {
@override
Widget build(BuildContext context) {
// 全局用户:当前总积分(登录后才有值;未登录 0)
final user = ref.watch(currentUserProvider).value;
// 对齐 Vue isPadDevice():宽度 >= 600dp 视为 Pad
final isPad = MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint;
return Scaffold(
// Vue 端整体背景 #F5F8FA + 主题化背景图(Flutter 无图资源,用纯色)
backgroundColor: const Color(0xFFF5F8FA),
extendBodyBehindAppBar: true,
appBar: AppBar(
title: const Text('我的积分'),
// go_router 已知 issue #144687:从 ShellRoute(4 tab)内 push 出来的
// 页面,AppBar 不自动显示返回箭头。这里手动加:
// 能 pop(正常从"我的"进入)→ pop 回上一页;
// 不能 pop(如 Web 深链/刷新直达)→ 兜底回"我的"tab。
// Vue 端 NavBar 是透明背景
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent, // 滚动时不叠加半透明色,背景保持不变
elevation: 0,
title: const Text('我的积分', style: TextStyle(color: Color(0xFF1D2B47))),
iconTheme: const IconThemeData(color: Color(0xFF1D2B47)),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () =>
context.canPop() ? context.pop() : context.go('/my'),
onPressed: () => context.canPop() ? context.pop() : context.go('/my'),
),
),
body: Column(
// 对齐 Vue .score-page:背景图 + #F5F8FA 底色
// background-size: 100% auto → BoxFit.fitWidth
body: Stack(
children: [
// ---------- 顶部积分卡 ----------
_PointHeaderCard(totalPoints: user?.userPoints ?? 0),
// ---------- 积分明细列表(分页) ----------
Expanded(
child: CommonPagedList<UserPointRecord>(
load: (page) async {
talker.debug('加载积分记录:pageIndex=$page pageSize=$_pageSize');
// pageKey 当 pageIndex;CommonPagedList 从 1 开始递增
final res = await UserPointApi.instance.getRecordPage(
pageIndex: page,
pageSize: _pageSize,
);
return PagingResult<UserPointRecord>(
data: res.data,
hasNext: res.total > page * _pageSize,
);
// 底层:主题化背景图(Vue themes/default.json → backgroundImage / backgroundImage_pad)
// 用 CachedNetworkImage + ImgUrl.build 加载远端 CDN 图片
Positioned(
top: 0,
left: 0,
right: 0,
child: SizedBox(
height: isPad
? 345.h
: 260.h, // Vue topSpaceHeight: 690rpx/360rpx
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
isPad
? '/StaticResource/2026-06-02/myScore-bg-pad1780381071330.png'
: '/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 {
const _PointHeaderCard({required this.totalPoints});
/// 顶部积分数字行:大号数字 + 装饰色带 + "积分" 标签
/// 对齐 Vue .score-summary(Pad/手机 双尺寸)
class _PointHeaderBlock extends StatelessWidget {
const _PointHeaderBlock({required this.totalPoints, required this.isPad});
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
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 12.h),
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 18.h),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFFFF9800), Color(0xFFFFB74D)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
height: 71.h,
// 仅渐变背景,不设圆角 → 由外层 Container 的 clipBehavior 统一裁剪
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFFFFFF), Color(0xFFFFFFFF), Color(0xFFEDF3F7)],
stops: [0, 0.5, 1],
),
borderRadius: BorderRadius.circular(16.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 16.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'当前积分',
style: TextStyle(fontSize: 13.sp, color: Colors.white.withValues(alpha: 0.9)),
),
SizedBox(height: 6.h),
Text(
// 积分可能是小数(接口返回 105.5)
totalPoints.toString(),
style: TextStyle(
fontSize: 34.sp,
color: Colors.white,
fontWeight: FontWeight.bold,
// 左侧主题色竖条
Container(
width: 3.w,
height: 14.h,
decoration: BoxDecoration(
color: const Color(0xFF3D7DF5),
borderRadius: BorderRadius.circular(10.r),
),
),
SizedBox(height: 8.h),
SizedBox(width: 8.w),
Text(
'积分明细 · 变动随时记录',
'积分获取记录',
style: TextStyle(
fontSize: 12.sp,
color: Colors.white70,
fontSize: 16.sp,
fontWeight: FontWeight.w400,
color: const Color(0xFF1D2B47),
),
),
],
@@ -148,83 +376,135 @@ class _PointHeaderCard extends StatelessWidget {
}
}
/// 单条积分记录行
/// 单条积分记录行(对标 Vue .record-item)
class _RecordRow extends StatelessWidget {
const _RecordRow({required this.record});
const _RecordRow({
required this.record,
required this.expanded,
required this.onToggle,
});
final UserPointRecord record;
final bool expanded;
final VoidCallback onToggle;
@override
Widget build(BuildContext context) {
// 正负号决定颜色:加分绿 / 扣分橙 / 0 灰
final isAdd = record.points > 0;
final isSub = record.points < 0;
final Color signColor = isAdd
? const Color(0xFF2E7D32)
: (isSub ? const Color(0xFFE65100) : const Color(0xFF9E9E9E));
final Color iconBg = isAdd
? const Color(0xFFE8F5E9)
: (isSub ? const Color(0xFFFFF3E0) : const Color(0xFFF5F5F5));
final String sign = isAdd ? '+' : (isSub ? '-' : '');
// Vue 端:加分 #1D2B47 深色 / 扣分 #687FAD 浅灰蓝
final bool isAdd = record.points > 0;
final Color scoreColor = isAdd
? const Color(0xFF1D2B47)
: const Color(0xFF687FAD);
// points 是 int:加分加 "+",扣分直接显示负数(带 "-")
final String scoreText = isAdd ? '+${record.points}' : '${record.points}';
// 简化溢出判断:Vue 用隐藏 DOM 精确测量,Flutter 用字符长度近似
final bool overflow = record.pointMessage.length > 15;
return Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h),
padding: EdgeInsets.symmetric(vertical: 15.h, horizontal: 14.w),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 左侧圆形图标(加减符号)
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),
// 中间:说明 + 时间
// 左侧:SubjectTag + 消息 + 时间
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
record.pointMessage,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w500,
color: const Color(0xFF333333),
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// SubjectTag("积分"):对标 Vue <SubjectTag subject="积分" />
Container(
margin: EdgeInsets.only(top: 3.h),
padding: EdgeInsets.symmetric(
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(
record.bizTime,
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
_formatBizTime(record.bizTime),
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFFB4C2E0),
),
),
],
),
),
SizedBox(width: 8.w),
SizedBox(width: 9.w),
// 右侧:积分变动值
Text(
'$sign${record.points.abs()}',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.bold,
color: signColor,
Padding(
padding: EdgeInsets.only(top: 9.h),
child: Text(
scoreText,
style: TextStyle(
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;
}
}