From 34d980d875a24b02fb528f425964a62f162039b0 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Wed, 1 Jul 2026 19:25:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E8=AE=BE=E5=A4=87=E6=95=B0?= =?UTF-8?q?=E8=B6=85=E9=99=90=E6=8C=A1=E6=9D=BF=20DeviceLimitScreen(#16=20?= =?UTF-8?q?=E5=89=8D=E7=AB=AF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 登录响应带 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 --- client/lib/l10n/app_text.dart | 4 + client/lib/l10n/strings_en.dart | 8 +- client/lib/l10n/strings_zh.dart | 8 +- client/lib/main.dart | 6 + client/lib/screens/device_limit_screen.dart | 201 ++++++++++++++++++++ client/lib/services/auth_api.dart | 51 ++++- client/lib/state/auth_provider.dart | 5 + client/lib/widgets/auth_screen.dart | 2 + 8 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 client/lib/screens/device_limit_screen.dart diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index 4983839..0f41e82 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -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; diff --git a/client/lib/l10n/strings_en.dart b/client/lib/l10n/strings_en.dart index 3f25271..79a4947 100644 --- a/client/lib/l10n/strings_en.dart +++ b/client/lib/l10n/strings_en.dart @@ -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 diff --git a/client/lib/l10n/strings_zh.dart b/client/lib/l10n/strings_zh.dart index 2c5cf1e..df4d680 100644 --- a/client/lib/l10n/strings_zh.dart +++ b/client/lib/l10n/strings_zh.dart @@ -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 diff --git a/client/lib/main.dart b/client/lib/main.dart index 72c3ca8..3262114 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -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 { 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(); }); } diff --git a/client/lib/screens/device_limit_screen.dart b/client/lib/screens/device_limit_screen.dart new file mode 100644 index 0000000..6be3240 --- /dev/null +++ b/client/lib/screens/device_limit_screen.dart @@ -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 createState() => _DeviceLimitScreenState(); +} + +class _DeviceLimitScreenState extends ConsumerState { + String? _busyUuid; // 正在移除的设备 uuid + + Future _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 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 _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 []; + + // 已降到上限内 → 清除挡板放行(下一帧改 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} 天前'; + } +} diff --git a/client/lib/services/auth_api.dart b/client/lib/services/auth_api.dart index 7b15fbc..18b065e 100644 --- a/client/lib/services/auth_api.dart +++ b/client/lib/services/auth_api.dart @@ -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 m) => AuthTokens( accessToken: m['access_token'] as String? ?? '', refreshToken: m['refresh_token'] as String? ?? '', + deviceLimit: m['device_limit'] is Map + ? DeviceLimit.fromJson(Map.from(m['device_limit'] as Map)) + : null, + ); +} + +/// 超限信号:当前套餐上限 + 账户活跃设备列表(供「移除设备」页展示)。 +class DeviceLimit { + const DeviceLimit({required this.maxDevices, required this.devices}); + + final int maxDevices; + final List devices; + + factory DeviceLimit.fromJson(Map m) => DeviceLimit( + maxDevices: (m['max_devices'] as num?)?.toInt() ?? 0, + devices: ((m['devices'] as List?) ?? const []) + .whereType() + .map((e) => DeviceBrief.fromJson(Map.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 m) => DeviceBrief( + uuid: m['uuid'] as String? ?? '', + name: m['name'] as String? ?? '', + platform: m['platform'] as String? ?? '', + lastSeen: m['last_seen'] as String?, ); } diff --git a/client/lib/state/auth_provider.dart b/client/lib/state/auth_provider.dart index 6e04e4b..b23f8e3 100644 --- a/client/lib/state/auth_provider.dart +++ b/client/lib/state/auth_provider.dart @@ -95,6 +95,11 @@ class AuthNotifier extends StateNotifier { /// 可在测试中 override,注入 stub TokenStore(避免 FlutterSecureStorage 平台依赖)。 final tokenStoreProvider = Provider((_) => const TokenStore()); +/// 登录时活跃设备超套餐上限的信号(来自登录响应 device_limit)。非 null → 顶层路由 +/// 挡在「移除设备」页(登录已成功、token 已发,但先降到上限内才进主界面)。用户在 +/// DeviceLimitScreen 移除设备降到上限内后置回 null,放行进主界面;登出时也清空。 +final deviceLimitProvider = StateProvider((_) => null); + final authProvider = StateNotifierProvider( (ref) => AuthNotifier(ref.watch(tokenStoreProvider)), diff --git a/client/lib/widgets/auth_screen.dart b/client/lib/widgets/auth_screen.dart index c609c49..2d7634e 100644 --- a/client/lib/widgets/auth_screen.dart +++ b/client/lib/widgets/auth_screen.dart @@ -128,6 +128,8 @@ class _AuthScreenState extends ConsumerState ); 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; });