feat(payment-password): 添加支付密码功能(全栈)
后端(identity-service):
- Prisma schema 新增 paymentPasswordHash 字段及手动迁移脚本
- user-application.service 添加 4 个方法:isPaymentPasswordSet / setPaymentPassword /
changePaymentPassword / verifyPaymentPassword(纯新增,不修改已有逻辑)
- user-account.controller 新增 4 个端点:
GET /user/payment-password-status
POST /user/set-payment-password
POST /user/change-payment-password
POST /user/verify-payment-password → { valid: bool }
前端(mobile-app):
- account_service 新增 4 个方法对应后端 4 个接口
- 新建 change_payment_password_page.dart:6 位数字支付密码设置/修改页面
- 路由:RoutePaths / RouteNames / AppRouter 注册 /security/payment-password
- profile_page:'修改登录密码' 改为 '修改密码',下方新增 '支付密码' 入口
- PasswordVerifyDialog:新增 title / subtitle / hint 可选参数,支持登录/支付双模式
- planting_location_page:认种确认改为验证支付密码(verifyPaymentPassword)
- pre_planting_purchase_page:预种确认后追加支付密码验证步骤
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d91ff7b83a
commit
cad7ebe832
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable: 新增支付密码哈希字段
|
||||
ALTER TABLE "user_accounts" ADD COLUMN "payment_password_hash" VARCHAR(100);
|
||||
|
|
@ -17,7 +17,8 @@ model UserAccount {
|
|||
email String? @unique @db.VarChar(100) // 绑定的邮箱地址
|
||||
emailVerified Boolean @default(false) @map("email_verified") // 邮箱是否验证
|
||||
emailVerifiedAt DateTime? @map("email_verified_at") // 邮箱验证时间
|
||||
passwordHash String? @map("password_hash") @db.VarChar(100) // bcrypt 哈希密码
|
||||
passwordHash String? @map("password_hash") @db.VarChar(100) // bcrypt 哈希登录密码
|
||||
paymentPasswordHash String? @map("payment_password_hash") @db.VarChar(100) // bcrypt 哈希支付密码
|
||||
nickname String @db.VarChar(100)
|
||||
avatarUrl String? @map("avatar_url") @db.Text
|
||||
|
||||
|
|
|
|||
|
|
@ -756,6 +756,77 @@ export class UserAccountController {
|
|||
return { valid };
|
||||
}
|
||||
|
||||
// ============ 支付密码相关 ============
|
||||
|
||||
@Get('payment-password-status')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '查询支付密码状态',
|
||||
description: '查询当前用户是否已设置支付密码',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '{ isSet: boolean }' })
|
||||
async getPaymentPasswordStatus(@CurrentUser() user: CurrentUserData) {
|
||||
const isSet = await this.userService.isPaymentPasswordSet(user.userId);
|
||||
return { isSet };
|
||||
}
|
||||
|
||||
@Post('set-payment-password')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '设置支付密码',
|
||||
description: '首次设置支付密码(无需旧密码)',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '设置成功' })
|
||||
@ApiResponse({ status: 400, description: '支付密码已设置' })
|
||||
async setPaymentPassword(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() body: { password: string },
|
||||
) {
|
||||
await this.userService.setPaymentPassword({
|
||||
userId: user.userId,
|
||||
password: body.password,
|
||||
});
|
||||
return { message: '支付密码设置成功' };
|
||||
}
|
||||
|
||||
@Post('change-payment-password')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '修改支付密码',
|
||||
description: '验证旧支付密码后修改为新支付密码',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '修改成功' })
|
||||
@ApiResponse({ status: 400, description: '旧支付密码错误' })
|
||||
async changePaymentPassword(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() body: { oldPassword: string; newPassword: string },
|
||||
) {
|
||||
await this.userService.changePaymentPassword({
|
||||
userId: user.userId,
|
||||
oldPassword: body.oldPassword,
|
||||
newPassword: body.newPassword,
|
||||
});
|
||||
return { message: '支付密码修改成功' };
|
||||
}
|
||||
|
||||
@Post('verify-payment-password')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '验证支付密码',
|
||||
description: '验证用户的支付密码,用于认种/预种等支付操作二次验证',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '{ valid: boolean }' })
|
||||
async verifyPaymentPassword(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() body: { password: string },
|
||||
) {
|
||||
const valid = await this.userService.verifyPaymentPassword(
|
||||
user.userId,
|
||||
body.password,
|
||||
);
|
||||
return { valid };
|
||||
}
|
||||
|
||||
@Get('users/resolve-address/:accountSequence')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
|
|
|
|||
|
|
@ -2856,6 +2856,106 @@ export class UserApplicationService {
|
|||
return isValid;
|
||||
}
|
||||
|
||||
// ============ 支付密码相关 ============
|
||||
|
||||
/**
|
||||
* 查询是否已设置支付密码
|
||||
*/
|
||||
async isPaymentPasswordSet(userId: string): Promise<boolean> {
|
||||
const user = await this.prisma.userAccount.findUnique({
|
||||
where: { userId: BigInt(userId) },
|
||||
select: { paymentPasswordHash: true },
|
||||
});
|
||||
return !!user?.paymentPasswordHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置支付密码(首次设置,无需旧密码)
|
||||
*/
|
||||
async setPaymentPassword(command: {
|
||||
userId: string;
|
||||
password: string;
|
||||
}): Promise<void> {
|
||||
this.logger.log(`Setting payment password for user: ${command.userId}`);
|
||||
|
||||
const user = await this.prisma.userAccount.findUnique({
|
||||
where: { userId: BigInt(command.userId) },
|
||||
select: { paymentPasswordHash: true },
|
||||
});
|
||||
|
||||
if (user?.paymentPasswordHash) {
|
||||
throw new ApplicationError('支付密码已设置,请使用修改支付密码功能');
|
||||
}
|
||||
|
||||
const bcrypt = await import('bcrypt');
|
||||
const paymentPasswordHash = await bcrypt.hash(command.password, 10);
|
||||
|
||||
await this.prisma.userAccount.update({
|
||||
where: { userId: BigInt(command.userId) },
|
||||
data: { paymentPasswordHash },
|
||||
});
|
||||
|
||||
this.logger.log(`Payment password set for user: ${command.userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改支付密码(需验证旧密码)
|
||||
*/
|
||||
async changePaymentPassword(command: {
|
||||
userId: string;
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
}): Promise<void> {
|
||||
this.logger.log(`Changing payment password for user: ${command.userId}`);
|
||||
|
||||
const user = await this.prisma.userAccount.findUnique({
|
||||
where: { userId: BigInt(command.userId) },
|
||||
select: { paymentPasswordHash: true },
|
||||
});
|
||||
|
||||
if (!user?.paymentPasswordHash) {
|
||||
throw new ApplicationError('尚未设置支付密码,请先设置');
|
||||
}
|
||||
|
||||
const bcrypt = await import('bcrypt');
|
||||
const isOldValid = await bcrypt.compare(command.oldPassword, user.paymentPasswordHash);
|
||||
if (!isOldValid) {
|
||||
throw new ApplicationError('旧支付密码错误');
|
||||
}
|
||||
|
||||
const paymentPasswordHash = await bcrypt.hash(command.newPassword, 10);
|
||||
await this.prisma.userAccount.update({
|
||||
where: { userId: BigInt(command.userId) },
|
||||
data: { paymentPasswordHash },
|
||||
});
|
||||
|
||||
this.logger.log(`Payment password changed for user: ${command.userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证支付密码
|
||||
*
|
||||
* @returns 密码是否正确
|
||||
*/
|
||||
async verifyPaymentPassword(userId: string, password: string): Promise<boolean> {
|
||||
this.logger.log(`Verifying payment password for user: ${userId}`);
|
||||
|
||||
const user = await this.prisma.userAccount.findUnique({
|
||||
where: { userId: BigInt(userId) },
|
||||
select: { paymentPasswordHash: true },
|
||||
});
|
||||
|
||||
if (!user?.paymentPasswordHash) {
|
||||
throw new ApplicationError('请先设置支付密码');
|
||||
}
|
||||
|
||||
const bcrypt = await import('bcrypt');
|
||||
const isValid = await bcrypt.compare(password, user.paymentPasswordHash);
|
||||
|
||||
this.logger.log(`Payment password verification result for user ${userId}: ${isValid}`);
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 遮蔽手机号
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -2124,6 +2124,93 @@ class AccountService {
|
|||
}
|
||||
}
|
||||
|
||||
// ============ 支付密码相关 ============
|
||||
|
||||
/// 查询是否已设置支付密码 (GET /user/payment-password-status)
|
||||
///
|
||||
/// 返回 true 表示已设置,false 表示未设置
|
||||
Future<bool> isPaymentPasswordSet() async {
|
||||
debugPrint('$_tag isPaymentPasswordSet() - 查询支付密码状态');
|
||||
try {
|
||||
final response = await _apiClient.get('/user/payment-password-status');
|
||||
final isSet = response.data['isSet'] == true;
|
||||
debugPrint('$_tag isPaymentPasswordSet() - 结果: $isSet');
|
||||
return isSet;
|
||||
} catch (e) {
|
||||
debugPrint('$_tag isPaymentPasswordSet() - 异常: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置支付密码(首次设置)(POST /user/set-payment-password)
|
||||
///
|
||||
/// [password] 支付密码明文(建议6位数字)
|
||||
Future<void> setPaymentPassword(String password) async {
|
||||
debugPrint('$_tag setPaymentPassword() - 开始设置支付密码');
|
||||
try {
|
||||
await _apiClient.post(
|
||||
'/user/set-payment-password',
|
||||
data: {'password': password},
|
||||
);
|
||||
debugPrint('$_tag setPaymentPassword() - 支付密码设置成功');
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('$_tag setPaymentPassword() - API 异常: $e');
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
debugPrint('$_tag setPaymentPassword() - 未知异常: $e');
|
||||
throw ApiException('设置支付密码失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 修改支付密码 (POST /user/change-payment-password)
|
||||
///
|
||||
/// [oldPassword] 当前支付密码
|
||||
/// [newPassword] 新支付密码
|
||||
Future<void> changePaymentPassword({
|
||||
required String oldPassword,
|
||||
required String newPassword,
|
||||
}) async {
|
||||
debugPrint('$_tag changePaymentPassword() - 开始修改支付密码');
|
||||
try {
|
||||
await _apiClient.post(
|
||||
'/user/change-payment-password',
|
||||
data: {'oldPassword': oldPassword, 'newPassword': newPassword},
|
||||
);
|
||||
debugPrint('$_tag changePaymentPassword() - 支付密码修改成功');
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('$_tag changePaymentPassword() - API 异常: $e');
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
debugPrint('$_tag changePaymentPassword() - 未知异常: $e');
|
||||
throw ApiException('修改支付密码失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证支付密码 (POST /user/verify-payment-password)
|
||||
///
|
||||
/// 后端响应格式:{ "valid": true/false }
|
||||
/// - 返回 true:密码正确
|
||||
/// - 返回 false:密码错误
|
||||
/// - 抛出异常:未设置支付密码或网络错误
|
||||
Future<bool> verifyPaymentPassword(String password) async {
|
||||
debugPrint('$_tag verifyPaymentPassword() - 开始验证支付密码');
|
||||
try {
|
||||
final response = await _apiClient.post(
|
||||
'/user/verify-payment-password',
|
||||
data: {'password': password},
|
||||
);
|
||||
final valid = response.data['valid'] == true;
|
||||
debugPrint('$_tag verifyPaymentPassword() - 验证结果: $valid');
|
||||
return valid;
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('$_tag verifyPaymentPassword() - 验证异常: $e');
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
debugPrint('$_tag verifyPaymentPassword() - 未知异常: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 邮箱绑定相关 ============
|
||||
|
||||
/// 获取邮箱绑定状态
|
||||
|
|
|
|||
|
|
@ -243,17 +243,20 @@ class _PlantingLocationPageState extends ConsumerState<PlantingLocationPage> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 验证登录密码后再提交认种
|
||||
/// 验证支付密码后再提交认种 [2026-03-05]
|
||||
///
|
||||
/// 插入在 [PlantingConfirmDialog] 确认与 [_submitPlanting] 之间:
|
||||
/// 弹出 [PasswordVerifyDialog] → 密码正确才调用 [_submitPlanting],
|
||||
/// 弹出 [PasswordVerifyDialog](支付密码模式)→ 密码正确才调用 [_submitPlanting],
|
||||
/// 取消或验证失败则停留在当前页,不触发任何提交逻辑。
|
||||
Future<void> _verifyPasswordThenSubmit() async {
|
||||
final verified = await PasswordVerifyDialog.show(
|
||||
context: context,
|
||||
title: '验证支付密码',
|
||||
subtitle: '为保障资金安全,请输入您的6位支付密码以确认认种',
|
||||
hint: '请输入支付密码',
|
||||
onVerify: (password) async {
|
||||
final accountService = ref.read(accountServiceProvider);
|
||||
return await accountService.verifyLoginPassword(password);
|
||||
return await accountService.verifyPaymentPassword(password);
|
||||
},
|
||||
);
|
||||
if (verified == true) {
|
||||
|
|
|
|||
|
|
@ -7,28 +7,45 @@ import 'package:flutter/material.dart';
|
|||
/// 抛出异常表示网络/系统错误(由弹窗捕获并展示提示)
|
||||
typedef PasswordVerifyCallback = Future<bool> Function(String password);
|
||||
|
||||
/// 认种密码校验弹窗
|
||||
/// 密码校验弹窗(通用)
|
||||
///
|
||||
/// 在 [PlantingConfirmDialog] 用户点击"确认认种"之后、实际提交之前弹出,
|
||||
/// 要求用户输入登录密码进行二次身份验证,防止误操作或他人操作。
|
||||
/// 支持两种模式:
|
||||
/// - 登录密码验证(认种/预种前身份核实)
|
||||
/// - 支付密码验证(认种/预种支付二次确认)
|
||||
///
|
||||
/// 调用方式:
|
||||
/// ```dart
|
||||
/// // 验证支付密码
|
||||
/// final verified = await PasswordVerifyDialog.show(
|
||||
/// context: context,
|
||||
/// onVerify: (password) => accountService.verifyLoginPassword(password),
|
||||
/// title: '验证支付密码',
|
||||
/// subtitle: '请输入6位支付密码以确认认种',
|
||||
/// hint: '请输入支付密码',
|
||||
/// onVerify: (password) => accountService.verifyPaymentPassword(password),
|
||||
/// );
|
||||
/// if (verified == true) _submitPlanting();
|
||||
/// ```
|
||||
///
|
||||
/// 后端接口:POST /user/verify-password → { valid: bool }
|
||||
/// 后端接口:POST /user/verify-payment-password → { valid: bool }
|
||||
class PasswordVerifyDialog extends StatefulWidget {
|
||||
/// 密码校验回调,由调用方注入(通常调用 accountService.verifyLoginPassword)
|
||||
/// 密码校验回调,由调用方注入
|
||||
final PasswordVerifyCallback onVerify;
|
||||
|
||||
/// 弹窗标题,默认"验证登录密码"
|
||||
final String title;
|
||||
|
||||
/// 说明文字,默认"为保障账户安全,请输入您的登录密码以继续"
|
||||
final String subtitle;
|
||||
|
||||
/// 输入框 hint,默认"请输入登录密码"
|
||||
final String hint;
|
||||
|
||||
const PasswordVerifyDialog({
|
||||
super.key,
|
||||
required this.onVerify,
|
||||
this.title = '验证登录密码',
|
||||
this.subtitle = '为保障账户安全,请输入您的登录密码以继续',
|
||||
this.hint = '请输入登录密码',
|
||||
});
|
||||
|
||||
/// 显示密码验证弹窗
|
||||
|
|
@ -36,12 +53,20 @@ class PasswordVerifyDialog extends StatefulWidget {
|
|||
static Future<bool?> show({
|
||||
required BuildContext context,
|
||||
required PasswordVerifyCallback onVerify,
|
||||
String title = '验证登录密码',
|
||||
String subtitle = '为保障账户安全,请输入您的登录密码以继续',
|
||||
String hint = '请输入登录密码',
|
||||
}) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
barrierColor: const Color(0x80000000),
|
||||
builder: (context) => PasswordVerifyDialog(onVerify: onVerify),
|
||||
builder: (context) => PasswordVerifyDialog(
|
||||
onVerify: onVerify,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
hint: hint,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +98,7 @@ class _PasswordVerifyDialogState extends State<PasswordVerifyDialog> {
|
|||
Future<void> _handleConfirm() async {
|
||||
final password = _passwordController.text.trim();
|
||||
if (password.isEmpty) {
|
||||
setState(() => _errorMessage = '请输入登录密码');
|
||||
setState(() => _errorMessage = '请输入${widget.hint.replaceAll('请输入', '')}');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -205,9 +230,9 @@ class _PasswordVerifyDialogState extends State<PasswordVerifyDialog> {
|
|||
|
||||
/// 构建标题
|
||||
Widget _buildTitle() {
|
||||
return const Text(
|
||||
'验证登录密码',
|
||||
style: TextStyle(
|
||||
return Text(
|
||||
widget.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
|
|
@ -221,9 +246,9 @@ class _PasswordVerifyDialogState extends State<PasswordVerifyDialog> {
|
|||
|
||||
/// 构建说明文字
|
||||
Widget _buildSubtitle() {
|
||||
return const Text(
|
||||
'为保障账户安全,请输入您的登录密码以继续认种',
|
||||
style: TextStyle(
|
||||
return Text(
|
||||
widget.subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.5,
|
||||
|
|
@ -267,7 +292,7 @@ class _PasswordVerifyDialogState extends State<PasswordVerifyDialog> {
|
|||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
border: InputBorder.none,
|
||||
hintText: '请输入登录密码',
|
||||
hintText: widget.hint,
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import '../../../../core/di/injection_container.dart';
|
|||
import '../../../../core/services/pre_planting_service.dart';
|
||||
import '../../../../core/services/tree_pricing_service.dart';
|
||||
import '../../../../routes/route_paths.dart';
|
||||
import '../../../planting/presentation/widgets/password_verify_dialog.dart'; // [2026-03-05] 支付密码验证
|
||||
|
||||
// ============================================
|
||||
// [2026-02-17] 预种计划购买页面
|
||||
|
|
@ -344,6 +345,19 @@ class _PrePlantingPurchasePageState
|
|||
final confirmed = await _showConfirmDialog();
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
// 第三步:验证支付密码 [2026-03-05]
|
||||
final verified = await PasswordVerifyDialog.show(
|
||||
context: context,
|
||||
title: '验证支付密码',
|
||||
subtitle: '为保障资金安全,请输入您的6位支付密码以确认预种',
|
||||
hint: '请输入支付密码',
|
||||
onVerify: (password) async {
|
||||
final accountService = ref.read(accountServiceProvider);
|
||||
return await accountService.verifyPaymentPassword(password);
|
||||
},
|
||||
);
|
||||
if (verified != true || !mounted) return;
|
||||
|
||||
setState(() => _isPurchasing = true);
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1342,11 +1342,16 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
context.push(RoutePaths.googleAuth);
|
||||
}
|
||||
|
||||
/// 修改密码
|
||||
/// 修改登录密码
|
||||
void _goToChangePassword() {
|
||||
context.push(RoutePaths.changePassword);
|
||||
}
|
||||
|
||||
/// 支付密码设置/修改 [2026-03-05]
|
||||
void _goToPaymentPassword() {
|
||||
context.push(RoutePaths.paymentPassword);
|
||||
}
|
||||
|
||||
/// 绑定邮箱
|
||||
void _goToBindEmail() {
|
||||
context.push(RoutePaths.bindEmail);
|
||||
|
|
@ -4206,9 +4211,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
// ),
|
||||
_buildSettingItem(
|
||||
icon: Icons.lock,
|
||||
title: '修改登录密码',
|
||||
title: '修改密码',
|
||||
onTap: _goToChangePassword,
|
||||
),
|
||||
_buildSettingItem(
|
||||
icon: Icons.payment,
|
||||
title: '支付密码',
|
||||
onTap: _goToPaymentPassword,
|
||||
),
|
||||
_buildSettingItem(
|
||||
icon: Icons.email,
|
||||
title: '绑定邮箱',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,487 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
|
||||
/// 支付密码设置/修改页面
|
||||
///
|
||||
/// - 未设置时:直接输入新密码(首次设置)
|
||||
/// - 已设置时:需验证旧密码后修改
|
||||
///
|
||||
/// 支付密码格式:6位纯数字
|
||||
class ChangePaymentPasswordPage extends ConsumerStatefulWidget {
|
||||
const ChangePaymentPasswordPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ChangePaymentPasswordPage> createState() =>
|
||||
_ChangePaymentPasswordPageState();
|
||||
}
|
||||
|
||||
class _ChangePaymentPasswordPageState
|
||||
extends ConsumerState<ChangePaymentPasswordPage> {
|
||||
final TextEditingController _oldPasswordController = TextEditingController();
|
||||
final TextEditingController _newPasswordController = TextEditingController();
|
||||
final TextEditingController _confirmPasswordController =
|
||||
TextEditingController();
|
||||
|
||||
bool _showOldPassword = false;
|
||||
bool _showNewPassword = false;
|
||||
bool _showConfirmPassword = false;
|
||||
bool _isSubmitting = false;
|
||||
bool _hasPaymentPassword = false;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadStatus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_oldPasswordController.dispose();
|
||||
_newPasswordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadStatus() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final accountService = ref.read(accountServiceProvider);
|
||||
final hasPassword = await accountService.isPaymentPasswordSet();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hasPaymentPassword = hasPassword;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hasPaymentPassword = false;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验支付密码格式:6位纯数字
|
||||
String? _validatePassword(String password) {
|
||||
if (password.length != 6) return '支付密码必须为6位数字';
|
||||
if (!RegExp(r'^\d{6}$').hasMatch(password)) return '支付密码只能包含数字';
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final oldPassword = _oldPasswordController.text;
|
||||
final newPassword = _newPasswordController.text;
|
||||
final confirmPassword = _confirmPasswordController.text;
|
||||
|
||||
if (_hasPaymentPassword && oldPassword.isEmpty) {
|
||||
_showError('请输入当前支付密码');
|
||||
return;
|
||||
}
|
||||
if (newPassword.isEmpty) {
|
||||
_showError('请输入新支付密码');
|
||||
return;
|
||||
}
|
||||
final formatError = _validatePassword(newPassword);
|
||||
if (formatError != null) {
|
||||
_showError(formatError);
|
||||
return;
|
||||
}
|
||||
if (confirmPassword != newPassword) {
|
||||
_showError('两次输入的支付密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
final accountService = ref.read(accountServiceProvider);
|
||||
if (_hasPaymentPassword) {
|
||||
await accountService.changePaymentPassword(
|
||||
oldPassword: oldPassword,
|
||||
newPassword: newPassword,
|
||||
);
|
||||
} else {
|
||||
await accountService.setPaymentPassword(newPassword);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _isSubmitting = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(_hasPaymentPassword ? '支付密码修改成功' : '支付密码设置成功'),
|
||||
backgroundColor: const Color(0xFF4CAF50),
|
||||
),
|
||||
);
|
||||
context.pop(true);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isSubmitting = false);
|
||||
_showError(
|
||||
'操作失败: ${e.toString().replaceAll('Exception: ', '')}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFFFF5E6), Color(0xFFFFE4B5)],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildAppBar(),
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Color(0xFFD4AF37)),
|
||||
),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildStatusCard(),
|
||||
const SizedBox(height: 24),
|
||||
_buildPasswordRequirements(),
|
||||
const SizedBox(height: 24),
|
||||
if (_hasPaymentPassword) ...[
|
||||
_buildPasswordInput(
|
||||
controller: _oldPasswordController,
|
||||
label: '当前支付密码',
|
||||
hint: '请输入当前6位支付密码',
|
||||
showPassword: _showOldPassword,
|
||||
onToggleVisibility: () => setState(
|
||||
() => _showOldPassword = !_showOldPassword),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
_buildPasswordInput(
|
||||
controller: _newPasswordController,
|
||||
label: '新支付密码',
|
||||
hint: '请输入6位数字支付密码',
|
||||
showPassword: _showNewPassword,
|
||||
onToggleVisibility: () => setState(
|
||||
() => _showNewPassword = !_showNewPassword),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildPasswordInput(
|
||||
controller: _confirmPasswordController,
|
||||
label: '确认支付密码',
|
||||
hint: '请再次输入6位支付密码',
|
||||
showPassword: _showConfirmPassword,
|
||||
onToggleVisibility: () => setState(() =>
|
||||
_showConfirmPassword = !_showConfirmPassword),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
_buildSubmitButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar() {
|
||||
return Container(
|
||||
height: 64,
|
||||
padding: const EdgeInsets.only(top: 16, left: 16, right: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => context.pop(),
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(Icons.arrow_back,
|
||||
size: 24, color: Color(0xFF5D4037)),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_hasPaymentPassword ? '修改支付密码' : '设置支付密码',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.25,
|
||||
letterSpacing: -0.27,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 48),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusCard() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x80FFFFFF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0x33D4AF37), width: 1),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: _hasPaymentPassword
|
||||
? const Color(0xFF4CAF50).withValues(alpha: 0.1)
|
||||
: const Color(0xFFFF9800).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Icon(
|
||||
_hasPaymentPassword ? Icons.payment : Icons.payment_outlined,
|
||||
color: _hasPaymentPassword
|
||||
? const Color(0xFF4CAF50)
|
||||
: const Color(0xFFFF9800),
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_hasPaymentPassword ? '已设置支付密码' : '未设置支付密码',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _hasPaymentPassword
|
||||
? const Color(0xFF4CAF50)
|
||||
: const Color(0xFFFF9800),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_hasPaymentPassword
|
||||
? '认种、预种等支付操作需要验证支付密码'
|
||||
: '设置后,认种和预种时需输入支付密码确认',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
color: Color(0xFF745D43),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPasswordRequirements() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF5E6),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0x33D4AF37), width: 1),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 20, color: Color(0xFF8B5A2B)),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'支付密码要求',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildRequirementItem('必须为 6 位纯数字'),
|
||||
_buildRequirementItem('与登录密码相互独立'),
|
||||
_buildRequirementItem('用于认种、预种等支付场景的二次验证'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRequirementItem(String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle_outline,
|
||||
size: 16, color: Color(0xFFD4AF37)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
color: Color(0xFF745D43),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPasswordInput({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required String hint,
|
||||
required bool showPassword,
|
||||
required VoidCallback onToggleVisibility,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 54,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x80FFFFFF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0x80FFFFFF), width: 1),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 2,
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
obscureText: !showPassword,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.19,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
|
||||
border: InputBorder.none,
|
||||
counterText: '',
|
||||
hintText: hint,
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.19,
|
||||
color: Color(0x995D4037),
|
||||
),
|
||||
suffixIcon: GestureDetector(
|
||||
onTap: onToggleVisibility,
|
||||
child: Icon(
|
||||
showPassword ? Icons.visibility_off : Icons.visibility,
|
||||
color: const Color(0xFF8B5A2B),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubmitButton() {
|
||||
return GestureDetector(
|
||||
onTap: _isSubmitting ? null : _submit,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: _isSubmitting
|
||||
? const Color(0xFFD4AF37).withValues(alpha: 0.5)
|
||||
: const Color(0xFFD4AF37),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x4DD4AF37),
|
||||
blurRadius: 14,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: _isSubmitting
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
_hasPaymentPassword ? '修改支付密码' : '设置支付密码',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.5,
|
||||
letterSpacing: 0.24,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ import '../features/planting/presentation/pages/planting_quantity_page.dart';
|
|||
import '../features/planting/presentation/pages/planting_location_page.dart';
|
||||
import '../features/security/presentation/pages/google_auth_page.dart';
|
||||
import '../features/security/presentation/pages/change_password_page.dart';
|
||||
import '../features/security/presentation/pages/change_payment_password_page.dart'; // [2026-03-05] 支付密码页
|
||||
import '../features/security/presentation/pages/bind_email_page.dart';
|
||||
import '../features/authorization/presentation/pages/authorization_apply_page.dart';
|
||||
import '../features/withdraw/presentation/pages/withdraw_usdt_page.dart';
|
||||
|
|
@ -341,6 +342,13 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||
builder: (context, state) => const ChangePasswordPage(),
|
||||
),
|
||||
|
||||
// Payment Password Page (支付密码设置/修改) [2026-03-05]
|
||||
GoRoute(
|
||||
path: RoutePaths.paymentPassword,
|
||||
name: RouteNames.paymentPassword,
|
||||
builder: (context, state) => const ChangePaymentPasswordPage(),
|
||||
),
|
||||
|
||||
// Bind Email Page (绑定邮箱)
|
||||
GoRoute(
|
||||
path: RoutePaths.bindEmail,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ class RouteNames {
|
|||
static const plantingLocation = 'planting-location';
|
||||
static const googleAuth = 'google-auth';
|
||||
static const changePassword = 'change-password';
|
||||
static const paymentPassword = 'payment-password'; // [2026-03-05] 支付密码
|
||||
static const bindEmail = 'bind-email';
|
||||
static const authorizationApply = 'authorization-apply';
|
||||
static const transactionHistory = 'transaction-history';
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ class RoutePaths {
|
|||
static const plantingLocation = '/planting/location';
|
||||
static const googleAuth = '/security/google-auth';
|
||||
static const changePassword = '/security/password';
|
||||
static const paymentPassword = '/security/payment-password'; // [2026-03-05] 支付密码设置/修改
|
||||
static const bindEmail = '/security/email';
|
||||
static const authorizationApply = '/authorization/apply';
|
||||
static const transactionHistory = '/trading/history';
|
||||
|
|
|
|||
Loading…
Reference in New Issue