完善移动端错误提示中文化

This commit is contained in:
czl231
2026-09-02 21:07:47 +08:00
parent 0f2475098f
commit dd250d80a0
10 changed files with 521 additions and 156 deletions

View File

@@ -1,7 +1,78 @@
// 功能描述:封装用户端 HTTP 请求,并将服务端错误转换为安全、可读的中文提示。
// 版本1.1.0
import 'dart:convert';
import 'package:http/http.dart' as http;
const Map<int, String> _apiErrorMessages = {
404: '记录不存在',
1001: '请求标识缺失',
1002: '登录信息缺失,请重新登录',
1003: '接口密钥缺失',
1004: '请求头参数缺失',
1101: '暂无数据',
1102: '请求解析失败',
1103: '请填写必填参数',
1104: '无权执行此操作',
1105: '服务端数据处理失败',
1106: '服务端数据处理失败',
1107: '服务内部错误,请稍后重试',
1108: '密码错误',
1110: '账号已禁用',
1111: '当前记录已停用',
1112: '记录不存在',
1301: '登录状态已失效,请重新登录',
1302: '登录状态已失效,请重新登录',
1303: '登录状态已失效,请重新登录',
1304: '登录状态已失效,请重新登录',
1305: '登录状态已失效,请重新登录',
1306: '登录状态已失效,请重新登录',
1307: '登录状态已变更,请重新登录',
1308: '登录已过期,请重新登录',
1309: '登录状态已失效,请重新登录',
1310: '登录状态已失效,请重新登录',
1311: '登录状态已失效,请重新登录',
1312: '登录状态已失效,请重新登录',
1313: '登录失败,请稍后重试',
1314: '登录状态已失效,请重新登录',
1501: '服务暂时不可用,请稍后重试',
1502: '服务暂时不可用,请稍后重试',
1503: '服务暂时不可用,请稍后重试',
1504: '服务暂时不可用,请稍后重试',
1505: '服务暂时不可用,请稍后重试',
1506: '服务暂时不可用,请稍后重试',
1507: '服务暂时不可用,请稍后重试',
1702: '操作已取消',
1703: '操作失败,请稍后重试',
1704: '请求参数错误',
1705: '请求超时,请稍后重试',
1706: '记录已存在',
1707: '无权执行此操作',
1708: '系统繁忙,请稍后重试',
1709: '当前状态不支持此操作',
1710: '操作已中止',
1711: '输入内容超出允许范围',
1712: '当前功能暂不支持',
1713: '服务暂时不可用,请稍后重试',
1714: '服务数据异常,请稍后重试',
1715: '请先登录',
};
final RegExp _chineseCharacterPattern = RegExp(r'[\u3400-\u9fff]');
/// 将服务端错误转换为中文用户提示。
///
/// [code] 是稳定错误码,[message] 是服务端原始消息;返回值不会透传未知英文技术信息。
String localizeApiErrorMessage(int code, String? message) {
final mappedMessage = _apiErrorMessages[code];
if (mappedMessage != null) return mappedMessage;
final originalMessage = message?.trim() ?? '';
if (_chineseCharacterPattern.hasMatch(originalMessage)) return originalMessage;
return '操作失败,请稍后重试';
}
/// 表示客户端可识别并可安全展示的接口异常。
class ApiException implements Exception {
const ApiException(this.code, this.message);
@@ -12,6 +83,7 @@ class ApiException implements Exception {
String toString() => message;
}
/// 负责用户端统一 HTTP 请求、鉴权头和响应解析。
class ApiClient {
ApiClient(
this._tokenProvider, {
@@ -59,18 +131,39 @@ class ApiClient {
request.headers['content-type'] = 'application/json; charset=UTF-8';
request.body = jsonEncode(body);
}
final streamed = await _client.send(request);
final response = await http.Response.fromStream(streamed);
final response = await _sendRequest(request);
return _decode(response);
}
/// 发送请求并统一处理网络连接异常。
Future<http.Response> _sendRequest(http.BaseRequest request) async {
try {
return await http.Response.fromStream(await _client.send(request));
} catch (_) {
throw const ApiException(500, '网络连接失败,请稍后重试');
}
}
/// 解析统一响应结构,并按错误码生成中文提示。
Object? _decode(http.Response response) {
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(response.statusCode, '网络请求失败(${response.statusCode}');
}
final decoded = jsonDecode(response.body);
Object? decoded;
try {
decoded = jsonDecode(response.body);
} on FormatException {
throw const ApiException(500, '服务端响应格式错误');
}
if (decoded is! Map<String, Object?>) {
throw const ApiException(500, '服务端响应格式错误');
}
final code = (decoded['code'] as num?)?.toInt() ?? 500;
if (code != 0) {
throw ApiException(code, decoded['message'] as String? ?? '操作失败');
throw ApiException(
code,
localizeApiErrorMessage(code, decoded['message'] as String?),
);
}
return decoded['details'];
}

View File

@@ -1,8 +1,12 @@
// 功能描述:提供用户端手机号密码登录界面及本地输入校验。
// 版本1.1.0
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../app/dependencies.dart';
import '../../../data/services/api_client.dart';
/// 用户端手机号密码登录页面。
class LoginPage extends StatefulWidget {
const LoginPage({required this.session, super.key});
@@ -12,11 +16,15 @@ class LoginPage extends StatefulWidget {
State<LoginPage> createState() => _LoginPageState();
}
/// 管理登录输入、提交状态和错误提示。
class _LoginPageState extends State<LoginPage> {
static final RegExp _phonePattern = RegExp(r'^1[3-9]\d{9}$');
final _phone = TextEditingController();
final _password = TextEditingController();
bool _submitting = false;
String? _error;
String? _phoneError;
bool _obscurePassword = true;
@override
@@ -26,15 +34,30 @@ class _LoginPageState extends State<LoginPage> {
super.dispose();
}
/// 校验手机号并提交登录请求。
Future<void> _login() async {
final phone = _phone.text.trim();
if (!_phonePattern.hasMatch(phone)) {
setState(() {
_phoneError = '请输入正确的11位手机号';
_error = null;
});
return;
}
setState(() {
_submitting = true;
_phoneError = null;
_error = null;
});
try {
await widget.session.login(phone: _phone.text.trim(), password: _password.text);
await widget.session.login(phone: phone, password: _password.text);
} catch (error) {
if (mounted) setState(() => _error = error.toString());
// 登录接口使用统一密码错误码,页面不区分账号是否存在,避免泄露账号状态。
final message = error is ApiException
? (error.code == 1108 ? '手机号或密码错误' : error.toString())
: '登录失败,请稍后重试';
if (mounted) setState(() => _error = message);
} finally {
if (mounted) setState(() => _submitting = false);
}
@@ -86,9 +109,13 @@ class _LoginPageState extends State<LoginPage> {
controller: _phone,
keyboardType: TextInputType.phone,
autofillHints: const [AutofillHints.telephoneNumber],
decoration: const InputDecoration(
onChanged: (_) {
if (_phoneError != null) setState(() => _phoneError = null);
},
decoration: InputDecoration(
labelText: '手机号',
prefixIcon: Icon(Icons.phone_outlined),
prefixIcon: const Icon(Icons.phone_outlined),
errorText: _phoneError,
),
),
const SizedBox(height: 14),

View File

@@ -1,9 +1,13 @@
// 功能描述:验证用户端登录页字段、手机号校验和接口错误中文化。
// 版本1.1.0
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/app/dependencies.dart';
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';
/// 验证用户端登录界面的关键交互。
void main() {
testWidgets('login page exposes phone and password fields', (tester) async {
final session = UserSession(SecureSessionStore());
@@ -13,4 +17,23 @@ void main() {
expect(find.byType(TextField), findsNWidgets(2));
expect(find.text('安全登录'), findsOneWidget);
});
testWidgets('手机号格式错误显示输入框中文提示', (tester) async {
final session = UserSession(SecureSessionStore());
await tester.pumpWidget(MaterialApp(home: LoginPage(session: session)));
await tester.enterText(find.byType(TextField).first, '+8613800138000');
await tester.tap(find.text('安全登录'));
await tester.pump();
expect(find.text('请输入正确的11位手机号'), findsOneWidget);
expect(find.text('Invalid Argument'), findsNothing);
});
test('接口错误优先按错误码中文化并屏蔽未知英文', () {
expect(localizeApiErrorMessage(1704, 'Invalid Argument'), '请求参数错误');
expect(localizeApiErrorMessage(1108, 'Password Incorrect'), '密码错误');
expect(localizeApiErrorMessage(9999, '业务处理失败'), '业务处理失败');
expect(localizeApiErrorMessage(9999, 'Unexpected Error'), '操作失败,请稍后重试');
});
}