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
+4
View File
@@ -95,6 +95,10 @@ abstract class AppText {
String get devicesSub;
String get thisDevice;
String get remove;
// 设备数超上限挡板(#16):%s = 套餐设备上限数
String get deviceLimitTitle;
String get deviceLimitDesc; // "你的套餐最多 %s 台设备,移除一台以继续。"
String get deviceLimitRemoveOldest;
// 设备管理(P6):在线状态 / 操作 / 危险确认
String get devOnline;
String get devOffline;
+7 -1
View File
@@ -130,9 +130,15 @@ class StringsEn extends AppText {
@override
String get accChange => 'Change';
@override
String get deviceLimitTitle => 'Device limit reached';
@override
String get deviceLimitDesc => 'Your plan allows up to %s devices. Remove one to continue.';
@override
String get deviceLimitRemoveOldest => 'Remove least recently used';
@override
String get myDevices => 'My devices';
@override
String get devicesSub => 'Up to 5 devices on PRO';
String get devicesSub => 'Up to 3 devices on PRO';
@override
String get thisDevice => 'This device';
@override
+7 -1
View File
@@ -129,9 +129,15 @@ class StringsZh extends AppText {
@override
String get accChange => '修改';
@override
String get deviceLimitTitle => '设备数已达上限';
@override
String get deviceLimitDesc => '你的套餐最多 %s 台设备,请移除一台以继续。';
@override
String get deviceLimitRemoveOldest => '移除最久未用';
@override
String get myDevices => '我的设备';
@override
String get devicesSub => 'PRO 套餐最多 5 台设备同时在线';
String get devicesSub => 'PRO 套餐最多 3 台设备同时在线';
@override
String get thisDevice => '本机';
@override
+6
View File
@@ -22,6 +22,7 @@ import 'state/connection_provider.dart';
import 'state/nodes_provider.dart';
import 'state/settings_provider.dart';
import 'system_tray.dart';
import 'screens/device_limit_screen.dart';
import 'widgets/auth_screen.dart';
import 'widgets/onboarding_screen.dart';
import 'widgets/pangolin_logo.dart';
@@ -272,6 +273,11 @@ class _RootFlowState extends ConsumerState<RootFlow> {
if (!auth.isLoggedIn) {
return AuthScreen(t: t, onDone: () { _afterAuth(); });
}
// 设备数超上限挡板(#16):登录成功但活跃设备超套餐上限 → 先移除设备降到上限内。
final deviceLimit = ref.watch(deviceLimitProvider);
if (deviceLimit != null) {
return DeviceLimitScreen(t: t, limit: deviceLimit);
}
if (_forceOnboarding) {
return OnboardingScreen(t: t, onDone: () { _afterOnboarding(); });
}
+201
View File
@@ -0,0 +1,201 @@
// device_limit_screen.dart — 设备数超上限挡板(#16)。
//
// 登录成功但账户活跃设备数超套餐上限时,顶层路由把用户挡在此页(登录已生效、token
// 已发,但先移除设备降到上限内才进主界面)。降到上限内后清除 deviceLimitProvider 放行。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../l10n/app_text.dart';
import '../models/device.dart';
import '../pangolin_theme.dart';
import '../services/auth_api.dart';
import '../services/device_identity.dart';
import '../state/account_providers.dart';
import '../state/auth_provider.dart';
class DeviceLimitScreen extends ConsumerStatefulWidget {
const DeviceLimitScreen({super.key, required this.t, required this.limit});
final AppText t;
final DeviceLimit limit;
@override
ConsumerState<DeviceLimitScreen> createState() => _DeviceLimitScreenState();
}
class _DeviceLimitScreenState extends ConsumerState<DeviceLimitScreen> {
String? _busyUuid; // 正在移除的设备 uuid
Future<void> _remove(String uuid) async {
setState(() => _busyUuid = uuid);
try {
await ref.read(devicesProvider.notifier).remove(uuid);
} catch (_) {
// 失败保持在本页;build 会据最新列表决定是否放行。
} finally {
if (mounted) setState(() => _busyUuid = null);
}
}
void _removeOldest(List<Device> devices, String currentId) {
final others = devices.where((d) => d.uuid != currentId).toList()
..sort((a, b) {
final ta = a.lastSeen?.millisecondsSinceEpoch ?? 0;
final tb = b.lastSeen?.millisecondsSinceEpoch ?? 0;
return ta.compareTo(tb); // 最久未用在前
});
if (others.isNotEmpty) _remove(others.first.uuid);
}
Future<void> _logout() async {
ref.read(deviceLimitProvider.notifier).state = null;
await ref.read(authProvider.notifier).logout();
}
@override
Widget build(BuildContext context) {
final t = widget.t;
final c = context.pangolin;
final max = widget.limit.maxDevices;
final currentId = ref.watch(localDeviceIdProvider).valueOrNull ?? '';
final devicesAsync = ref.watch(devicesProvider);
final devices = devicesAsync.valueOrNull ?? const <Device>[];
// 已降到上限内 → 清除挡板放行(下一帧改 provider,避免 build 内写状态)。
if (devicesAsync.hasValue && devices.length <= max) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && ref.read(deviceLimitProvider) != null) {
ref.read(deviceLimitProvider.notifier).state = null;
}
});
}
final removableCount = devices.where((d) => d.uuid != currentId).length;
return Scaffold(
backgroundColor: c.bg,
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 32),
Icon(Icons.devices_other_rounded, size: 40, color: c.accent),
const SizedBox(height: 14),
Text(t.deviceLimitTitle,
textAlign: TextAlign.center,
style: TextStyle(color: c.fg1, fontSize: 20, fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
Text(t.deviceLimitDesc.replaceFirst('%s', '$max'),
textAlign: TextAlign.center,
style: TextStyle(color: c.fg2, fontSize: 14, height: 1.5)),
const SizedBox(height: 18),
if (removableCount > 0)
OutlinedButton.icon(
onPressed: _busyUuid != null ? null : () => _removeOldest(devices, currentId),
icon: const Icon(Icons.history_rounded, size: 18),
label: Text(t.deviceLimitRemoveOldest),
style: OutlinedButton.styleFrom(
foregroundColor: c.accent,
side: BorderSide(color: c.accentBorder),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
const SizedBox(height: 14),
Expanded(
child: !devicesAsync.hasValue
? Center(child: CircularProgressIndicator(color: c.accent, strokeWidth: 2.4))
: ListView.separated(
itemCount: devices.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (_, i) => _deviceTile(c, t, devices[i], currentId),
),
),
const SizedBox(height: 8),
TextButton(
onPressed: _busyUuid != null ? null : _logout,
style: TextButton.styleFrom(foregroundColor: c.fg3),
child: Text(t.signOut),
),
const SizedBox(height: 12),
],
),
),
),
),
),
);
}
Widget _deviceTile(PangolinScheme c, AppText t, Device d, String currentId) {
final isCurrent = d.uuid == currentId;
final busy = _busyUuid == d.uuid;
final sub = d.online
? t.devOnline
: (d.lastSeen != null ? '${t.devLastOnline} ${_rel(d.lastSeen!)}' : t.devOffline);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: c.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: c.border),
),
child: Row(
children: [
Icon(_platformIcon(d.platformKind), size: 22, color: c.fg2),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(d.name.isEmpty ? d.platformKind.label : d.name,
maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(color: c.fg1, fontSize: 15, fontWeight: FontWeight.w600)),
const SizedBox(height: 2),
Text(sub, style: TextStyle(color: c.fg2, fontSize: 12)),
],
),
),
const SizedBox(width: 10),
if (isCurrent)
Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
decoration: BoxDecoration(
color: c.accentSubtle,
borderRadius: BorderRadius.circular(999),
),
child: Text(t.thisDevice, style: TextStyle(color: c.accent, fontSize: 12, fontWeight: FontWeight.w600)),
)
else if (busy)
SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: c.danger, strokeWidth: 2.2))
else
TextButton(
onPressed: _busyUuid != null ? null : () => _remove(d.uuid),
style: TextButton.styleFrom(foregroundColor: c.danger),
child: Text(t.remove),
),
],
),
);
}
static IconData _platformIcon(DevicePlatform p) => switch (p) {
DevicePlatform.ios || DevicePlatform.android => Icons.smartphone_rounded,
DevicePlatform.windows || DevicePlatform.linux => Icons.computer_rounded,
DevicePlatform.macos => Icons.laptop_mac_rounded,
DevicePlatform.unknown => Icons.devices_other_rounded,
};
// 相对时间(粗粒度):x 分钟/小时/天前。
static String _rel(DateTime t) {
final d = DateTime.now().toUtc().difference(t.toUtc());
if (d.inMinutes < 1) return '刚刚';
if (d.inMinutes < 60) return '${d.inMinutes} 分钟前';
if (d.inHours < 24) return '${d.inHours} 小时前';
return '${d.inDays} 天前';
}
}
+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?,
);
}
+5
View File
@@ -95,6 +95,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
/// 可在测试中 override,注入 stub TokenStore(避免 FlutterSecureStorage 平台依赖)。
final tokenStoreProvider = Provider<TokenStore>((_) => const TokenStore());
/// 登录时活跃设备超套餐上限的信号(来自登录响应 device_limit)。非 null → 顶层路由
/// 挡在「移除设备」页(登录已成功、token 已发,但先降到上限内才进主界面)。用户在
/// DeviceLimitScreen 移除设备降到上限内后置回 null,放行进主界面;登出时也清空。
final deviceLimitProvider = StateProvider<DeviceLimit?>((_) => null);
final authProvider =
StateNotifierProvider<AuthNotifier, AuthState>(
(ref) => AuthNotifier(ref.watch(tokenStoreProvider)),
+2
View File
@@ -128,6 +128,8 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
);
await ref.read(tokenStoreProvider).saveLastEmail(_email.text.trim());
await ref.read(authProvider.notifier).saveTokens(tokens);
// 超限信号(非 null 时顶层路由挡到「移除设备」页;null 清除旧状态)。
ref.read(deviceLimitProvider.notifier).state = tokens.deviceLimit;
if (mounted) widget.onDone();
} on AuthApiException catch (e) {
if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; });