router 冲突解决

This commit is contained in:
chenshuainan
2026-09-09 16:24:44 +08:00
19 changed files with 1658 additions and 802 deletions
+48
View File
@@ -89,6 +89,54 @@ class UserApi {
);
}
/// 注销账号 → POST /api/v1/User/CloseAccount
///
/// 对齐 Vue api/login: `closeAccount = () => request.post('/api/v1/User/CloseAccount')`
/// 调用方负责弹确认框 + 注销后清理本地状态。
static Future<void> closeAccount() async {
await DioClient.instance.post('/api/v1/User/CloseAccount');
}
// ==================== 注册 ====================
/// 注册 → POST /api/v1/User/Register
///
/// 对齐 Vue api/login `register`: { account, password },密码同样 MD5 加密
/// Vue RegisterResponse = { code, message }(业务响应壳),Flutter 端 DioClient
/// 已自动处理成功/失败分支,成功不关心 body,失败直接抛 ApiException。
static Future<void> register({
required String account,
required String password,
}) async {
await DioClient.instance.post(
'/api/v1/User/Register',
data: {'account': account.trim(), 'password': _md5(password.trim())},
);
}
// ==================== 审核模式 ====================
/// 获取审核模式(决定是否展示"去注册"入口)→ GET /api/v1/Public/GetIsAuditMode
///
/// 对齐 Vue api/login: `getAuditMode()`,返回值本身就是 boolean
/// (request 拦截器已解包),接口不可用时安全回退 false(不展示注册入口)。
static Future<bool> getAuditMode() async {
try {
final data = await DioClient.instance.get(
'/api/v1/Public/GetIsAuditMode',
);
if (data is bool) return data;
if (data is Map) {
// 兼容 { isAuditMode: true } 包装
final v = data['isAuditMode'];
if (v is bool) return v;
}
return false;
} catch (_) {
return false;
}
}
/// MD5 加密(UniApp stringToMd5 的等价实现)
static String _md5(String text) {
return md5.convert(utf8.encode(text)).toString();
+1 -1
View File
@@ -73,7 +73,7 @@ class CommonPagedList<T> extends StatefulWidget {
this.separatorBuilder,
this.padding,
this.emptyWidget,
this.noMoreText = '— 没有更多了 —',
this.noMoreText = '- AI 爱学蝶变提醒你 没有更多了 -',
this.invisibleItemsThreshold = 0,
this.enableRefresh = true,
});
+7 -2
View File
@@ -22,8 +22,10 @@ class AppEnv {
/// 品牌标识:aixue / chucai / yule / ...(默认 aixue=爱学蝶变)
/// 品牌表见 app_brand.dart;主题/接口/App 名都随它走(AppConfig 聚合)
static const String brand =
String.fromEnvironment('BRAND', defaultValue: 'aixue');
static const String brand = String.fromEnvironment(
'BRAND',
defaultValue: 'aixue',
);
/// 三套环境地址表(与 uni-app envList 完全对齐)
static const Map<String, Map<String, String>> _envTable = {
@@ -58,6 +60,9 @@ class AppEnv {
/// 当前环境学习报告地址(预留,对标 envList.reportUrl)
static String get reportUrl => value('reportUrl');
/// 静态资源 CDN 域名(对标 uni-app utils/image.js 的 IMG_URL)
static const String imgBaseUrl = 'https://hx.cnzhyx.cn';
/// 是否正式环境(组合规则里判断用:prod 才走品牌专属地址)
static bool get isProd => env == 'prod';
}
+1 -1
View File
@@ -38,7 +38,7 @@ import 'package:talker_dio_logger/talker_dio_logger.dart';
/// ============================================================
class DioClient {
/// 网络日志总开关:true = 控制台打印请求/响应数据,false = 静默
static const bool enableNetworkLog = false;
static const bool enableNetworkLog = true;
// ---------- 1. 单例:整个 App 只有一个 Dio ----------
DioClient._()
+46 -23
View File
@@ -6,6 +6,7 @@ import 'package:new_learn/features/auth/legal_document_page.dart';
import 'package:new_learn/features/auth/legal_documents.dart';
import 'package:new_learn/features/auth/login_page.dart';
import 'package:new_learn/features/auth/privacy_consent_page.dart';
import 'package:new_learn/features/auth/register_page.dart';
import 'package:new_learn/features/class/all_student_view.dart';
import 'package:new_learn/features/class/class_page.dart';
import 'package:new_learn/features/class/my_interact_page.dart';
@@ -14,6 +15,10 @@ import 'package:new_learn/core/api/class/model.dart';
import 'package:new_learn/features/my/my_page.dart';
import 'package:new_learn/features/conquerPackage/captureList/index.dart';
import 'package:new_learn/features/my/my_point/my_point_page.dart';
import 'package:new_learn/features/my/my_report/my_report_page.dart';
import 'package:new_learn/features/my/my_report/report_detail_page.dart';
import 'package:new_learn/features/my/settings/settings_page.dart';
import 'package:new_learn/features/my/settings/about_app_page.dart';
import 'package:new_learn/features/recite/recite_page.dart';
import 'package:new_learn/features/task/task_page.dart';
import 'package:new_learn/core/router/main_tab_page.dart';
@@ -96,34 +101,47 @@ 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: '/myReport', builder: (_, _) => const MyReportPage()),
GoRoute(
path: '/report-detail',
builder: (_, state) {
// extra 格式:{'title': '...', 'url': '...'}
final extra = state.extra as Map<String, dynamic>? ?? {};
return ReportDetailPage(
title: extra['title'] as String? ?? '我的报告',
url: extra['url'] as String? ?? '',
);
},
),
GoRoute(path: '/settings', builder: (_, _) => const SettingsPage()),
GoRoute(path: '/about-app', builder: (_, _) => const AboutAppPage()),
// ---------- 首次启动隐私协议页 ----------
GoRoute(
path: '/privacy-consent',
@@ -143,7 +161,7 @@ final GoRouter appRouter = GoRouter(
content: kPrivacyPolicyContent,
),
),
// ---------- 登录页 ----------
// ---------- 登录/注册页(未登录可访问) ----------
GoRoute(path: '/login', builder: (_, _) => const LoginPage()),
// ---------- 攻克包 - 截图名单 ----------
GoRoute(
@@ -151,6 +169,8 @@ final GoRouter appRouter = GoRouter(
builder: (_, state) =>
CaptureListPage(courseId: state.extra as String),
),
GoRoute(path: '/register', builder: (_, _) => const RegisterPage()),
// app_router.dart 的 routes 里加,不用管跳转逻辑
// GoRoute(path: '/task/:id', builder: (_, _) => const TaskDetailPage()),
// ---------- 以后新业务页在这里加,一行一个 ----------
@@ -158,7 +178,7 @@ final GoRouter appRouter = GoRouter(
);
/// 登录守卫(对标 vue-router beforeEach):
/// 没登录 → 强制去 /login;已登录还访问 /login → 踢回首页
/// 没登录 → 强制去 /login;已登录还访问 /login 或 /register → 踢回首页
String? _guardLogin(BuildContext context, GoRouterState state) {
final acceptedPrivacy = privacyAcceptedNotifier.value;
final loggedIn = DioClient.instance.accessToken != null;
@@ -167,11 +187,14 @@ String? _guardLogin(BuildContext context, GoRouterState state) {
state.matchedLocation == '/user-agreement' ||
state.matchedLocation == '/privacy-policy';
final atLogin = state.matchedLocation == '/login';
final atRegister = state.matchedLocation == '/register';
// 未登录可白名单:隐私协议页 / 协议文档 / 登录页 / 注册页
final atAuthFree = atLogin || atRegister || atLegalDocument;
if (!acceptedPrivacy && !atPrivacyConsent && !atLegalDocument) {
return '/privacy-consent';
}
if (acceptedPrivacy && atPrivacyConsent) return loggedIn ? '/' : '/login';
if (!loggedIn && !atLogin && !atLegalDocument) return '/login';
if (loggedIn && atLogin) return '/';
if (!loggedIn && !atAuthFree && !atPrivacyConsent) return '/login';
if (loggedIn && (atLogin || atRegister)) return '/';
return null;
}
+68
View File
@@ -4,6 +4,44 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:new_learn/core/models/user_model.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// ============================================================
/// 【LoginHistoryItem】登录历史单条记录 —— 对齐 Vue loginHistory
///
/// account → 登录账号(唯一标识,重复登录时去重用)
/// userName → 登录后拿到的昵称(展示用,可能为空)
/// password → 记住的密码(明文,存在 secure_storage 里一条 JSON)
/// 空串 "" 表示本次没勾选记住密码
/// rememberPassword → 是否勾选了记住密码(决定密码是否回填)
/// ============================================================
class LoginHistoryItem {
const LoginHistoryItem({
required this.account,
required this.userName,
required this.password,
required this.rememberPassword,
});
final String account;
final String userName;
final String password;
final bool rememberPassword;
Map<String, dynamic> toJson() => {
'account': account,
'userName': userName,
'password': password,
'rememberPassword': rememberPassword,
};
factory LoginHistoryItem.fromJson(Map<String, dynamic> json) =>
LoginHistoryItem(
account: json['account'] as String? ?? '',
userName: json['userName'] as String? ?? '',
password: json['password'] as String? ?? '',
rememberPassword: json['rememberPassword'] as bool? ?? false,
);
}
/// ============================================================
/// 【UserStorage】用户本地存储(token 安全存储 + 用户信息缓存)
///
@@ -118,6 +156,36 @@ class UserStorage {
Future<void> clearRememberedPassword() =>
_secure.delete(key: _rememberedPasswordKey);
// ---------- 登录历史(多条,最多 3 条,对齐 Vue loginHistory) ----------
static const String _loginHistoryKey = 'login_history';
static const int _maxHistoryCount = 3;
/// 读取登录历史(没存过返回空列表)
Future<List<LoginHistoryItem>> readLoginHistory() async {
final raw = await _secure.read(key: _loginHistoryKey);
if (raw == null) return [];
try {
final list = jsonDecode(raw) as List<dynamic>;
return list
.map((e) => LoginHistoryItem.fromJson(e as Map<String, dynamic>))
.toList();
} catch (_) {
return []; // 缓存损坏时当没历史处理
}
}
/// 保存登录历史(整体替换存,最多保留 3 条)
Future<void> saveLoginHistory(List<LoginHistoryItem> items) async {
final trimmed = items.length > _maxHistoryCount
? items.sublist(0, _maxHistoryCount)
: items;
await _secure.write(
key: _loginHistoryKey,
value: jsonEncode(trimmed.map((e) => e.toJson()).toList()),
);
}
/// 一键退出登录:清 Token + 用户信息
/// (记住的账号密码保留,方便用户下次登录)
Future<void> clearAll() async {
File diff suppressed because it is too large Load Diff
+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),
+65 -29
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,8 +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),
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
elevation: 0,
title: const Text(
'我的互动',
@@ -63,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),
],
),
),
],
),
),
@@ -127,13 +160,14 @@ class _UpdateTab extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PostListBody(
fetchPage: ({required pageIndex, required pageSize, required prevPageId}) =>
ClassApi.instance.getUserClassPost(
queryType: 0,
pageIndex: pageIndex,
pageSize: pageSize,
prevPageId: prevPageId,
),
fetchPage:
({required pageIndex, required pageSize, required prevPageId}) =>
ClassApi.instance.getUserClassPost(
queryType: 0,
pageIndex: pageIndex,
pageSize: pageSize,
prevPageId: prevPageId,
),
emptyText: '暂无动态',
onTapPost: onTapPost,
);
@@ -147,12 +181,13 @@ class _LikeTab extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PostListBody(
fetchPage: ({required pageIndex, required pageSize, required prevPageId}) =>
ClassApi.instance.getMyLikePage(
pageIndex: pageIndex,
pageSize: pageSize,
prevPageId: prevPageId,
),
fetchPage:
({required pageIndex, required pageSize, required prevPageId}) =>
ClassApi.instance.getMyLikePage(
pageIndex: pageIndex,
pageSize: pageSize,
prevPageId: prevPageId,
),
emptyText: '暂无点赞',
removeOnUnlike: true,
onTapPost: onTapPost,
@@ -167,14 +202,15 @@ class _CommentTab extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PostListBody(
fetchPage: ({required pageIndex, required pageSize, required prevPageId}) =>
ClassApi.instance.getMyCommentPage(
pageIndex: pageIndex,
pageSize: pageSize,
prevPageId: prevPageId,
),
fetchPage:
({required pageIndex, required pageSize, required prevPageId}) =>
ClassApi.instance.getMyCommentPage(
pageIndex: pageIndex,
pageSize: pageSize,
prevPageId: prevPageId,
),
emptyText: '暂无留言',
onTapPost: onTapPost,
);
}
}
}
+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(
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/api/class/model.dart';
import 'package:new_learn/core/components/image_preview_viewer.dart';
import 'package:new_learn/core/components/resolve_problem_box.dart';
import 'package:new_learn/core/components/user_circle_avatar.dart';
import 'package:new_learn/core/utils/img_url.dart';
@@ -12,7 +13,8 @@ import 'package:new_learn/core/utils/img_url.dart';
/// 【班级动态组件】由 class_update_view.dart 抽离的展示组件
///
/// PostCard(动态卡片,按 type 分发内容 + 相册 + 点赞评论栏)
/// CardHeader / SubjectTag / ResolveProblemBox / AlbumDesc / AlbumGrid
/// CardHeader / SubjectTag / AlbumDesc / AlbumGrid / LikeCommentBar / ...
/// ResolveProblemBox 已抽离到 core/components/resolve_problem_box.dart
/// LikeCommentBar / MetaButton / CommentPreview / CommentInput
///
/// 原为 class_update_view.dart 内的私有组件,重命名去下划线后放到本文件。
@@ -263,65 +265,6 @@ class SubjectTag extends StatelessWidget {
}
}
/// 攻克任务卡(对标 ResolveProblem)
class ResolveProblemBox extends StatelessWidget {
const ResolveProblemBox({super.key, required this.desc});
final String desc;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: EdgeInsets.fromLTRB(19.w, 25.h, 19.w, 11.h),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFEAF4FF), Color(0xFFFFFFFF)],
),
borderRadius: BorderRadius.circular(10.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'刚刚攻克了',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w900,
color: Color(0xFF1D2B47),
),
),
SizedBox(height: 3.h),
Text.rich(
TextSpan(
children: [
TextSpan(
text: '「$desc」',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF1D2B47),
),
),
const TextSpan(
text: ' 的所有高难考点!',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF45597E),
),
),
],
),
),
],
),
);
}
}
/// 纯文字笔记(album-desc,最多 2 行)
class AlbumDesc extends StatelessWidget {
const AlbumDesc({super.key, required this.text});
@@ -441,15 +384,26 @@ class LikeCommentBar extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.end,
children: [
MetaButton(
icon: post.userLikeStatus
? Icons.favorite
: Icons.favorite_border,
icon: Image.asset(
post.userLikeStatus
? 'assets/img/class/like-check-icon.png'
: 'assets/img/class/like-icon.png',
width: 13.w,
height: 13.w,
),
count: post.likeCount,
active: post.userLikeStatus,
onTap: onLike,
),
SizedBox(width: 8.w),
MetaButton(icon: Icons.chat_bubble_outline, count: post.replyCount),
MetaButton(
icon: Image.asset(
'assets/img/class/comment-icon.png',
width: 13.w,
height: 13.w,
),
count: post.replyCount,
),
],
),
if (showComposer) ...[
@@ -474,7 +428,7 @@ class MetaButton extends StatelessWidget {
this.onTap,
});
final IconData icon;
final Widget icon;
final int count;
final bool active;
final VoidCallback? onTap;
@@ -496,7 +450,7 @@ class MetaButton extends StatelessWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 13.w, color: color),
icon,
SizedBox(width: 4.w),
Text('$count', style: TextStyle(fontSize: 13, color: color)),
],
+24 -9
View File
@@ -34,7 +34,8 @@ class PostListBody extends ConsumerStatefulWidget {
required int pageIndex,
required int pageSize,
required int prevPageId,
}) fetchPage;
})
fetchPage;
final String emptyText;
final bool removeOnUnlike;
@@ -233,16 +234,30 @@ class _PostListBodyState extends ConsumerState<PostListBody> {
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: 14.w),
itemCount: _posts.length + (_hasMore ? 1 : 0),
itemCount: _posts.length + 1,
itemBuilder: (context, index) {
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(
padding: EdgeInsets.symmetric(vertical: 16.h),
child: const Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2.5),
padding: EdgeInsets.symmetric(vertical: 20.h),
child: Center(
child: Text(
'- AI 爱学蝶变提醒你 没有更多了 -',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFFB4C2E0),
),
),
),
);
@@ -262,4 +277,4 @@ class _PostListBodyState extends ConsumerState<PostListBody> {
),
);
}
}
}
+297 -261
View File
@@ -1,16 +1,14 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/components/image_preview_viewer.dart';
import 'package:new_learn/core/components/confirm_box.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/oss_api.dart';
import 'package:new_learn/core/network/oss_uploader.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/local_notification_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/api/auth/api.dart';
@@ -47,103 +45,152 @@ class MyPage extends ConsumerWidget {
final user = ref.watch(currentUserProvider).value;
return Scaffold(
body: SafeArea(
child: Column(
children: [
// 顶部渐变背景区域(用户信息头部)
Container(
width: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFFE2C2), Color(0xFFFFFFFF)],
),
),
child: Padding(
padding: EdgeInsets.only(bottom: 20.h),
child: _UserHeader(
name: user?.userName ?? '未登录',
className: user?.className ?? '-',
avatar: user?.avatar ?? '',
),
backgroundColor: const Color(0xFFF5F8FA),
// 整个 body 用 SingleChildScrollView 包 —— 完全对齐 Vue 的 scroll-view flex:1 结构
// Vue: <scroll-view scroll-y style="flex:1"> 内部先放 HeaderBac 再放卡片区
body: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Column(
children: [
// 头部背景:和 Vue HeaderBac 完全一致
// Stack 高度由内容决定,Positioned 背景图溢出不裁(Clip.none)
// → 渐变尾巴"飘"到下方,卡片紧贴 Stack 底部 = 浮在渐变上
Stack(
clipBehavior: Clip.none,
children: [
// 底层:skyBlue 主题渐变背景图(Positioned 不绑 bottom,不撑 Stack 高度)
Positioned(
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: () {
context.push('/myReport');
},
),
_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: 16.h),
// 第三组:设置(Vue my.vue #ifdef APP-PLUS 专属,Flutter 始终显示)
_MenuCard(
items: [
_MenuItem(
imageUrl:
'${AppEnv.imgBaseUrl}/StaticResource/2026-08-20/setting1787192171875.png',
title: '设置',
onTap: () => context.push('/settings'),
),
],
),
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 {
final ok = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('退出登录'),
content: const Text('确定要退出当前账号吗?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('退出'),
),
],
),
final ok = await showConfirmBox(
context,
title: '是否退出登录',
confirmText: '确定',
cancelText: '取消',
);
if (ok != true) return;
if (!ok) return;
// ① 清内存 Token:Dio 拦截器立刻停止携带 Authorization
DioClient.instance.accessToken = null;
@@ -169,125 +216,49 @@ class _UserHeader extends ConsumerStatefulWidget {
required this.name,
required this.className,
required this.avatar,
required this.onLogout,
});
final String name;
final String className;
final String avatar;
final VoidCallback onLogout;
@override
ConsumerState<_UserHeader> createState() => _UserHeaderState();
}
class _UserHeaderState extends ConsumerState<_UserHeader> {
static const String _avatarHeroTag = 'my-page-avatar';
/// 刚选中的本地新头像(null = 还没选)
PickedImage? _pickedAvatar;
/// 点击头像:
/// 1. 没头像 → 直接更换;
/// 2. 有头像 → 先弹动作表,支持"预览头像 / 更换头像"。
/// 点击头像 → 直接触发选图上传(对齐 Vue my.vue changeAvatar 逻辑,无预览中间步骤)
Future<void> _onAvatarTap() async {
if (!_hasPreviewableAvatar) {
await _pickAndUploadAvatar();
return;
}
final action = await showModalBottomSheet<String>(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (dialogContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.visibility_outlined),
title: const Text('预览头像'),
onTap: () => Navigator.of(dialogContext).pop('preview'),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: const Text('更换头像'),
onTap: () => Navigator.of(dialogContext).pop('change'),
),
const SizedBox(height: 8),
],
),
),
);
if (!mounted) return;
if (action == 'preview') {
await _previewAvatar();
return;
}
if (action == 'change') {
await _pickAndUploadAvatar();
}
await _pickAndUploadAvatar();
}
bool get _hasPreviewableAvatar {
return _pickedAvatar != null || widget.avatar.isNotEmpty;
}
Future<void> _previewAvatar() async {
final picked = _pickedAvatar;
if (picked?.bytes != null) {
await ImagePreviewViewer.showSingle(
context,
imageProvider: MemoryImage(picked!.bytes!),
heroTag: _avatarHeroTag,
label: '头像预览',
);
return;
}
if (widget.avatar.isNotEmpty) {
await ImagePreviewViewer.showSingle(
context,
imageProvider: NetworkImage(widget.avatar),
heroTag: _avatarHeroTag,
label: '头像预览',
);
}
}
/// 选择头像:拍照/相册 → OSS 直传 → 调业务接口更新头像字段 → 更新全局用户
///
/// 这里和 UniApp 旧项目保持一致,完整链路是:
/// 1. 先选图片;
/// 2. 本地压缩/方向修正;
/// 3. 上传到 OSS,拿到最终图片 url;
/// 4. 调 /api/v1/User/UpdateUserAvatar,把服务器里的 avatar 字段改掉;
/// 5. 更新 currentUserProvider + 本地缓存,让界面立刻刷新。
///
/// 为什么"我的"页这里不再重新请求用户信息接口:
/// 登录成功时已经把完整用户信息拉下来放进了 currentUserProvider;
/// 头像上传成功后,本次只有 avatar 这一个字段发生变化,
/// 所以前端直接做一次局部更新就够了,没必要再额外请求整份用户资料。
/// 选择头像并上传(对齐 Vue my.vue 的 changeAvatar 完整链路):
/// 1. showPickSheet 选图(相册+拍照,内部已含权限请求)
/// 2. ImageProcessor 压缩 + EXIF 方向修正
/// 3. 显示 loading 遮罩(对应 Vue uni.showLoading '上传中...')
/// 4. OssUploader.uploadImage 直传 OSS
/// 5. UserApi.updateUserAvatar 调业务接口更新头像字段
/// 6. 弹审核确认框(对应 Vue proxy.$confirm)
/// 注意:不上传后立刻刷新用户信息,头像保持旧的,等后端审核通过后自然生效
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;
final image = picked.first;
// 先显示本地临时预览,让用户选完图后有即时反馈。
setState(() => _pickedAvatar = image);
// ① 读图片字节:Web 走 bytes;原生走 photo_manager 纯 Dart 的 originBytes
// (不 import dart:io,Web 才能编译)
// ① 读图片字节(Web 走 bytes;原生走 originBytes)
final bytes = image.bytes ?? await image.entity?.originBytes;
if (bytes == null || bytes.isEmpty) {
_toast('读取图片失败,请重试');
return;
}
// ② 压缩 + EXIF 方向修正(后台 isolate 跑,不卡页面)
// 头像只需 512 长边;null = 尺寸超出 14~4000 像素硬限制
// ② 压缩 + EXIF 方向修正
final processed = await ImageProcessor.process(
bytes: bytes,
fileName: image.name,
@@ -298,7 +269,10 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
return;
}
// ③ OSS 直传:申请路径 → 拿 STS → V4 签名 → PUT(拦截器自动带 Token)
// ③ 显示 loading 遮罩(对应 Vue uni.showLoading)
if (!mounted) return;
_showLoading('上传中...');
try {
final fileName = _replaceExtension(
image.name ?? 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg',
@@ -308,6 +282,8 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
'头像处理:${processed.originalBytes}B -> ${processed.processedBytes}B '
'(${processed.contentType}, $fileName)',
);
// ④ OSS 直传
final url = await OssUploader.uploadImage(
bytes: processed.bytes,
fileName: fileName,
@@ -315,30 +291,60 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
scene: UploadScene.avatar,
);
// ④ 通知业务接口"当前用户头像已改成这个 OSS 地址"。
// 这一步对应 UniApp 的 UpdateUserAvatar({ avatar: url }),
// 目的是把服务器里当前用户资料的 avatar 字段真正更新掉。
// ⑤ 业务接口更新头像字段(对应 Vue UpdateUserAvatar({ avatar: url }))
await UserApi.updateUserAvatar(url);
// ⑤ 头像字段只改本地当前用户信息即可,不需要"我的"页再重新请求接口。
// updateAvatar(url) 内部会做两件事:
// - 更新 currentUserProvider 的内存状态;
// - 把更新后的用户对象写回 SharedPreferences。
// 因此所有 ref.watch(currentUserProvider) 的页面都会一起刷新。
await ref.read(currentUserProvider.notifier).updateAvatar(url);
// 上传成功后清掉本地临时预览,改为展示服务器返回的头像 URL。
// 否则界面会一直优先显示 _pickedAvatar,看起来像"方向没修正",
// 实际上你看到的是本地原图/相册缩略图,不是处理后的上传结果。
if (mounted) {
setState(() => _pickedAvatar = null);
}
// ⑥ 关闭 loading,弹审核确认框(对应 Vue proxy.$confirm)
if (!mounted) return;
_toast('头像上传成功');
_hideLoading();
await showConfirmBox(
context,
title: '上传成功,等待审核完成会自动生效',
confirmText: '确定',
cancelText: '取消',
);
} catch (e) {
if (!mounted) return;
_toast('头像上传失败:$e');
_hideLoading();
_toast('笔记上传失败,请检查网络后重试');
}
}
/// 显示全局 loading 遮罩(对应 Vue uni.showLoading)
void _showLoading(String message) {
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => PopScope(
canPop: false,
child: Center(
child: Container(
padding: EdgeInsets.symmetric(horizontal: 32.w, vertical: 24.h),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(12.r),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(color: Colors.white),
SizedBox(height: 16.h),
Text(
message,
style: TextStyle(color: Colors.white, fontSize: 14.sp),
),
],
),
),
),
),
);
}
void _hideLoading() {
if (Navigator.of(context, rootNavigator: true).canPop()) {
Navigator.of(context, rootNavigator: true).pop();
}
}
@@ -349,45 +355,32 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
);
}
/// 头像主体:按「本地新图 → 网络图 → 默认首字圆」三级渲染
/// 头像主体:网络头像 → 默认首字圆 二级渲染
Widget _buildAvatar() {
final size = 62.w;
final picked = _pickedAvatar;
// 一级:刚选中的本地头像
if (picked != null) {
return Hero(
tag: _avatarHeroTag,
child: ClipOval(
child: PickerService.buildThumb(picked, width: size, height: size),
),
);
}
// 二级:服务器返回的网络头像(加载失败自动降级到默认头像)
// 一级:服务器返回的网络头像(加载失败自动降级到默认头像)
if (widget.avatar.isNotEmpty) {
return Hero(
tag: _avatarHeroTag,
child: ClipOval(
child: Image.network(
widget.avatar,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _buildDefaultAvatar(),
),
return ClipOval(
child: Image.network(
widget.avatar,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _buildDefaultAvatar(),
),
);
}
// 三级:默认头像(首字圆)
// 二级:默认头像(首字圆)
return _buildDefaultAvatar();
}
/// 默认头像:用户名首字圆形色块
Widget _buildDefaultAvatar() {
final avatarChar =
widget.name.isNotEmpty ? widget.name.characters.first : '?';
final avatarChar = widget.name.isNotEmpty
? widget.name.characters.first
: '?';
return Container(
width: 62.w,
height: 62.h,
@@ -459,11 +452,16 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
SizedBox(height: 4.h),
Text(
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 +475,70 @@ String _replaceExtension(String fileName, String extension) {
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 {
const _MenuItem({
required this.color,
required this.imageUrl,
required this.title,
required this.onTap,
this.iconData,
this.trailingText,
});
final Color color;
final String imageUrl; // 图标(CDN 图片 URL,和 Vue 端保持一致)
final String title;
final VoidCallback onTap; // 点击跳转
final IconData? iconData; // 普通图标
final String? trailingText; // 右侧文字,如 "382积分"
}
@@ -509,19 +557,7 @@ class _MenuCard extends StatelessWidget {
borderRadius: BorderRadius.circular(16.r),
),
child: Column(
children: [
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]),
],
],
children: [for (int i = 0; i < items.length; i++) _buildRow(items[i])],
),
);
}
@@ -534,16 +570,13 @@ class _MenuCard extends StatelessWidget {
child: Row(
children: [
SizedBox(width: 16.w),
// 左侧彩色方形图标
Container(
width: 36.w,
height: 36.h,
decoration: BoxDecoration(
color: item.color,
borderRadius: BorderRadius.circular(10.r),
),
alignment: Alignment.center,
child: Icon(item.iconData, color: Colors.white, size: 20.w),
// 左侧图标(和 Vue 端一致的 CDN 图片)
Image.network(
item.imageUrl,
width: 34.w,
height: 34.h,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => SizedBox(width: 34.w, height: 34.h),
),
SizedBox(width: 12.w),
// 中间标题
@@ -561,7 +594,10 @@ class _MenuCard extends StatelessWidget {
if (item.trailingText != null) ...[
Text(
item.trailingText!,
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF999999)),
style: TextStyle(
fontSize: 13.sp,
color: const Color(0xFF999999),
),
),
SizedBox(width: 2.w),
],
+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;
}
}
+4
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/app.dart';
@@ -108,6 +109,9 @@ Future<void> main() async {
// 需要在启动 App 之前读取本地存储、调用原生插件,必须写在 runApp 之前!
// 作用: 初始化 Flutter 和原生的绑定桥梁
WidgetsFlutterBinding.ensureInitialized();
// flutter_inappwebview 平台实现注册(Chrome Web / Windows desktop 必须显式触发)
// 否则会报 InAppWebViewPlatform.instance != null 断言错误
InAppWebViewPlatform.instance;
// go_router 17.x 大坑:命令式导航(context.push/go)默认不写回浏览器
// 地址栏(optionURLReflectsImperativeAPIs 默认 false)。必须在使用
// appRouter(runApp)之前全局开启,否则 web 调试 URL 不随页面变化。
+64
View File
@@ -483,6 +483,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.1.5+1"
flutter_inappwebview:
dependency: "direct main"
description:
name: flutter_inappwebview
sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "6.1.5"
flutter_inappwebview_android:
dependency: transitive
description:
name: flutter_inappwebview_android
sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "1.1.3"
flutter_inappwebview_internal_annotations:
dependency: transitive
description:
name: flutter_inappwebview_internal_annotations
sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "1.3.0"
flutter_inappwebview_ios:
dependency: transitive
description:
name: flutter_inappwebview_ios
sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "1.1.2"
flutter_inappwebview_macos:
dependency: transitive
description:
name: flutter_inappwebview_macos
sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "1.1.2"
flutter_inappwebview_platform_interface:
dependency: transitive
description:
name: flutter_inappwebview_platform_interface
sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "1.3.0+1"
flutter_inappwebview_web:
dependency: transitive
description:
name: flutter_inappwebview_web
sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "1.1.2"
flutter_inappwebview_windows:
dependency: transitive
description:
name: flutter_inappwebview_windows
sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "0.6.0"
flutter_lints:
dependency: "direct dev"
description:
+11 -2
View File
@@ -2,7 +2,7 @@ name: new_learn
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
publish_to: "none" # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
@@ -166,6 +166,14 @@ dependencies:
# url_launcher:跳转应用商店 / 外部浏览器下载地址
url_launcher: ^6.3.1
# WebView(内嵌 H5 报告详情页,支持 JS ↔ Flutter 双向通信)
# 为什么用 flutter_inappwebview 而不是 webview_flutter:
# - 需要 H5 内部按钮回调到 Flutter(JS bridge:addJavaScriptHandler)
# - 比官方 webview_flutter 功能强:支持 callAsyncJavaScript、cookie 管理等
# - 6.1.5 stable 最低 Flutter 3.24 / Dart 3.5,项目 Flutter 3.44.8 完全兼容
# ⚠ Android 端要求 minSdkVersion >= 19(flutter_inappwebview 6.x 默认)
flutter_inappwebview: ^6.1.5
# 常驻底部面板(smooth_sheets:3 停靠点拖拽面板,支持内部 ListView 滚动联动)
# 用法:lib/core/components/persistent_snap_sheet.dart 封装组件
# 版本:1.1.1(2026-07 发布,支持 persistent + modal 两种模式)
@@ -222,7 +230,6 @@ dev_dependencies:
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
@@ -236,6 +243,8 @@ flutter:
- assets/img/tabbar/
- assets/img/tabbar/default/
- assets/audio/
- assets/img/class/
- assets/img/my/
# To add assets to your application, add an assets section, like this:
# assets:
@@ -8,6 +8,7 @@
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <flutter_timezone/flutter_timezone_plugin_c_api.h>
#include <gal/gal_plugin_c_api.h>
@@ -21,6 +22,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
FlutterTimezonePluginCApiRegisterWithRegistrar(
+1
View File
@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
file_selector_windows
flutter_inappwebview_windows
flutter_secure_storage_windows
flutter_timezone
gal