From 3096297198827493a24e90e4d2b061453ce1e36a Mon Sep 17 00:00:00 2001 From: hailin Date: Fri, 16 Jan 2026 07:58:16 -0800 Subject: [PATCH] =?UTF-8?q?feat(mining-app):=20=E8=B5=84=E4=BA=A7=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E4=BC=98=E5=8C=96=E5=8F=8A=E4=B8=AA=E4=BA=BA=E8=B5=84?= =?UTF-8?q?=E6=96=99=E7=BC=96=E8=BE=91=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除资产页面的"提现"按钮,将"划转"改为"C2C" - 删除积分值卡片上的"可提现"标签 - 简化资产页面和兑换页面的标题栏,移除左右图标 - 统一资产页面背景色与兑换页面一致 - 新增个人资料编辑页面,支持头像颜色选择和昵称修改 - 头像和昵称支持本地存储 Co-Authored-By: Claude Opus 4.5 --- .../lib/core/router/app_router.dart | 5 + .../mining-app/lib/core/router/routes.dart | 1 + .../presentation/pages/asset/asset_page.dart | 2 +- .../pages/profile/edit_profile_page.dart | 381 ++++++++++++++++++ .../pages/profile/profile_page.dart | 69 ++-- .../providers/user_providers.dart | 24 ++ 6 files changed, 455 insertions(+), 27 deletions(-) create mode 100644 frontend/mining-app/lib/presentation/pages/profile/edit_profile_page.dart diff --git a/frontend/mining-app/lib/core/router/app_router.dart b/frontend/mining-app/lib/core/router/app_router.dart index 3dcffc04..e162bb82 100644 --- a/frontend/mining-app/lib/core/router/app_router.dart +++ b/frontend/mining-app/lib/core/router/app_router.dart @@ -11,6 +11,7 @@ import '../../presentation/pages/contribution/contribution_records_page.dart'; import '../../presentation/pages/trading/trading_page.dart'; import '../../presentation/pages/asset/asset_page.dart'; import '../../presentation/pages/profile/profile_page.dart'; +import '../../presentation/pages/profile/edit_profile_page.dart'; import '../../presentation/pages/profile/mining_records_page.dart'; import '../../presentation/pages/profile/planting_records_page.dart'; import '../../presentation/widgets/main_shell.dart'; @@ -112,6 +113,10 @@ final appRouterProvider = Provider((ref) { path: Routes.plantingRecords, builder: (context, state) => const PlantingRecordsPage(), ), + GoRoute( + path: Routes.editProfile, + builder: (context, state) => const EditProfilePage(), + ), ShellRoute( builder: (context, state, child) => MainShell(child: child), routes: [ diff --git a/frontend/mining-app/lib/core/router/routes.dart b/frontend/mining-app/lib/core/router/routes.dart index e0efff18..bbfa33f8 100644 --- a/frontend/mining-app/lib/core/router/routes.dart +++ b/frontend/mining-app/lib/core/router/routes.dart @@ -8,6 +8,7 @@ class Routes { static const String trading = '/trading'; static const String asset = '/asset'; static const String profile = '/profile'; + static const String editProfile = '/edit-profile'; static const String miningRecords = '/mining-records'; static const String contributionRecords = '/contribution-records'; static const String plantingRecords = '/planting-records'; diff --git a/frontend/mining-app/lib/presentation/pages/asset/asset_page.dart b/frontend/mining-app/lib/presentation/pages/asset/asset_page.dart index 00676868..2b2a56ba 100644 --- a/frontend/mining-app/lib/presentation/pages/asset/asset_page.dart +++ b/frontend/mining-app/lib/presentation/pages/asset/asset_page.dart @@ -33,7 +33,7 @@ class AssetPage extends ConsumerWidget { final asset = assetAsync.valueOrNull; return Scaffold( - backgroundColor: Colors.white, + backgroundColor: const Color(0xFFF5F5F5), body: SafeArea( bottom: false, child: LayoutBuilder( diff --git a/frontend/mining-app/lib/presentation/pages/profile/edit_profile_page.dart b/frontend/mining-app/lib/presentation/pages/profile/edit_profile_page.dart new file mode 100644 index 00000000..374f5e48 --- /dev/null +++ b/frontend/mining-app/lib/presentation/pages/profile/edit_profile_page.dart @@ -0,0 +1,381 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../providers/user_providers.dart'; + +class EditProfilePage extends ConsumerStatefulWidget { + const EditProfilePage({super.key}); + + @override + ConsumerState createState() => _EditProfilePageState(); +} + +class _EditProfilePageState extends ConsumerState { + static const Color _orange = Color(0xFFFF6B00); + static const Color _green = Color(0xFF10B981); + static const Color _darkText = Color(0xFF1F2937); + static const Color _grayText = Color(0xFF6B7280); + static const Color _bgGray = Color(0xFFF3F4F6); + + late TextEditingController _nicknameController; + int _selectedAvatarIndex = 0; + bool _isLoading = false; + + // 预设头像颜色列表 + static const List _avatarColors = [ + Color(0xFFFF6B00), // 橙色 + Color(0xFF10B981), // 绿色 + Color(0xFF3B82F6), // 蓝色 + Color(0xFFEF4444), // 红色 + Color(0xFF8B5CF6), // 紫色 + Color(0xFFF59E0B), // 琥珀色 + Color(0xFFEC4899), // 粉色 + Color(0xFF06B6D4), // 青色 + ]; + + @override + void initState() { + super.initState(); + final user = ref.read(userNotifierProvider); + _nicknameController = TextEditingController(text: user.nickname ?? ''); + _selectedAvatarIndex = user.avatarIndex; + } + + @override + void dispose() { + _nicknameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final user = ref.watch(userNotifierProvider); + + return Scaffold( + backgroundColor: _bgGray, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: _darkText), + onPressed: () => context.pop(), + ), + title: const Text( + '编辑资料', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: _darkText, + ), + ), + centerTitle: true, + actions: [ + TextButton( + onPressed: _isLoading ? null : _saveProfile, + child: Text( + '保存', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: _isLoading ? _grayText : _orange, + ), + ), + ), + ], + ), + body: SingleChildScrollView( + child: Column( + children: [ + const SizedBox(height: 24), + + // 头像选择区域 + _buildAvatarSection(user), + + const SizedBox(height: 32), + + // 昵称输入区域 + _buildNicknameSection(), + + const SizedBox(height: 16), + + // 用户信息展示(只读) + _buildInfoSection(user), + ], + ), + ), + ); + } + + Widget _buildAvatarSection(UserState user) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '选择头像', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: _darkText, + ), + ), + const SizedBox(height: 20), + + // 当前头像预览 + Center( + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + _avatarColors[_selectedAvatarIndex].withValues(alpha: 0.8), + _avatarColors[_selectedAvatarIndex], + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: _avatarColors[_selectedAvatarIndex].withValues(alpha: 0.3), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Center( + child: Text( + _getAvatarText(user), + style: const TextStyle( + fontSize: 42, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + ), + + const SizedBox(height: 24), + + // 头像颜色选择网格 + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: _avatarColors.length, + itemBuilder: (context, index) { + final isSelected = _selectedAvatarIndex == index; + return GestureDetector( + onTap: () { + setState(() { + _selectedAvatarIndex = index; + }); + }, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + _avatarColors[index].withValues(alpha: 0.8), + _avatarColors[index], + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + border: isSelected + ? Border.all(color: _darkText, width: 3) + : null, + boxShadow: isSelected + ? [ + BoxShadow( + color: _avatarColors[index].withValues(alpha: 0.4), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: isSelected + ? const Icon(Icons.check, color: Colors.white, size: 28) + : null, + ), + ); + }, + ), + ], + ), + ); + } + + Widget _buildNicknameSection() { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '昵称', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: _darkText, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _nicknameController, + maxLength: 20, + decoration: InputDecoration( + hintText: '请输入昵称', + hintStyle: const TextStyle(color: _grayText), + filled: true, + fillColor: _bgGray, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _orange, width: 2), + ), + counterStyle: const TextStyle(color: _grayText), + ), + ), + ], + ), + ); + } + + Widget _buildInfoSection(UserState user) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '账户信息', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: _darkText, + ), + ), + const SizedBox(height: 16), + _buildInfoItem('手机号', user.phone ?? '--'), + const Divider(height: 24), + _buildInfoItem('实名状态', user.isKycVerified ? '已实名' : '未实名', + valueColor: user.isKycVerified ? _green : _grayText), + if (user.realName != null && user.realName!.isNotEmpty) ...[ + const Divider(height: 24), + _buildInfoItem('真实姓名', _maskName(user.realName!)), + ], + ], + ), + ); + } + + Widget _buildInfoItem(String label, String value, {Color? valueColor}) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 14, + color: _grayText, + ), + ), + Text( + value, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: valueColor ?? _darkText, + ), + ), + ], + ); + } + + String _getAvatarText(UserState user) { + final nickname = _nicknameController.text; + if (nickname.isNotEmpty) { + return nickname.substring(0, 1).toUpperCase(); + } + if (user.realName?.isNotEmpty == true) { + return user.realName!.substring(0, 1).toUpperCase(); + } + return 'U'; + } + + String _maskName(String name) { + if (name.length <= 1) return name; + return '${name.substring(0, 1)}${'*' * (name.length - 1)}'; + } + + Future _saveProfile() async { + setState(() { + _isLoading = true; + }); + + try { + final notifier = ref.read(userNotifierProvider.notifier); + + // 保存头像 + await notifier.updateAvatar(_selectedAvatarIndex); + + // 保存昵称 + final nickname = _nicknameController.text.trim(); + if (nickname.isNotEmpty) { + await notifier.updateNickname(nickname); + } + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('保存成功'), + backgroundColor: _green, + ), + ); + context.pop(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('保存失败: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } +} diff --git a/frontend/mining-app/lib/presentation/pages/profile/profile_page.dart b/frontend/mining-app/lib/presentation/pages/profile/profile_page.dart index 32da7171..73630a75 100644 --- a/frontend/mining-app/lib/presentation/pages/profile/profile_page.dart +++ b/frontend/mining-app/lib/presentation/pages/profile/profile_page.dart @@ -19,6 +19,18 @@ class ProfilePage extends ConsumerWidget { static const Color _bgGray = Color(0xFFF3F4F6); static const Color _red = Color(0xFFEF4444); + // 预设头像颜色列表(与编辑页面保持一致) + static const List _avatarColors = [ + Color(0xFFFF6B00), // 橙色 + Color(0xFF10B981), // 绿色 + Color(0xFF3B82F6), // 蓝色 + Color(0xFFEF4444), // 红色 + Color(0xFF8B5CF6), // 紫色 + Color(0xFFF59E0B), // 琥珀色 + Color(0xFFEC4899), // 粉色 + Color(0xFF06B6D4), // 青色 + ]; + @override Widget build(BuildContext context, WidgetRef ref) { final user = ref.watch(userNotifierProvider); @@ -94,34 +106,39 @@ class ProfilePage extends ConsumerWidget { } Widget _buildUserHeader(BuildContext context, UserState user) { + final avatarColor = _avatarColors[user.avatarIndex % _avatarColors.length]; + return Container( padding: const EdgeInsets.all(20), color: Colors.white, child: Row( children: [ - // 头像 - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - colors: [_orange.withValues(alpha: 0.8), _orange], - begin: Alignment.topLeft, - end: Alignment.bottomRight, + // 头像(可点击) + GestureDetector( + onTap: () => context.push(Routes.editProfile), + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [avatarColor.withValues(alpha: 0.8), avatarColor], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), ), - ), - child: Center( - child: Text( - user.nickname?.isNotEmpty == true - ? user.nickname!.substring(0, 1).toUpperCase() - : (user.realName?.isNotEmpty == true - ? user.realName!.substring(0, 1).toUpperCase() - : 'U'), - style: const TextStyle( - fontSize: 36, - fontWeight: FontWeight.bold, - color: Colors.white, + child: Center( + child: Text( + user.nickname?.isNotEmpty == true + ? user.nickname!.substring(0, 1).toUpperCase() + : (user.realName?.isNotEmpty == true + ? user.realName!.substring(0, 1).toUpperCase() + : 'U'), + style: const TextStyle( + fontSize: 36, + fontWeight: FontWeight.bold, + color: Colors.white, + ), ), ), ), @@ -137,7 +154,9 @@ class ProfilePage extends ConsumerWidget { Row( children: [ Text( - user.realName ?? user.nickname ?? '榴莲用户', + user.nickname?.isNotEmpty == true + ? user.nickname! + : (user.realName ?? '榴莲用户'), style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, @@ -190,9 +209,7 @@ class ProfilePage extends ConsumerWidget { // 编辑按钮 IconButton( - onPressed: () { - // TODO: 编辑个人资料 - }, + onPressed: () => context.push(Routes.editProfile), icon: const Icon( Icons.edit_outlined, color: _grayText, diff --git a/frontend/mining-app/lib/presentation/providers/user_providers.dart b/frontend/mining-app/lib/presentation/providers/user_providers.dart index 7482a218..102d8528 100644 --- a/frontend/mining-app/lib/presentation/providers/user_providers.dart +++ b/frontend/mining-app/lib/presentation/providers/user_providers.dart @@ -15,6 +15,7 @@ class UserState { final DateTime? lastLoginAt; final String? accessToken; final String? refreshToken; + final int avatarIndex; final bool isLoggedIn; final bool isLoading; final String? error; @@ -31,6 +32,7 @@ class UserState { this.lastLoginAt, this.accessToken, this.refreshToken, + this.avatarIndex = 0, this.isLoggedIn = false, this.isLoading = false, this.error, @@ -50,6 +52,7 @@ class UserState { DateTime? lastLoginAt, String? accessToken, String? refreshToken, + int? avatarIndex, bool? isLoggedIn, bool? isLoading, String? error, @@ -66,6 +69,7 @@ class UserState { lastLoginAt: lastLoginAt ?? this.lastLoginAt, accessToken: accessToken ?? this.accessToken, refreshToken: refreshToken ?? this.refreshToken, + avatarIndex: avatarIndex ?? this.avatarIndex, isLoggedIn: isLoggedIn ?? this.isLoggedIn, isLoading: isLoading ?? this.isLoading, error: error, @@ -86,6 +90,8 @@ class UserNotifier extends StateNotifier { final refreshToken = prefs.getString('refresh_token'); final accountSequence = prefs.getString('account_sequence'); final phone = prefs.getString('phone'); + final avatarIndex = prefs.getInt('avatar_index') ?? 0; + final nickname = prefs.getString('nickname'); if (accessToken != null && refreshToken != null && accountSequence != null) { state = state.copyWith( @@ -93,6 +99,8 @@ class UserNotifier extends StateNotifier { refreshToken: refreshToken, accountSequence: accountSequence, phone: phone, + avatarIndex: avatarIndex, + nickname: nickname, isLoggedIn: true, ); // 登录后自动获取用户详情 @@ -114,6 +122,8 @@ class UserNotifier extends StateNotifier { await prefs.remove('refresh_token'); await prefs.remove('account_sequence'); await prefs.remove('phone'); + await prefs.remove('avatar_index'); + await prefs.remove('nickname'); } Future sendSmsCode(String phone, String type) async { @@ -261,6 +271,20 @@ class UserNotifier extends StateNotifier { // 静默失败,不影响用户体验 } } + + /// 更新头像索引(本地存储) + Future updateAvatar(int avatarIndex) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('avatar_index', avatarIndex); + state = state.copyWith(avatarIndex: avatarIndex); + } + + /// 更新昵称(本地存储) + Future updateNickname(String nickname) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('nickname', nickname); + state = state.copyWith(nickname: nickname); + } } final userNotifierProvider = StateNotifierProvider(