feat(client): 设备数超限挡板 DeviceLimitScreen(#16 前端)

登录响应带 device_limit 时,顶层路由把用户挡在「移除设备」页,降到上限内才进主界面。

- auth_api.dart:AuthTokens 解析 device_limit;新增 DeviceLimit/DeviceBrief 模型
- auth_provider.dart:deviceLimitProvider(挡板状态);auth_screen 登录后置入
- main.dart:isLoggedIn 后若 deviceLimit!=null → DeviceLimitScreen(挡 HomeShell)
- screens/device_limit_screen.dart(新):设备列表(本机标注不可删)+ 每台移除
  + 一键「移除最久未用」+ 退出登录;降到上限内自动清挡板放行(复用 devicesProvider.remove)
- l10n:deviceLimitTitle/Desc/RemoveOldest(zh/en);顺带修 devicesSub 文案 5→3 台

analyze 干净;client 全量测试 161 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-01 19:25:14 +08:00
parent 6bac7fd2f0
commit 34d980d875
8 changed files with 282 additions and 3 deletions
+50 -1
View File
@@ -26,14 +26,63 @@ class AuthApiException implements Exception {
/// 登录 / 注册成功后返回的 JWT 令牌对。
class AuthTokens {
const AuthTokens({required this.accessToken, required this.refreshToken});
const AuthTokens({
required this.accessToken,
required this.refreshToken,
this.deviceLimit,
});
final String accessToken;
final String refreshToken;
/// 非 null 表示登录成功但账户活跃设备数超套餐上限——登录仍生效(token 已发),
/// 客户端须先让用户移除设备(降到上限内)才进主界面。见 device_limit 挡板。
final DeviceLimit? deviceLimit;
factory AuthTokens.fromJson(Map<String, dynamic> m) => AuthTokens(
accessToken: m['access_token'] as String? ?? '',
refreshToken: m['refresh_token'] as String? ?? '',
deviceLimit: m['device_limit'] is Map
? DeviceLimit.fromJson(Map<String, dynamic>.from(m['device_limit'] as Map))
: null,
);
}
/// 超限信号:当前套餐上限 + 账户活跃设备列表(供「移除设备」页展示)。
class DeviceLimit {
const DeviceLimit({required this.maxDevices, required this.devices});
final int maxDevices;
final List<DeviceBrief> devices;
factory DeviceLimit.fromJson(Map<String, dynamic> m) => DeviceLimit(
maxDevices: (m['max_devices'] as num?)?.toInt() ?? 0,
devices: ((m['devices'] as List?) ?? const [])
.whereType<Map>()
.map((e) => DeviceBrief.fromJson(Map<String, dynamic>.from(e)))
.toList(),
);
}
/// 超限页用的精简设备视图。
class DeviceBrief {
const DeviceBrief({
required this.uuid,
required this.name,
required this.platform,
this.lastSeen,
});
final String uuid;
final String name;
final String platform;
final String? lastSeen; // RFC 3339 UTC
factory DeviceBrief.fromJson(Map<String, dynamic> m) => DeviceBrief(
uuid: m['uuid'] as String? ?? '',
name: m['name'] as String? ?? '',
platform: m['platform'] as String? ?? '',
lastSeen: m['last_seen'] as String?,
);
}