- 将 ApiClient 升级为全局单例,支持完整账号生命周期(登录/注册/刷新/校验/退出) - 新增 TokenResponse / VerifyResponse / BindingItem 数据模型 - 新增 SplashPage 启动登录态检查与 LoginPage 独立登录页 - 新增被控端绑定列表展示、配对码兑换及设备选择功能 - 新增全局 401 拦截器,统一处理令牌失效并跳转登录 - 优化令牌存储策略,对齐 Android 端 TokenStore 行为 - 重构主页面 UI,移除内嵌登录对话框,改用独立页面流程
215 lines
7.2 KiB
Dart
215 lines
7.2 KiB
Dart
import 'package:flutter/cupertino.dart';
|
||
|
||
import '../api/api_client.dart';
|
||
|
||
/// 独立登录页,对应 Android 端 `activity/login/LoginActivity`。
|
||
///
|
||
/// 行为与 Android 端保持一致:
|
||
/// - 用户名 / 密码双输入,均非空才允许提交;
|
||
/// - 支持注册(`POST /api/auth/register`),注册成功后停留在登录页提示登录;
|
||
/// - 登录成功(`POST /api/auth/login`)后持久化令牌并回显用户名,
|
||
/// 通过命名路由 `/home` 替换栈顶进入主页;
|
||
/// - 提交过程中禁用输入与按钮,展示加载指示。
|
||
class LoginPage extends StatefulWidget {
|
||
const LoginPage({super.key, required this.apiClient, this.message});
|
||
|
||
final ApiClient apiClient;
|
||
|
||
/// 从主页因登录失效跳转过来时展示的提示文案。
|
||
final String? message;
|
||
|
||
@override
|
||
State<LoginPage> createState() => _LoginPageState();
|
||
}
|
||
|
||
class _LoginPageState extends State<LoginPage> {
|
||
final _usernameController = TextEditingController();
|
||
final _passwordController = TextEditingController();
|
||
final _passwordFocus = FocusNode();
|
||
|
||
bool _busy = false;
|
||
String? _error;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_error = widget.message;
|
||
// 回显上次登录的用户名(对应 LoginViewModel.lastUsername())。
|
||
widget.apiClient.getUsername().then((name) {
|
||
if (mounted && name != null && name.isNotEmpty) {
|
||
_usernameController.text = name;
|
||
}
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_usernameController.dispose();
|
||
_passwordController.dispose();
|
||
_passwordFocus.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
/// 读取并校验输入;返回 null 表示校验未通过(错误已展示)。
|
||
({String username, String password})? _readInput() {
|
||
final username = _usernameController.text.trim();
|
||
final password = _passwordController.text.trim();
|
||
if (username.isEmpty || password.isEmpty) {
|
||
setState(() => _error = '请输入用户名和密码');
|
||
return null;
|
||
}
|
||
return (username: username, password: password);
|
||
}
|
||
|
||
Future<void> _login() async {
|
||
final input = _readInput();
|
||
if (input == null) return;
|
||
|
||
setState(() {
|
||
_busy = true;
|
||
_error = null;
|
||
});
|
||
try {
|
||
await widget.apiClient.login(input.username, input.password);
|
||
if (!mounted) return;
|
||
Navigator.of(context).pushReplacementNamed('/home');
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_busy = false;
|
||
_error = _describe(e, '登录失败');
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _register() async {
|
||
final input = _readInput();
|
||
if (input == null) return;
|
||
|
||
setState(() {
|
||
_busy = true;
|
||
_error = null;
|
||
});
|
||
try {
|
||
await widget.apiClient.register(input.username, input.password);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_busy = false;
|
||
_error = '注册成功,请登录';
|
||
});
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_busy = false;
|
||
_error = _describe(e, '注册失败');
|
||
});
|
||
}
|
||
}
|
||
|
||
/// 优先展示服务端返回的错误文案,其余异常回落到通用提示。
|
||
String _describe(Object e, String fallback) {
|
||
if (e is ApiException) return e.code;
|
||
return '$fallback:$e';
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return CupertinoPageScaffold(
|
||
navigationBar: const CupertinoNavigationBar(middle: Text('登录')),
|
||
child: SafeArea(
|
||
child: Center(
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 420),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
const Icon(
|
||
CupertinoIcons.device_phone_portrait,
|
||
size: 56,
|
||
color: CupertinoColors.activeBlue,
|
||
),
|
||
const SizedBox(height: 16),
|
||
const Text(
|
||
'WebRTC 控制端',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||
),
|
||
const SizedBox(height: 8),
|
||
const Text(
|
||
'登录后可连接已绑定的被控设备',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
color: CupertinoColors.systemGrey,
|
||
),
|
||
),
|
||
const SizedBox(height: 32),
|
||
CupertinoTextField(
|
||
controller: _usernameController,
|
||
enabled: !_busy,
|
||
placeholder: '用户名',
|
||
prefix: const Padding(
|
||
padding: EdgeInsets.only(left: 12),
|
||
child: Icon(CupertinoIcons.person,
|
||
size: 20, color: CupertinoColors.systemGrey),
|
||
),
|
||
textInputAction: TextInputAction.next,
|
||
autocorrect: false,
|
||
onSubmitted: (_) => _passwordFocus.requestFocus(),
|
||
padding: const EdgeInsets.symmetric(
|
||
vertical: 14, horizontal: 12),
|
||
),
|
||
const SizedBox(height: 12),
|
||
CupertinoTextField(
|
||
controller: _passwordController,
|
||
focusNode: _passwordFocus,
|
||
enabled: !_busy,
|
||
placeholder: '密码',
|
||
obscureText: true,
|
||
prefix: const Padding(
|
||
padding: EdgeInsets.only(left: 12),
|
||
child: Icon(CupertinoIcons.lock,
|
||
size: 20, color: CupertinoColors.systemGrey),
|
||
),
|
||
textInputAction: TextInputAction.done,
|
||
onSubmitted: (_) => _busy ? null : _login(),
|
||
padding: const EdgeInsets.symmetric(
|
||
vertical: 14, horizontal: 12),
|
||
),
|
||
if (_error != null) ...[
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
_error!,
|
||
textAlign: TextAlign.center,
|
||
style: const TextStyle(
|
||
fontSize: 13,
|
||
color: CupertinoColors.destructiveRed,
|
||
),
|
||
),
|
||
],
|
||
const SizedBox(height: 24),
|
||
CupertinoButton.filled(
|
||
onPressed: _busy ? null : _login,
|
||
child: _busy
|
||
? const CupertinoActivityIndicator(
|
||
color: CupertinoColors.white)
|
||
: const Text('登录'),
|
||
),
|
||
const SizedBox(height: 8),
|
||
CupertinoButton(
|
||
onPressed: _busy ? null : _register,
|
||
child: const Text('注册新账号'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|