router 冲突解决
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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._()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+546
-269
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
|
||||
@@ -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
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 不随页面变化。
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
connectivity_plus
|
||||
file_selector_windows
|
||||
flutter_inappwebview_windows
|
||||
flutter_secure_storage_windows
|
||||
flutter_timezone
|
||||
gal
|
||||
|
||||
Reference in New Issue
Block a user