diff --git a/apps/service_app/lib/ui/features/auth/login_page.dart b/apps/service_app/lib/ui/features/auth/login_page.dart index 6e5d91a..9cb80a9 100644 --- a/apps/service_app/lib/ui/features/auth/login_page.dart +++ b/apps/service_app/lib/ui/features/auth/login_page.dart @@ -1,5 +1,5 @@ // 功能描述:提供工作人员端手机号密码登录界面及本地输入校验。 -// 版本:1.1.0 +// 版本:1.2.0 import 'package:flutter/material.dart'; import '../../../app/dependencies.dart'; @@ -21,25 +21,34 @@ class _LoginPageState extends State { final _phone = TextEditingController(); final _password = TextEditingController(); + final _phoneFocus = FocusNode(); + final _passwordFocus = FocusNode(); bool _busy = false; String? _error; String? _phoneError; + String? _passwordError; bool _obscurePassword = true; - /// 校验手机号并提交登录请求。 + /// 校验手机号和密码,通过后再提交登录请求。 Future _login() async { final phone = _phone.text.trim(); - if (!_phonePattern.hasMatch(phone)) { + final phoneValid = _phonePattern.hasMatch(phone); + final passwordValid = _password.text.isNotEmpty; + if (!phoneValid || !passwordValid) { setState(() { - _phoneError = '请输入正确的11位手机号'; + _phoneError = phoneValid ? null : '请输入正确的11位手机号'; + _passwordError = passwordValid ? null : '请输入密码'; _error = null; }); + // 聚焦第一个无效字段,便于键盘和辅助技术用户立即修正。 + (phoneValid ? _passwordFocus : _phoneFocus).requestFocus(); return; } setState(() { _busy = true; _phoneError = null; + _passwordError = null; _error = null; }); try { @@ -59,6 +68,8 @@ class _LoginPageState extends State { void dispose() { _phone.dispose(); _password.dispose(); + _phoneFocus.dispose(); + _passwordFocus.dispose(); super.dispose(); } @@ -106,9 +117,15 @@ class _LoginPageState extends State { const SizedBox(height: 40), TextField( controller: _phone, + focusNode: _phoneFocus, keyboardType: TextInputType.phone, onChanged: (_) { - if (_phoneError != null) setState(() => _phoneError = null); + if (_phoneError != null || _error != null) { + setState(() { + _phoneError = null; + _error = null; + }); + } }, decoration: InputDecoration( labelText: '工作人员手机号', @@ -119,11 +136,22 @@ class _LoginPageState extends State { 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: (_) => _busy ? null : _login(), decoration: InputDecoration( labelText: '登录密码', prefixIcon: const Icon(Icons.lock_outline), + errorText: _passwordError, suffixIcon: IconButton( tooltip: _obscurePassword ? '显示密码' : '隐藏密码', onPressed: () => setState(() => _obscurePassword = !_obscurePassword), diff --git a/apps/service_app/test/ui/login_page_test.dart b/apps/service_app/test/ui/login_page_test.dart index 5405026..d99bdaf 100644 --- a/apps/service_app/test/ui/login_page_test.dart +++ b/apps/service_app/test/ui/login_page_test.dart @@ -1,5 +1,5 @@ -// 功能描述:验证工作人员端登录页字段、手机号校验和接口错误中文化。 -// 版本:1.1.0 +// 功能描述:验证工作人员端登录页字段、必填校验、提交行为和接口错误中文化。 +// 版本:1.2.0 import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:service_app/app/dependencies.dart'; @@ -7,6 +7,22 @@ import 'package:service_app/data/services/api_client.dart'; import 'package:service_app/data/services/secure_session_store.dart'; import 'package:service_app/ui/features/auth/login_page.dart'; +/// 记录登录调用,验证本地校验是否正确阻止请求。 +class _RecordingStaffSession extends StaffSession { + _RecordingStaffSession() : super(SecureSessionStore()); + + int loginCallCount = 0; + String? lastPhone; + String? lastPassword; + + @override + Future login(String phone, String password) async { + loginCallCount += 1; + lastPhone = phone; + lastPassword = password; + } +} + /// 验证工作人员端登录界面的关键交互。 void main() { testWidgets('staff login explains the single shared entry', (tester) async { @@ -18,8 +34,8 @@ void main() { expect(find.byType(TextField), findsNWidgets(2)); }); - testWidgets('手机号格式错误显示输入框中文提示', (tester) async { - final session = StaffSession(SecureSessionStore()); + testWidgets('手机号错误且密码为空时同时提示并阻止登录调用', (tester) async { + final session = _RecordingStaffSession(); await tester.pumpWidget(MaterialApp(home: LoginPage(session: session))); await tester.enterText(find.byType(TextField).first, '+8613800138000'); @@ -27,7 +43,48 @@ void main() { await tester.pump(); expect(find.text('请输入正确的11位手机号'), findsOneWidget); + expect(find.text('请输入密码'), findsOneWidget); expect(find.text('Invalid Argument'), findsNothing); + expect(session.loginCallCount, 0); + expect(tester.widget(find.byType(TextField).first).focusNode?.hasFocus, isTrue); + }); + + testWidgets('合法手机号配空密码时提示密码必填并阻止登录调用', (tester) async { + final session = _RecordingStaffSession(); + await tester.pumpWidget(MaterialApp(home: LoginPage(session: session))); + + await tester.enterText(find.byType(TextField).first, '13800138000'); + await tester.tap(find.text('进入工作台')); + await tester.pump(); + + expect(find.text('请输入正确的11位手机号'), findsNothing); + expect(find.text('请输入密码'), findsOneWidget); + expect(session.loginCallCount, 0); + expect(tester.widget(find.byType(TextField).at(1)).focusNode?.hasFocus, isTrue); + }); + + testWidgets('修改字段清除对应错误并由回车原样提交密码', (tester) async { + final session = _RecordingStaffSession(); + await tester.pumpWidget(MaterialApp(home: LoginPage(session: session))); + + await tester.tap(find.text('进入工作台')); + await tester.pump(); + await tester.enterText(find.byType(TextField).first, '13800138000'); + await tester.pump(); + + expect(find.text('请输入正确的11位手机号'), findsNothing); + expect(find.text('请输入密码'), findsOneWidget); + + await tester.enterText(find.byType(TextField).at(1), ' 密码123 '); + await tester.pump(); + expect(find.text('请输入密码'), findsNothing); + + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(session.loginCallCount, 1); + expect(session.lastPhone, '13800138000'); + expect(session.lastPassword, ' 密码123 '); }); test('接口错误优先按错误码中文化并屏蔽未知英文', () { diff --git a/apps/user_app/lib/ui/features/auth/login_page.dart b/apps/user_app/lib/ui/features/auth/login_page.dart index f279a68..7d8245b 100644 --- a/apps/user_app/lib/ui/features/auth/login_page.dart +++ b/apps/user_app/lib/ui/features/auth/login_page.dart @@ -1,5 +1,5 @@ // 功能描述:提供用户端手机号密码登录界面及本地输入校验。 -// 版本:1.1.0 +// 版本:1.2.0 import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -30,9 +30,12 @@ class _LoginPageState extends State { final _phone = TextEditingController(); final _password = TextEditingController(); + final _phoneFocus = FocusNode(); + final _passwordFocus = FocusNode(); bool _submitting = false; String? _error; String? _phoneError; + String? _passwordError; bool _obscurePassword = true; @override @@ -47,23 +50,31 @@ class _LoginPageState extends State { void dispose() { _phone.dispose(); _password.dispose(); + _phoneFocus.dispose(); + _passwordFocus.dispose(); super.dispose(); } - /// 校验手机号并提交登录请求。 + /// 校验手机号和密码,通过后再提交登录请求。 Future _login() async { final phone = _phone.text.trim(); - if (!_phonePattern.hasMatch(phone)) { + final phoneValid = _phonePattern.hasMatch(phone); + final passwordValid = _password.text.isNotEmpty; + if (!phoneValid || !passwordValid) { setState(() { - _phoneError = '请输入正确的11位手机号'; + _phoneError = phoneValid ? null : '请输入正确的11位手机号'; + _passwordError = passwordValid ? null : '请输入密码'; _error = null; }); + // 聚焦第一个无效字段,便于键盘和辅助技术用户立即修正。 + (phoneValid ? _passwordFocus : _phoneFocus).requestFocus(); return; } setState(() { _submitting = true; _phoneError = null; + _passwordError = null; _error = null; }); try { @@ -123,10 +134,16 @@ class _LoginPageState extends State { const SizedBox(height: 40), TextField( controller: _phone, + focusNode: _phoneFocus, keyboardType: TextInputType.phone, autofillHints: const [AutofillHints.telephoneNumber], onChanged: (_) { - if (_phoneError != null) setState(() => _phoneError = null); + if (_phoneError != null || _error != null) { + setState(() { + _phoneError = null; + _error = null; + }); + } }, decoration: InputDecoration( labelText: '手机号', @@ -137,12 +154,22 @@ class _LoginPageState extends State { 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), diff --git a/apps/user_app/test/ui/login_page_test.dart b/apps/user_app/test/ui/login_page_test.dart index 8625840..3baf262 100644 --- a/apps/user_app/test/ui/login_page_test.dart +++ b/apps/user_app/test/ui/login_page_test.dart @@ -1,5 +1,5 @@ -// 功能描述:验证用户端登录页字段、手机号校验和接口错误中文化。 -// 版本:1.1.0 +// 功能描述:验证用户端登录页字段、必填校验、提交行为和接口错误中文化。 +// 版本:1.2.0 import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:user_app/app/dependencies.dart'; @@ -7,6 +7,27 @@ import 'package:user_app/data/services/api_client.dart'; import 'package:user_app/data/services/secure_session_store.dart'; import 'package:user_app/ui/features/auth/login_page.dart'; +/// 记录登录调用,验证本地校验是否正确阻止请求。 +class _RecordingUserSession extends UserSession { + _RecordingUserSession() : super(SecureSessionStore()); + + int loginCallCount = 0; + String? lastPhone; + String? lastPassword; + + @override + Future login({ + required String phone, + required String password, + String? verificationCode, + String? requestIdentity, + }) async { + loginCallCount += 1; + lastPhone = phone; + lastPassword = password; + } +} + /// 验证用户端登录界面的关键交互。 void main() { testWidgets('login page exposes phone and password fields', (tester) async { @@ -18,8 +39,8 @@ void main() { expect(find.text('安全登录'), findsOneWidget); }); - testWidgets('手机号格式错误显示输入框中文提示', (tester) async { - final session = UserSession(SecureSessionStore()); + testWidgets('手机号错误且密码为空时同时提示并阻止登录调用', (tester) async { + final session = _RecordingUserSession(); await tester.pumpWidget(MaterialApp(home: LoginPage(session: session))); await tester.enterText(find.byType(TextField).first, '+8613800138000'); @@ -27,7 +48,48 @@ void main() { await tester.pump(); expect(find.text('请输入正确的11位手机号'), findsOneWidget); + expect(find.text('请输入密码'), findsOneWidget); expect(find.text('Invalid Argument'), findsNothing); + expect(session.loginCallCount, 0); + expect(tester.widget(find.byType(TextField).first).focusNode?.hasFocus, isTrue); + }); + + testWidgets('合法手机号配空密码时提示密码必填并阻止登录调用', (tester) async { + final session = _RecordingUserSession(); + await tester.pumpWidget(MaterialApp(home: LoginPage(session: session))); + + await tester.enterText(find.byType(TextField).first, '13800138000'); + await tester.tap(find.text('安全登录')); + await tester.pump(); + + expect(find.text('请输入正确的11位手机号'), findsNothing); + expect(find.text('请输入密码'), findsOneWidget); + expect(session.loginCallCount, 0); + expect(tester.widget(find.byType(TextField).at(1)).focusNode?.hasFocus, isTrue); + }); + + testWidgets('修改字段清除对应错误并由回车原样提交密码', (tester) async { + final session = _RecordingUserSession(); + await tester.pumpWidget(MaterialApp(home: LoginPage(session: session))); + + await tester.tap(find.text('安全登录')); + await tester.pump(); + await tester.enterText(find.byType(TextField).first, '13800138000'); + await tester.pump(); + + expect(find.text('请输入正确的11位手机号'), findsNothing); + expect(find.text('请输入密码'), findsOneWidget); + + await tester.enterText(find.byType(TextField).at(1), ' 密码123 '); + await tester.pump(); + expect(find.text('请输入密码'), findsNothing); + + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(session.loginCallCount, 1); + expect(session.lastPhone, '13800138000'); + expect(session.lastPassword, ' 密码123 '); }); test('接口错误优先按错误码中文化并屏蔽未知英文', () { diff --git a/docs/03-用户端App需求.md b/docs/03-用户端App需求.md index c53c5c7..ba0ee5f 100644 --- a/docs/03-用户端App需求.md +++ b/docs/03-用户端App需求.md @@ -6,6 +6,8 @@ 产品设计稿的默认登录页使用“手机号 + 验证码”方式,支持记住登录状态和忘记密码入口;用户名密码登录可作为兼容能力保留。验证码登录应具备频控、图形/行为校验和设备风控;登录前必须展示用户协议和隐私政策,并记录用户同意的协议版本。 +手机号密码登录必须在客户端同时校验手机号格式和密码必填;任一字段无效时不得发送登录请求,应在对应字段显示中文提示并聚焦首个无效字段。登录密码须原样提交,不裁剪首尾空格,也不在登录阶段重复执行注册密码长度规则。 + ### 首次进入与安全宣导 - 首次登录或安全宣导内容更新后,展示可配置的“智能瓶阀控制功能介绍”,至少支持功能介绍、安全案例、法律法规和安全注意事项等内容分类。 diff --git a/docs/04-服务端App需求.md b/docs/04-服务端App需求.md index 7c4b376..5dcbd9d 100644 --- a/docs/04-服务端App需求.md +++ b/docs/04-服务端App需求.md @@ -17,6 +17,7 @@ ### 账户、资质、上班与培训 - 登录支持用户名密码、记住登录状态、忘记密码、用户协议和隐私政策确认。注册页可申请配送员、安装维修员或安检员角色,但注册成功仅创建待审核账号;平台审核通过并校验资质后才可接单。 +- 手机号密码登录必须在客户端同时校验手机号格式和密码必填;任一字段无效时不得发送登录请求,应在对应字段显示中文提示并聚焦首个无效字段。登录密码须原样提交,登录按钮和密码框回车提交必须共用同一校验流程。 - 服务人员资料包括姓名、手机号、工号、工作单位、身份证明、角色对应从业证号、证书到期日和证件照片。配送员额外维护配送车辆车牌、颜色、车型及车辆照片;安装维修/安检人员维护专业资质、培训和工具能力。证件图片默认最多 6 张,数量和类型可配置。 - 上班/下班记录当前时间、状态、角色、地点和设备信息。未上班、超出服务区、资质失效或未完成每日培训时不得接新单;已接任务须完成、改派或按规则申请下班。 - 每日安全知识培训在首次进入工作台时弹出,按角色题库下发单选/多选题,记录题目版本、答案、结果、完成时间和次数。未完成培训时可浏览任务但默认不能开始执行;是否允许补训由平台配置。 diff --git a/docs/操作日志_移动端登录密码必填校验_20260904.md b/docs/操作日志_移动端登录密码必填校验_20260904.md new file mode 100644 index 0000000..7021e7e --- /dev/null +++ b/docs/操作日志_移动端登录密码必填校验_20260904.md @@ -0,0 +1,54 @@ +# 移动端登录密码必填校验操作日志 + +操作时间:2026-09-04 22:50:49 + +操作类型:修改、扩展 + +影响模块:用户端 Flutter App 登录页、工作人员端 Flutter App 登录页及组件测试 + +## 操作前状态 + +两个 App 的手机号密码登录页只校验手机号格式。输入合法手机号并将密码留空后,页面仍调用会话登录方法并向后端发送空密码;后端执行认证比对后统一返回密码错误,页面因此显示“手机号或密码错误”,无法准确指出密码未填写。工作人员端密码框同时缺少回车提交行为。 + +## 具体操作 + +1. 两端提交函数同时计算手机号格式和密码必填状态,任一字段无效时立即返回,不调用会话登录方法。 +2. 密码字段增加“请输入密码”错误提示,手机号与密码错误可同时显示。 +3. 校验失败后聚焦首个无效字段;手机号错误优先于密码错误。 +4. 修改对应字段时清除字段错误,修改任一凭据时清除旧的接口级错误。 +5. 工作人员端密码框增加回车提交,与按钮共用同一校验函数。 +6. 密码仅判断是否为空,非空密码保持原样,不裁剪空格、不执行注册阶段的最短长度校验。 +7. 两端测试引入记录型会话替身,覆盖空密码不调用登录、双字段错误、焦点、清错、回车提交和密码原样传递。 +8. 同步用户端、工作人员端需求说明及独立项目文档。 + +## 操作后状态 + +用户端和工作人员端在空密码时均显示字段级中文提示,不再向后端发送登录请求。手机号和密码同时无效时会完整展示两项问题并定位首个错误;输入修正后提示按字段清除。按钮和密码框回车执行相同校验,合法非空密码仍按原值交给既有会话层。 + +## 代码变更 + +- `apps/user_app/lib/ui/features/auth/login_page.dart`(+32/-5,当前 244 行):密码必填、双字段校验、焦点和清错行为。 +- `apps/service_app/lib/ui/features/auth/login_page.dart`(+33/-5,当前 217 行):同等校验能力及回车提交。 +- `apps/user_app/test/ui/login_page_test.dart`(+66/-4,当前 101 行):用户端登录页回归测试。 +- `apps/service_app/test/ui/login_page_test.dart`(+61/-4,当前 96 行):工作人员端登录页回归测试。 +- `docs/03-用户端App需求.md`、`docs/04-服务端App需求.md`:同步登录校验验收口径。 +- `docs/项目文档_移动端登录密码必填校验_v1.0.md`(新增 68 行):记录结构、行为、维护方法和边界。 + +## 验证结果 + +- 用户端 `flutter analyze --no-pub`:通过,无问题。 +- 工作人员端 `flutter analyze --no-pub`:通过,无问题。 +- 用户端 `flutter test --no-pub`:通过,共 29 项测试。 +- 工作人员端 `flutter test --no-pub`:通过,共 7 项测试。 +- 两端登录页定向测试:各 5 项通过;明确验证空密码时登录调用次数为 0。 +- 两端 `flutter build web --release --no-pub --dart-define=API_BASE_URL=http://127.0.0.1:12426`:通过。 +- `git diff --check`:通过,仅提示仓库既有的 LF/CRLF 转换策略,无空白错误。 +- 本地 Release 静态服务已恢复:用户端 `5180`、工作人员端 `5181` 均返回 HTTP 200。 +- 本地后端已按开发配置恢复监听 `12426`,平台健康接口返回 HTTP 200。 + +## 风险评估 + +- 变更只发生在两个登录页面,不修改后端、会话公共接口、路由或数据库,兼容既有调用方。 +- 密码不裁剪、不转换、不提前校验长度,避免改变既有合法凭据的认证结果。 +- 空密码仍可由绕过客户端的调用方直接请求后端;后端继续使用现有统一认证失败语义,这是本次确认的范围边界。 +- 错误提示增加后会使输入框纵向高度变化,但页面位于可滚动容器内,小屏幕不会被固定高度截断。 diff --git a/docs/项目文档_移动端登录密码必填校验_v1.0.md b/docs/项目文档_移动端登录密码必填校验_v1.0.md new file mode 100644 index 0000000..e444e9f --- /dev/null +++ b/docs/项目文档_移动端登录密码必填校验_v1.0.md @@ -0,0 +1,68 @@ +# 移动端登录密码必填校验项目文档 v1.0 + +## 1. 项目概述 + +- 项目名称:用户端与工作人员端登录密码必填校验。 +- 主要功能:在手机号密码登录请求发出前完成字段级校验,阻止空密码请求并提供明确中文反馈。 +- 技术栈:Flutter、Dart、Material 组件、Flutter Widget Test。 +- 运行环境:项目当前 Flutter SDK 与 Web 构建环境。 +- 实施范围:`user_app` 与 `service_app` 登录页面及其组件测试;后端接口、会话公共接口和数据库均不修改。 + +## 2. 目录结构说明 + +```text +platforms/ +├── apps/ +│ ├── user_app/ +│ │ ├── lib/ui/features/auth/login_page.dart # 用户端登录校验与交互 +│ │ └── test/ui/login_page_test.dart # 用户端登录组件回归测试 +│ └── service_app/ +│ ├── lib/ui/features/auth/login_page.dart # 工作人员端登录校验与交互 +│ └── test/ui/login_page_test.dart # 工作人员端登录组件回归测试 +└── docs/ + ├── 03-用户端App需求.md # 用户端登录验收口径 + ├── 04-服务端App需求.md # 工作人员端登录验收口径 + └── 操作日志_移动端登录密码必填校验_20260904.md # 本次实施与验证记录 +``` + +## 3. 核心文件说明 + +### 用户端登录页 + +- `_login` 同时校验手机号格式和密码是否为空;任一字段无效时立即返回,不调用 `UserSession.login`。 +- 手机号和密码各自维护字段错误,允许同时展示,并聚焦第一个无效字段。 +- 修改任一登录凭据会清除旧的接口级错误;修改对应字段会清除该字段错误。 +- 密码框回车和“安全登录”按钮共用 `_login`,密码内容不做裁剪或长度变换。 + +### 工作人员端登录页 + +- 与用户端采用相同的校验、清错和聚焦规则。 +- 密码框新增回车提交,并与“进入工作台”按钮共用 `_login`。 +- 继续调用既有 `StaffSession.login`,不改变会话及接口协议。 + +### 登录页组件测试 + +- 使用记录型会话替身统计登录调用,证明空密码时调用次数为零。 +- 覆盖双字段错误、合法手机号配空密码、首个错误字段聚焦、对应错误清除、回车提交及密码原样传递。 + +## 4. 变更记录 + +### v1.0(2026-09-04) + +- 两个 App 新增密码必填字段错误“请输入密码”。 +- 两个 App 统一双字段校验、首错聚焦和凭据编辑清错行为。 +- 工作人员端补齐密码框回车登录。 +- 新增两端登录页回归测试,不改变后端登录错误码和既有会话接口。 + +## 5. 维护指南 + +- 后续新增登录入口时,应复用同一提交函数,禁止绕过字段校验直接调用会话登录方法。 +- 登录密码是精确凭据,不应使用 `trim`、大小写转换或注册阶段的最短长度规则处理。 +- 若未来支持验证码登录,应按登录模式分别校验密码或验证码,避免把两种凭据同时设为必填。 +- 修改交互后至少运行两端 `flutter analyze --no-pub`、登录页定向测试和完整测试。 + +## 6. 已知边界 + +- 本次仅在客户端阻止空密码请求,后端仍维持现有统一认证失败语义。 +- 本次不修改手机号规则、密码注册规则、忘记密码流程或验证码登录能力。 +- 非空但错误的密码仍由后端统一返回认证失败,客户端不会推断账户是否存在。