refactor(flutter): 重构账号模块并新增配对与独立登录页面

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

View File

@@ -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<String, dynamic> 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<BindingItem> listFrom(dynamic raw) {
if (raw is! List) return const [];
return raw
.whereType<Map>()
.map((e) => BindingItem.fromJson(Map<String, dynamic>.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,
);
}

View File

@@ -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<String, dynamic> json) => TokenResponse(
accessToken: (json['accessToken'] ?? '') as String,
refreshToken: json['refreshToken'] as String?,
tokenType: json['tokenType'] as String?,
expiresIn: (json['expiresIn'] as num?)?.toInt() ?? 0,
);
}

View File

@@ -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<String, dynamic> 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?,
);
}

View File

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

View File

@@ -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<SplashPage> createState() => _SplashPageState();
}
class _SplashPageState extends State<SplashPage> {
@override
void initState() {
super.initState();
_bootstrap();
}
Future<void> _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(),
],
),
),
);
}
}