已完成用户APP首期功能开发

交付用户端首期页面、配套接口、后台资源及测试文档。用户APP构建、静态分析和三个管理后台构建通过;完整测试仍有2项失败,后端模型注释检查未通过,详见交付记录。
This commit is contained in:
czl231
2026-09-13 00:57:32 +08:00
parent a0a4bb1218
commit 3ef33b531d
793 changed files with 40959 additions and 1204 deletions

View File

@@ -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();
});
}
}
}

View 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 ? '处理中' : '修改密码'),
),
],
);
}

View File

@@ -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());