1. 重构路由结构:将班级子页移出ShellBranch,解决tabbar遮挡问题 2. 统一修改全局"没有更多了"提示文案为带品牌标识的版本 3. 重构积分排行榜UI,修复切换状态时的布局抖动问题,替换图标为CDN资源 4. 重构我的积分页面:对齐Vue端UI设计,新增展开收起详情功能,优化背景与布局 5. 优化我的互动页面布局与样式,新增背景图支持
511 lines
18 KiB
Dart
511 lines
18 KiB
Dart
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】我的积分页 —— 对标 Vue pages/myPackage/myPoint/index.vue
|
||
///
|
||
/// 一、数据来源(不动,保持现有实现):
|
||
/// 1. 顶部积分数字 → currentUser.userPoints(登录时已拉取)
|
||
/// 2. 积分记录分页 → UserPointApi.getRecordPage → CommonPagedList
|
||
///
|
||
/// 二、UI 对齐 Vue(本次改动):
|
||
/// 1. 背景 #F5F8FA + AppBar 透明(extendBodyBehindAppBar)
|
||
/// 2. 顶部大号积分数字 + 装饰色带 + "积分" 小标签(代替原橙色渐变卡)
|
||
/// 3. 列表标题栏:左侧主题色竖条 + "积分获取记录"
|
||
/// 4. 列表项:SubjectTag("积分") + 消息 + 时间 + 积分值(去掉圆形加减图标)
|
||
/// 5. 加分 #1D2B47 深色 / 扣分 #687FAD 浅灰蓝(对齐 Vue CSS)
|
||
///
|
||
/// 三、字段类型确认:
|
||
/// UserPointRecord.points = int(固定整数)
|
||
/// UserModel.userPoints = double(总积分可能有小数)
|
||
/// ============================================================
|
||
|
||
class MyPointPage extends ConsumerStatefulWidget {
|
||
const MyPointPage({super.key});
|
||
|
||
@override
|
||
ConsumerState<MyPointPage> createState() => _MyPointPageState();
|
||
}
|
||
|
||
class _MyPointPageState extends ConsumerState<MyPointPage> {
|
||
static const int _pageSize = 15;
|
||
|
||
/// 展开/收起控制:key = 记录在当前页内的 index
|
||
final Set<int> _expandedSet = {};
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
talker.debug('打开页面:我的积分');
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
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(
|
||
// 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'),
|
||
),
|
||
),
|
||
// 对齐 Vue .score-page:背景图 + #F5F8FA 底色
|
||
// background-size: 100% auto → BoxFit.fitWidth
|
||
body: Stack(
|
||
children: [
|
||
// 底层:主题化背景图(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);
|
||
}
|
||
});
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 顶部积分数字行:大号数字 + 装饰色带 + "积分" 标签
|
||
/// 对齐 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,
|
||
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],
|
||
),
|
||
),
|
||
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),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 单条积分记录行(对标 Vue .record-item)
|
||
class _RecordRow extends StatelessWidget {
|
||
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) {
|
||
// 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(vertical: 15.h, horizontal: 14.w),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// 左侧:SubjectTag + 消息 + 时间
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
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: 10.h),
|
||
// 时间
|
||
Text(
|
||
_formatBizTime(record.bizTime),
|
||
style: TextStyle(
|
||
fontSize: 12.sp,
|
||
color: const Color(0xFFB4C2E0),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
SizedBox(width: 9.w),
|
||
// 右侧:积分变动值
|
||
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;
|
||
}
|
||
}
|