Files
VibeCoding/webrtc_controller_flutter/lib/pages/login_page.dart
TongTongStudio bb488d216d refactor(flutter): 重构账号模块并新增配对与独立登录页面
- 将 ApiClient 升级为全局单例,支持完整账号生命周期(登录/注册/刷新/校验/退出)
- 新增 TokenResponse / VerifyResponse / BindingItem 数据模型
- 新增 SplashPage 启动登录态检查与 LoginPage 独立登录页
- 新增被控端绑定列表展示、配对码兑换及设备选择功能
- 新增全局 401 拦截器,统一处理令牌失效并跳转登录
- 优化令牌存储策略,对齐 Android 端 TokenStore 行为
- 重构主页面 UI,移除内嵌登录对话框,改用独立页面流程
2026-08-03 16:20:11 +08:00

215 lines
7.2 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: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('注册新账号'),
),
],
),
),
),
),
),
);
}
}