diff --git a/.gitignore b/.gitignore index 3820a95..4f16a5b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Miscellaneous +.trae/ *.class *.log *.pyc diff --git a/assets/img/my/myScore-bg.png b/assets/img/my/myScore-bg.png new file mode 100644 index 0000000..334d4b4 Binary files /dev/null and b/assets/img/my/myScore-bg.png differ diff --git a/lib/core/components/common_paged_list.dart b/lib/core/components/common_paged_list.dart index 779cd16..0663e7b 100644 --- a/lib/core/components/common_paged_list.dart +++ b/lib/core/components/common_paged_list.dart @@ -73,7 +73,7 @@ class CommonPagedList extends StatefulWidget { this.separatorBuilder, this.padding, this.emptyWidget, - this.noMoreText = '— 没有更多了 —', + this.noMoreText = '- AI 爱学蝶变提醒你 没有更多了 -', this.invisibleItemsThreshold = 0, this.enableRefresh = true, }); diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 1efa595..ea8f947 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -95,34 +95,33 @@ final GoRouter appRouter = GoRouter( GoRoute(path: '/recite', builder: (_, _) => const RecitePage()), ], ), - // 班级(含班级模块子页:全部同学 / 我的互动 / 动态详情) + // 班级(只有主 tab 页在 ShellBranch 内,子页放顶层避免 tabbar 遮挡) StatefulShellBranch( routes: [ GoRoute(path: '/class', builder: (_, _) => const ClassPage()), - GoRoute( - path: '/class/all-student', - builder: (_, _) => const AllStudentPage(), - ), - GoRoute( - path: '/class/my-interact', - builder: (_, _) => const MyInteractPage(), - ), - GoRoute( - path: '/class/my-update-detail', - builder: (_, state) => - MyUpdateDetailPage(initialPost: state.extra as UserPostDto), - ), ], ), - // 我的(含我的积分页) + // 我的 StatefulShellBranch( - routes: [ - GoRoute(path: '/my', builder: (_, _) => const MyPage()), - GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()), - ], + routes: [GoRoute(path: '/my', builder: (_, _) => const MyPage())], ), ], ), + // ---------- 二级业务页(不在 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( path: '/privacy-consent', diff --git a/lib/features/class/class_update_view.dart b/lib/features/class/class_update_view.dart index 7fc4b0f..8d84157 100644 --- a/lib/features/class/class_update_view.dart +++ b/lib/features/class/class_update_view.dart @@ -286,7 +286,7 @@ class _ClassUpdateViewState extends ConsumerState { padding: EdgeInsets.symmetric(vertical: 20.h), child: Center( child: Text( - '没有更多了', + '- AI 爱学蝶变提醒你 没有更多了 -', style: TextStyle( fontSize: 12.sp, color: const Color(0xFFB4C2E0), diff --git a/lib/features/class/my_interact_page.dart b/lib/features/class/my_interact_page.dart index 0d6cad9..c02c978 100644 --- a/lib/features/class/my_interact_page.dart +++ b/lib/features/class/my_interact_page.dart @@ -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 { 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 { 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), + ], + ), + ), ], ), ), diff --git a/lib/features/class/point_rank_view.dart b/lib/features/class/point_rank_view.dart index 92de141..6b8858a 100644 --- a/lib/features/class/point_rank_view.dart +++ b/lib/features/class/point_rank_view.dart @@ -174,7 +174,11 @@ class _PointRankViewState extends ConsumerState { ? 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 { 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 { ), ), ), - if (active) _buildTriangle(), + // 始终渲染三角形占位,visible 切换避免外层 Column 高度变化抖动 + Visibility( + visible: active, + maintainSize: true, + maintainAnimation: true, + maintainState: true, + child: _buildTriangle(), + ), ], ), ); @@ -338,11 +349,7 @@ class _PointRankViewState extends ConsumerState { ), ), 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 { ); } + 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( diff --git a/lib/features/class/widgets/post_list_body.dart b/lib/features/class/widgets/post_list_body.dart index 564c906..1395db8 100644 --- a/lib/features/class/widgets/post_list_body.dart +++ b/lib/features/class/widgets/post_list_body.dart @@ -253,7 +253,7 @@ class _PostListBodyState extends ConsumerState { padding: EdgeInsets.symmetric(vertical: 20.h), child: Center( child: Text( - '没有更多了', + '- AI 爱学蝶变提醒你 没有更多了 -', style: TextStyle( fontSize: 12.sp, color: const Color(0xFFB4C2E0), diff --git a/lib/features/my/my_point/my_point_page.dart b/lib/features/my/my_point/my_point_page.dart index c85028d..2e7379e 100644 --- a/lib/features/my/my_point/my_point_page.dart +++ b/lib/features/my/my_point/my_point_page.dart @@ -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 { - /// 每页条数(CommonPagedList 无 pageSize 概念,由业务在 load 里自定) - static const int _pageSize = 10; + static const int _pageSize = 15; + + /// 展开/收起控制:key = 记录在当前页内的 index + final Set _expandedSet = {}; @override void initState() { @@ -53,42 +51,84 @@ class _MyPointPageState extends ConsumerState { @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( - 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( - 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 { } } -/// 顶部积分卡:当前总积分 -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 expandedSet; + final ValueChanged 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( + 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( + 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 + 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; + } }