From bb488d216dccad75264739975bd2152f5f80c5fa Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Mon, 3 Aug 2026 16:15:59 +0800 Subject: [PATCH] =?UTF-8?q?refactor(flutter):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E6=A8=A1=E5=9D=97=E5=B9=B6=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=85=8D=E5=AF=B9=E4=B8=8E=E7=8B=AC=E7=AB=8B=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 ApiClient 升级为全局单例,支持完整账号生命周期(登录/注册/刷新/校验/退出) - 新增 TokenResponse / VerifyResponse / BindingItem 数据模型 - 新增 SplashPage 启动登录态检查与 LoginPage 独立登录页 - 新增被控端绑定列表展示、配对码兑换及设备选择功能 - 新增全局 401 拦截器,统一处理令牌失效并跳转登录 - 优化令牌存储策略,对齐 Android 端 TokenStore 行为 - 重构主页面 UI,移除内嵌登录对话框,改用独立页面流程 --- .../lib/models/binding_item.dart | 77 +++++++ .../lib/models/token_response.dart | 25 ++ .../lib/models/verify_response.dart | 39 ++++ .../lib/pages/login_page.dart | 214 ++++++++++++++++++ .../lib/pages/splash_page.dart | 67 ++++++ 5 files changed, 422 insertions(+) create mode 100644 webrtc_controller_flutter/lib/models/binding_item.dart create mode 100644 webrtc_controller_flutter/lib/models/token_response.dart create mode 100644 webrtc_controller_flutter/lib/models/verify_response.dart create mode 100644 webrtc_controller_flutter/lib/pages/login_page.dart create mode 100644 webrtc_controller_flutter/lib/pages/splash_page.dart diff --git a/webrtc_controller_flutter/lib/models/binding_item.dart b/webrtc_controller_flutter/lib/models/binding_item.dart new file mode 100644 index 0000000..16e30ac --- /dev/null +++ b/webrtc_controller_flutter/lib/models/binding_item.dart @@ -0,0 +1,77 @@ +/// 绑定关系条目,对应服务端 `ClientController#bindingView`。 +/// +/// 与 Android 端 `network/model/BindingItem` 字段保持一致。 +class BindingItem { + final String? bindingId; + + /// 被控端设备唯一标识,即连接时的目标设备 ID。 + final String deviceUid; + + final String? userId; + + /// OWNER / MEMBER。 + final String? role; + + final String? alias; + + /// ACTIVE / REVOKED。 + final String? status; + + /// 被控端当前是否在线(信令网络中存在活跃会话)。 + final bool online; + + const BindingItem({ + this.bindingId, + required this.deviceUid, + this.userId, + this.role, + this.alias, + this.status, + this.online = false, + }); + + factory BindingItem.fromJson(Map json) => BindingItem( + bindingId: json['bindingId'] as String?, + deviceUid: (json['deviceUid'] ?? json['deviceId'] ?? '').toString(), + userId: json['userId'] as String?, + role: json['role'] as String?, + alias: json['alias'] as String?, + status: json['status'] as String?, + online: json['online'] == true, + ); + + /// 将服务端返回的列表字段解析为绑定条目集合。 + static List listFrom(dynamic raw) { + if (raw is! List) return const []; + return raw + .whereType() + .map((e) => BindingItem.fromJson(Map.from(e))) + .where((e) => e.deviceUid.isNotEmpty) + .toList(); + } + + /// 是否为设备所有者。 + bool get isOwner => role == 'OWNER'; + + /// 列表展示名称:有别名时优先展示别名,否则展示设备 ID。 + String get name => + (alias != null && alias!.trim().isNotEmpty) ? alias! : deviceUid; + + /// 下拉展示文案:有别名时附带设备 ID。 + String displayName() { + if (alias != null && alias!.trim().isNotEmpty) { + return '$alias ($deviceUid)'; + } + return deviceUid; + } + + BindingItem copyWith({bool? online}) => BindingItem( + bindingId: bindingId, + deviceUid: deviceUid, + userId: userId, + role: role, + alias: alias, + status: status, + online: online ?? this.online, + ); +} diff --git a/webrtc_controller_flutter/lib/models/token_response.dart b/webrtc_controller_flutter/lib/models/token_response.dart new file mode 100644 index 0000000..ca75a4a --- /dev/null +++ b/webrtc_controller_flutter/lib/models/token_response.dart @@ -0,0 +1,25 @@ +/// 令牌响应,对应服务端 `POST /api/auth/login` 与 `POST /api/auth/refresh`。 +/// +/// 与 Android 端 `network/model/TokenResponse` 字段保持一致。 +class TokenResponse { + final String accessToken; + final String? refreshToken; + final String? tokenType; + + /// accessToken 有效期(秒)。 + final int expiresIn; + + const TokenResponse({ + required this.accessToken, + this.refreshToken, + this.tokenType, + this.expiresIn = 0, + }); + + factory TokenResponse.fromJson(Map json) => TokenResponse( + accessToken: (json['accessToken'] ?? '') as String, + refreshToken: json['refreshToken'] as String?, + tokenType: json['tokenType'] as String?, + expiresIn: (json['expiresIn'] as num?)?.toInt() ?? 0, + ); +} diff --git a/webrtc_controller_flutter/lib/models/verify_response.dart b/webrtc_controller_flutter/lib/models/verify_response.dart new file mode 100644 index 0000000..0882366 --- /dev/null +++ b/webrtc_controller_flutter/lib/models/verify_response.dart @@ -0,0 +1,39 @@ +/// 令牌校验响应,对应服务端 `GET /api/client/verify`。 +/// +/// 与 Android 端 `network/model/VerifyResponse` 字段保持一致。 +class VerifyResponse { + final bool valid; + + /// USER / DEVICE。 + final String? principalType; + final String? principalId; + final String? displayName; + + /// 过期时间(秒级时间戳)。 + final int expiresAt; + + /// 剩余有效秒数。 + final int remainingSeconds; + + final String? message; + + const VerifyResponse({ + required this.valid, + this.principalType, + this.principalId, + this.displayName, + this.expiresAt = 0, + this.remainingSeconds = 0, + this.message, + }); + + factory VerifyResponse.fromJson(Map json) => VerifyResponse( + valid: json['valid'] == true, + principalType: json['principalType'] as String?, + principalId: json['principalId'] as String?, + displayName: json['displayName'] as String?, + expiresAt: (json['expiresAt'] as num?)?.toInt() ?? 0, + remainingSeconds: (json['remainingSeconds'] as num?)?.toInt() ?? 0, + message: json['message'] as String?, + ); +} diff --git a/webrtc_controller_flutter/lib/pages/login_page.dart b/webrtc_controller_flutter/lib/pages/login_page.dart new file mode 100644 index 0000000..b88a75b --- /dev/null +++ b/webrtc_controller_flutter/lib/pages/login_page.dart @@ -0,0 +1,214 @@ +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 createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + 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 _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 _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('注册新账号'), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/webrtc_controller_flutter/lib/pages/splash_page.dart b/webrtc_controller_flutter/lib/pages/splash_page.dart new file mode 100644 index 0000000..26bbf6c --- /dev/null +++ b/webrtc_controller_flutter/lib/pages/splash_page.dart @@ -0,0 +1,67 @@ +import 'package:flutter/cupertino.dart'; + +import '../api/api_client.dart'; + +/// 启动页,对应 Android 端 `activity/splash/SplashActivity`。 +/// +/// 流程与 Android 端一致: +/// 1. 从安全存储恢复本地令牌; +/// 2. 无令牌 -> 直接进入登录页; +/// 3. 有令牌 -> 调用 `GET /api/client/verify` 校验,失效则用 refreshToken +/// 续期后再校验一次(`ApiClient.checkLoginState()`); +/// 4. 校验通过进入主页,否则清空令牌并进入登录页。 +class SplashPage extends StatefulWidget { + const SplashPage({super.key, required this.apiClient}); + + final ApiClient apiClient; + + @override + State createState() => _SplashPageState(); +} + +class _SplashPageState extends State { + @override + void initState() { + super.initState(); + _bootstrap(); + } + + Future _bootstrap() async { + await widget.apiClient.restore(); + final loggedIn = await widget.apiClient.checkLoginState(); + if (!mounted) return; + if (loggedIn) { + Navigator.of(context).pushReplacementNamed('/home'); + } else { + // 令牌已失效,清空后要求重新登录。 + await widget.apiClient.clear(); + if (!mounted) return; + Navigator.of(context).pushReplacementNamed('/login'); + } + } + + @override + Widget build(BuildContext context) { + return const CupertinoPageScaffold( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.device_phone_portrait, + size: 64, + color: CupertinoColors.activeBlue, + ), + SizedBox(height: 20), + Text( + 'WebRTC 控制端', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + SizedBox(height: 24), + CupertinoActivityIndicator(), + ], + ), + ), + ); + } +}