Files
tongxunruanjian/mobile/lib/screens/login_screen.dart
T
编码工程师andmultica-agent f1fe4a3928 feat(mobile,deploy): 接入安卓极光与华为通道并准备公网部署配置
客户端默认指向 jxd.jinniu.ink 的 HTTPS/WSS;登录后把 OpenIM userID 设为极光 alias。
华为 agconnect-services.json 不伪造、不入库。Master Secret 只从服务器文件注入。

Co-authored-by: multica-agent <github@multica.ai>
2026-08-30 21:32:58 +08:00

233 lines
8.3 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../config.dart';
import '../services/auth_api.dart';
import '../services/call_service.dart';
import '../services/im_service.dart';
import '../services/push_service.dart';
import '../theme.dart';
import 'home_screen.dart';
/// 登录页:工号 + 密码,登录中按钮转菊花,失败在按钮下方红字提示。
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
/// 本地保存登录凭证的键(自动登录用)
static const String keyUserID = 'login_userID';
static const String keyToken = 'login_token';
static const String keyNickname = 'login_nickname';
/// 保存登录凭证
static Future<void> saveCredential(String userID, String token, String nickname) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(keyUserID, userID);
await prefs.setString(keyToken, token);
await prefs.setString(keyNickname, nickname);
}
/// 清除登录凭证(退出登录 / token 失效时)
static Future<void> clearCredential() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(keyUserID);
await prefs.remove(keyToken);
await prefs.remove(keyNickname);
}
/// 读取已保存的凭证
static Future<(String, String)?> readCredential() async {
final prefs = await SharedPreferences.getInstance();
final userID = prefs.getString(keyUserID);
final token = prefs.getString(keyToken);
if (userID == null || userID.isEmpty || token == null || token.isEmpty) return null;
return (userID, token);
}
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _idCtrl = TextEditingController();
final TextEditingController _pwdCtrl = TextEditingController();
final AuthApi _authApi = AuthApi();
bool _logging = false;
String? _error;
@override
void dispose() {
_idCtrl.dispose();
_pwdCtrl.dispose();
super.dispose();
}
Future<void> _login() async {
final staffNo = _idCtrl.text.trim();
final password = _pwdCtrl.text;
if (staffNo.isEmpty || password.isEmpty) {
setState(() => _error = '请输入工号和密码');
return;
}
setState(() {
_logging = true;
_error = null;
});
try {
// 1. 公司账号登录,拿 OpenIM 的 userID/imToken
final result = await _authApi.login(
staffNo: staffNo,
password: password,
platformID: IMService.instance.platformID,
);
// 2. 初始化消息组件(分两步超时,方便定位卡在哪一步;老手机首次建库可能偏慢,放宽到 30 秒)
await IMService.instance.init().timeout(
const Duration(seconds: 30),
onTimeout: () => throw AuthException('消息组件初始化超时'),
);
// 3. 登录 OpenIM SDK
await IMService.instance.login(userID: result.userID, token: result.token).timeout(
const Duration(seconds: 20),
onTimeout: () => throw AuthException('连不上消息服务器,请确认手机连着公司内网 WiFi'),
);
// 3. 启动通话信令监听
CallService.instance.start();
// 4. 极光 alias = OpenIM userID,供服务端离线推送
await PushService.instance.bindUser(result.userID);
// 5. 存凭证用于下次自动登录
await LoginScreen.saveCredential(result.userID, result.token, result.nickname);
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const HomeScreen()),
);
} on AuthException catch (e) {
await _fail(e.message);
} catch (_) {
await _fail('登录出错了,请稍后再试');
} finally {
if (mounted) setState(() => _logging = false);
}
}
/// 登录失败:在提示下方附上服务器自检结果和 SDK 日志结尾,方便远程定位是哪一段不通
Future<void> _fail(String msg) async {
final parts = [msg];
try {
parts.add(await _diagnoseServer());
} catch (_) {
// 自检本身失败不影响原提示
}
final sdkErr = IMService.instance.lastConnectError;
if (sdkErr != null && sdkErr.isNotEmpty) parts.add('消息组件报告:$sdkErr');
parts.add('初始化进行到哪一步:${IMService.instance.lastInitStep}');
if (mounted) setState(() => _error = parts.join('\n'));
// SDK 日志可能较大,异步读完后再补一行提示用户拍照
try {
final tail = await IMService.instance.sdkLogTail();
if (tail.isNotEmpty && mounted) {
setState(() => _error = '${parts.join('\n')}\n—— SDK 日志结尾(拍照发给我)——\n$tail');
}
} catch (_) {
// 读日志失败不影响原提示
}
}
/// 逐个探测三个服务端口:能拿到任何 HTTP 响应就算通
Future<String> _diagnoseServer() async {
final dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
));
Future<String> probe(String label, String url) async {
try {
await dio.get(url);
return '$label通';
} on DioException catch (e) {
return e.response != null ? '$label通' : '$label不通';
} catch (_) {
return '$label不通';
}
}
final results = await Future.wait([
probe('账号服务', '$authApiBase/api/health'),
probe('消息接口', '$apiAddr/'),
probe('消息长连接', wsProbeUrl),
]);
return '自检:${results.join('')}';
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.pageBg,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: AppGap.x8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 96),
const Text(
'畅联',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 32, fontWeight: FontWeight.w600, color: AppColors.primary),
),
const SizedBox(height: AppGap.x12),
_input(_idCtrl, '请输入工号', Icons.person_outline, false),
const SizedBox(height: AppGap.x4),
_input(_pwdCtrl, '请输入密码', Icons.lock_outline, true),
const SizedBox(height: AppGap.x6),
SizedBox(
height: 48,
child: FilledButton(
onPressed: _logging ? null : _login,
style: FilledButton.styleFrom(
backgroundColor: AppColors.primary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: _logging
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text('登录', style: TextStyle(fontSize: AppFont.body)),
),
),
if (_error != null) ...[
const SizedBox(height: AppGap.x3),
Text(
_error!,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: AppFont.sub, color: AppColors.danger),
),
],
],
),
),
),
);
}
Widget _input(TextEditingController ctrl, String hint, IconData icon, bool obscure) {
return TextField(
controller: ctrl,
obscureText: obscure,
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(color: AppColors.textSecondary, fontSize: AppFont.body),
prefixIcon: Icon(icon, color: AppColors.textSecondary),
filled: true,
fillColor: AppColors.searchBg,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(vertical: AppGap.x4),
),
);
}
}