已完成用户APP首期功能开发
交付用户端首期页面、配套接口、后台资源及测试文档。用户APP构建、静态分析和三个管理后台构建通过;完整测试仍有2项失败,后端模型注释检查未通过,详见交付记录。
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
// 功能描述:提供用户端手机号密码登录界面及本地输入校验。
|
||||
// 版本:1.2.0
|
||||
// 功能描述:提供验证码和密码登录、协议确认与本地输入校验。
|
||||
// 版本:1.4.0
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/reference_image.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/auth_navigation.dart';
|
||||
import '../../../app/dependencies.dart';
|
||||
import '../../../data/services/api_client.dart';
|
||||
import '../../../data/repositories/client_repository.dart';
|
||||
import '../../../data/repositories/primary_repository.dart';
|
||||
import 'login_support.dart';
|
||||
|
||||
/// 用户端手机号密码登录页面。
|
||||
class LoginPage extends StatefulWidget {
|
||||
@@ -13,10 +18,12 @@ class LoginPage extends StatefulWidget {
|
||||
required this.session,
|
||||
this.redirectTarget,
|
||||
this.showSessionExpiredMessage = false,
|
||||
this.repository,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final UserSession session;
|
||||
final ClientRepository? repository;
|
||||
final String? redirectTarget;
|
||||
final bool showSessionExpiredMessage;
|
||||
|
||||
@@ -37,6 +44,13 @@ class _LoginPageState extends State<LoginPage> {
|
||||
String? _phoneError;
|
||||
String? _passwordError;
|
||||
bool _obscurePassword = true;
|
||||
bool _verificationMode = true;
|
||||
bool _remember = false;
|
||||
bool _sendingCode = false;
|
||||
int _seconds = 0;
|
||||
String? _requestIdentity;
|
||||
String? _codePhone;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -48,6 +62,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_phone.dispose();
|
||||
_password.dispose();
|
||||
_phoneFocus.dispose();
|
||||
@@ -59,17 +74,23 @@ class _LoginPageState extends State<LoginPage> {
|
||||
Future<void> _login() async {
|
||||
final phone = _phone.text.trim();
|
||||
final phoneValid = _phonePattern.hasMatch(phone);
|
||||
final passwordValid = _password.text.isNotEmpty;
|
||||
final passwordValid = _verificationMode
|
||||
? RegExp(r'^\d{4,8}$').hasMatch(_password.text.trim())
|
||||
: _password.text.isNotEmpty;
|
||||
if (!phoneValid || !passwordValid) {
|
||||
setState(() {
|
||||
_phoneError = phoneValid ? null : '请输入正确的11位手机号';
|
||||
_passwordError = passwordValid ? null : '请输入密码';
|
||||
_passwordError = passwordValid ? null : (_verificationMode ? '请输入有效验证码' : '请输入密码');
|
||||
_error = null;
|
||||
});
|
||||
// 聚焦第一个无效字段,便于键盘和辅助技术用户立即修正。
|
||||
(phoneValid ? _passwordFocus : _phoneFocus).requestFocus();
|
||||
return;
|
||||
}
|
||||
if (_verificationMode && (_requestIdentity == null || _codePhone != phone)) {
|
||||
setState(() => _error = '请先获取当前手机号的验证码');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_submitting = true;
|
||||
@@ -78,7 +99,53 @@ class _LoginPageState extends State<LoginPage> {
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await widget.session.login(phone: phone, password: _password.text);
|
||||
if (widget.repository != null) {
|
||||
final agreements = (await PrimaryRepository(
|
||||
widget.repository!,
|
||||
).contents()).where((c) => c.type == 'agreement').toList();
|
||||
if (!mounted) return;
|
||||
if (agreements.isNotEmpty) {
|
||||
widget.session.loginConsentShownAt = DateTime.now();
|
||||
final agreed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('请阅读并确认协议'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final agreement in agreements)
|
||||
ExpansionTile(
|
||||
title: Text(agreement.title),
|
||||
subtitle: Text('版本 ${agreement.version}'),
|
||||
children: [SelectableText(agreement.body)],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('不同意'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('同意并登录'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (agreed != true) return;
|
||||
}
|
||||
widget.session.loginConsents = agreements;
|
||||
}
|
||||
widget.session.rememberLogin = _remember;
|
||||
await widget.session.login(
|
||||
phone: phone,
|
||||
password: _verificationMode ? '' : _password.text,
|
||||
verificationCode: _verificationMode ? _password.text.trim() : null,
|
||||
requestIdentity: _verificationMode ? _requestIdentity : null,
|
||||
);
|
||||
} catch (error) {
|
||||
// 登录接口使用统一密码错误码,页面不区分账号是否存在,避免泄露账号状态。
|
||||
final message = error is ApiException
|
||||
@@ -90,51 +157,77 @@ class _LoginPageState extends State<LoginPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取验证码;手机号变化后旧验证码请求不可继续使用。
|
||||
Future<void> _sendCode() async {
|
||||
final phone = _phone.text.trim();
|
||||
if (!_phonePattern.hasMatch(phone)) {
|
||||
setState(() => _phoneError = '请输入正确的11位手机号');
|
||||
_phoneFocus.requestFocus();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_sendingCode = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final identity = await widget.session.sendCode(phone, 'login');
|
||||
if (identity.isEmpty) throw const ApiException(1714, '验证码发送结果待确认,请稍后重试');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_requestIdentity = identity;
|
||||
_codePhone = phone;
|
||||
_seconds = 60;
|
||||
});
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
setState(() => _seconds--);
|
||||
if (_seconds <= 0) timer.cancel();
|
||||
});
|
||||
} catch (error) {
|
||||
if (mounted) setState(() => _error = error is ApiException ? error.toString() : '验证码发送失败');
|
||||
} finally {
|
||||
if (mounted) setState(() => _sendingCode = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 32, 24, 24),
|
||||
padding: const EdgeInsets.fromLTRB(20, 76, 20, 32),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Align(
|
||||
child: Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.health_and_safety_rounded,
|
||||
size: 38,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
semanticLabel: '瓶安芯安全标识',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'瓶安芯',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const Center(child: SizedBox(width: 140, child: ReferenceImage.brand())),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'安全服务与生活采购',
|
||||
'智能角阀,安全守护',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: const TextStyle(fontSize: 15, height: 1.4, color: Color(0xFF666666)),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
const SizedBox(height: 26),
|
||||
Row(
|
||||
children: [
|
||||
_modeTab(true, '验证码登录'),
|
||||
_modeTab(false, '密码登录'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 29),
|
||||
const Text('手机号', style: TextStyle(fontSize: 14)),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _phone,
|
||||
focusNode: _phoneFocus,
|
||||
style: const TextStyle(fontSize: 14, height: 1.4),
|
||||
keyboardType: TextInputType.phone,
|
||||
autofillHints: const [AutofillHints.telephoneNumber],
|
||||
onChanged: (_) {
|
||||
@@ -146,40 +239,88 @@ class _LoginPageState extends State<LoginPage> {
|
||||
}
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: '手机号',
|
||||
prefixIcon: const Icon(Icons.phone_outlined),
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 13, vertical: 14),
|
||||
enabledBorder: _inputBorder,
|
||||
hintText: '请输入手机号',
|
||||
errorText: _phoneError,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _password,
|
||||
focusNode: _passwordFocus,
|
||||
obscureText: _obscurePassword,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
onChanged: (_) {
|
||||
if (_passwordError != null || _error != null) {
|
||||
setState(() {
|
||||
_passwordError = null;
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
onSubmitted: (_) => _submitting ? null : _login(),
|
||||
decoration: InputDecoration(
|
||||
labelText: '密码',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
errorText: _passwordError,
|
||||
suffixIcon: IconButton(
|
||||
tooltip: _obscurePassword ? '显示密码' : '隐藏密码',
|
||||
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
const SizedBox(height: 24),
|
||||
Text(_verificationMode ? '验证码' : '密码', style: const TextStyle(fontSize: 14)),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
// 切换验证码/密码或显隐时重建输入连接,避免Web沿用已移除的输入DOM。
|
||||
key: ValueKey('login-secret-$_verificationMode-$_obscurePassword'),
|
||||
controller: _password,
|
||||
focusNode: _passwordFocus,
|
||||
style: const TextStyle(fontSize: 14, height: 1.4),
|
||||
obscureText: !_verificationMode && _obscurePassword,
|
||||
keyboardType: _verificationMode
|
||||
? TextInputType.number
|
||||
: TextInputType.visiblePassword,
|
||||
autofillHints: [
|
||||
_verificationMode ? AutofillHints.oneTimeCode : AutofillHints.password,
|
||||
],
|
||||
onChanged: (_) {
|
||||
if (_passwordError != null || _error != null) {
|
||||
setState(() {
|
||||
_passwordError = null;
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
onSubmitted: (_) => _submitting ? null : _login(),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 13, vertical: 14),
|
||||
enabledBorder: _inputBorder,
|
||||
hintText: _verificationMode ? '请输入验证码' : '请输入密码',
|
||||
errorText: _passwordError,
|
||||
suffixIcon: _verificationMode
|
||||
? null
|
||||
: IconButton(
|
||||
tooltip: _obscurePassword ? '显示密码' : '隐藏密码',
|
||||
onPressed: () => _configureSecretInput(
|
||||
() => _obscurePassword = !_obscurePassword,
|
||||
),
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_verificationMode) ...[
|
||||
const SizedBox(width: 17),
|
||||
SizedBox(
|
||||
width: 113,
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 50),
|
||||
foregroundColor: const Color(0xFF005FFF),
|
||||
side: const BorderSide(color: Color(0xFF005FFF)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
onPressed: _sendingCode || _seconds > 0 ? null : _sendCode,
|
||||
child: Text(
|
||||
_sendingCode
|
||||
? '发送中'
|
||||
: _seconds > 0
|
||||
? '${_seconds}s'
|
||||
: '获取验证码',
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
@@ -206,15 +347,62 @@ class _LoginPageState extends State<LoginPage> {
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
title: const Text('记住登录状态', style: TextStyle(fontSize: 14)),
|
||||
activeColor: const Color(0xFF005FFF),
|
||||
value: _remember,
|
||||
onChanged: _submitting
|
||||
? null
|
||||
: (value) => setState(() => _remember = value ?? false),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF0661EE),
|
||||
minimumSize: const Size(0, 51),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||
),
|
||||
onPressed: _submitting ? null : _login,
|
||||
child: _submitting
|
||||
? const SizedBox.square(
|
||||
dimension: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('安全登录'),
|
||||
: const Text(
|
||||
'登录',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _submitting
|
||||
? null
|
||||
: () => showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => ResetPasswordDialog(session: widget.session),
|
||||
),
|
||||
child: const Text(
|
||||
'忘记密码',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w400),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(padding: EdgeInsets.zero),
|
||||
onPressed: () => showPublishedAgreements(context),
|
||||
child: const Text.rich(
|
||||
TextSpan(
|
||||
text: '登录即表示你已阅读并同意 ',
|
||||
style: TextStyle(fontSize: 12.5, height: 1.6, color: Color(0xFF666666)),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '《用户协议》和《隐私政策》',
|
||||
style: TextStyle(color: Color(0xFF005FFF)),
|
||||
),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.push(
|
||||
@@ -226,14 +414,6 @@ class _LoginPageState extends State<LoginPage> {
|
||||
),
|
||||
child: const Text('首次使用?注册账号'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'登录即表示你已阅读并同意《用户协议》和《隐私政策》',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -241,4 +421,67 @@ class _LoginPageState extends State<LoginPage> {
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 图 01 使用浅灰一像素边框,错误和焦点边框仍沿用主题状态。
|
||||
static const _inputBorder = OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(6)),
|
||||
borderSide: BorderSide(color: Color(0xFFCCCCCC)),
|
||||
);
|
||||
|
||||
/// 登录方式保持双标签布局,切换时清除上一种方式的凭据与错误。
|
||||
Widget _modeTab(bool verification, String label) {
|
||||
final selected = _verificationMode == verification;
|
||||
return Expanded(
|
||||
child: Semantics(
|
||||
selected: selected,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: selected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: selected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
minimumSize: const Size(0, 44),
|
||||
),
|
||||
onPressed: _submitting
|
||||
? null
|
||||
: () {
|
||||
if (_verificationMode == verification) return;
|
||||
_configureSecretInput(() {
|
||||
_verificationMode = verification;
|
||||
_password.clear();
|
||||
_passwordError = null;
|
||||
_error = null;
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 先结束旧输入连接,再应用输入类型;已有焦点在下一帧恢复,显隐保留文本。
|
||||
void _configureSecretInput(VoidCallback change) {
|
||||
final restoreFocus = _passwordFocus.hasFocus;
|
||||
_passwordFocus.unfocus();
|
||||
setState(change);
|
||||
if (restoreFocus) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _passwordFocus.requestFocus();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
175
apps/user_app/lib/ui/features/auth/login_support.dart
Normal file
175
apps/user_app/lib/ui/features/auth/login_support.dart
Normal file
@@ -0,0 +1,175 @@
|
||||
// 功能描述:登录页的已发布协议阅读与验证码重置密码交互。
|
||||
// 版本:1.0.0
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../app/dependencies.dart';
|
||||
import '../../../data/repositories/client_repository.dart';
|
||||
import '../../../data/repositories/primary_repository.dart';
|
||||
import '../../../data/services/api_client.dart';
|
||||
import '../../../domain/models/primary_models.dart';
|
||||
import '../../core/async_content.dart';
|
||||
|
||||
/// 读取后台已发布协议并展示实际版本;正文缺失时不提供虚构条款。
|
||||
Future<void> showPublishedAgreements(BuildContext context, {ClientRepository? repository}) =>
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => Dialog(
|
||||
child: SizedBox(
|
||||
width: 440,
|
||||
height: 520,
|
||||
child: Column(
|
||||
children: [
|
||||
AppBar(
|
||||
title: const Text('协议与隐私'),
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '关闭',
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: AsyncContent<List<PublishedContent>>(
|
||||
load: () async => (await PrimaryRepository(
|
||||
repository ?? ClientRepository(ApiClient(() => '')),
|
||||
).contents()).where((c) => c.type == 'agreement').toList(),
|
||||
empty: (items) => items.isEmpty,
|
||||
builder: (context, items) => ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
for (final item in items)
|
||||
ExpansionTile(
|
||||
title: Text(item.title),
|
||||
subtitle: Text('版本 ${item.version}'),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: SelectableText(item.body),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// 重置密码表单;验证码仅限重置用途,提交成功才关闭弹窗。
|
||||
class ResetPasswordDialog extends StatefulWidget {
|
||||
const ResetPasswordDialog({required this.session, super.key});
|
||||
final UserSession session;
|
||||
@override
|
||||
State<ResetPasswordDialog> createState() => _ResetPasswordDialogState();
|
||||
}
|
||||
|
||||
/// 管理重置验证码和密码输入,失败保留用户输入。
|
||||
class _ResetPasswordDialogState extends State<ResetPasswordDialog> {
|
||||
final _phone = TextEditingController();
|
||||
final _code = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
String? _request, _requestedPhone, _error;
|
||||
bool _busy = false;
|
||||
DateTime? _sentAt;
|
||||
@override
|
||||
void dispose() {
|
||||
_phone.dispose();
|
||||
_code.dispose();
|
||||
_password.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 执行发送或重置;参数标识发送验证码,异常统一展示中文。
|
||||
Future<void> _run(bool send) async {
|
||||
final phone = _phone.text.trim();
|
||||
if (!RegExp(r'^1[3-9]\d{9}$').hasMatch(phone)) {
|
||||
setState(() => _error = '请输入正确的11位手机号');
|
||||
return;
|
||||
}
|
||||
if (send && _sentAt != null && DateTime.now().difference(_sentAt!).inSeconds < 60) {
|
||||
setState(() => _error = '请稍后再获取验证码');
|
||||
return;
|
||||
}
|
||||
if (!send &&
|
||||
(_request == null ||
|
||||
phone != _requestedPhone ||
|
||||
_code.text.trim().isEmpty ||
|
||||
_password.text.runes.length < 6)) {
|
||||
setState(() => _error = '请获取当前手机号的验证码,并填写至少6位新密码');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
if (send) {
|
||||
final request = await widget.session.sendCode(phone, 'reset_login_password');
|
||||
if (request.isEmpty) throw const ApiException(1714, '验证码发送结果待确认');
|
||||
_request = request;
|
||||
_requestedPhone = phone;
|
||||
_sentAt = DateTime.now();
|
||||
} else {
|
||||
await widget.session.resetPassword(
|
||||
phone: phone,
|
||||
code: _code.text.trim(),
|
||||
requestIdentity: _request!,
|
||||
password: _password.text,
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('密码已修改,请重新登录')));
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted) setState(() => _error = error is ApiException ? error.toString() : '请求失败,请稍后重试');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AlertDialog(
|
||||
title: const Text('找回密码'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _phone,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: const InputDecoration(labelText: '手机号'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _code,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: '验证码'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _busy ? null : () => _run(true),
|
||||
child: Text(_request == null ? '获取验证码' : '重新获取验证码'),
|
||||
),
|
||||
TextField(
|
||||
controller: _password,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(labelText: '新密码'),
|
||||
),
|
||||
if (_error != null)
|
||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: _busy ? null : () => Navigator.pop(context), child: const Text('取消')),
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : () => _run(false),
|
||||
child: Text(_busy ? '处理中' : '修改密码'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,8 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
final identity = await widget.session.sendCode(_phone.text.trim(), 'register');
|
||||
setState(() {
|
||||
_requestIdentity = identity;
|
||||
_message = '验证码已发送';
|
||||
// 当前接口只建立Mock请求,不能把记录写入Redis描述为短信送达。
|
||||
_message = '验证码请求已建立,当前环境未发送短信';
|
||||
});
|
||||
} catch (error) {
|
||||
setState(() => _message = error.toString());
|
||||
|
||||
Reference in New Issue
Block a user