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:
@@ -95,6 +95,10 @@ abstract class AppText {
|
|||||||
String get devicesSub;
|
String get devicesSub;
|
||||||
String get thisDevice;
|
String get thisDevice;
|
||||||
String get remove;
|
String get remove;
|
||||||
|
// 设备数超上限挡板(#16):%s = 套餐设备上限数
|
||||||
|
String get deviceLimitTitle;
|
||||||
|
String get deviceLimitDesc; // "你的套餐最多 %s 台设备,移除一台以继续。"
|
||||||
|
String get deviceLimitRemoveOldest;
|
||||||
// 设备管理(P6):在线状态 / 操作 / 危险确认
|
// 设备管理(P6):在线状态 / 操作 / 危险确认
|
||||||
String get devOnline;
|
String get devOnline;
|
||||||
String get devOffline;
|
String get devOffline;
|
||||||
|
|||||||
@@ -130,9 +130,15 @@ class StringsEn extends AppText {
|
|||||||
@override
|
@override
|
||||||
String get accChange => 'Change';
|
String get accChange => 'Change';
|
||||||
@override
|
@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';
|
String get myDevices => 'My devices';
|
||||||
@override
|
@override
|
||||||
String get devicesSub => 'Up to 5 devices on PRO';
|
String get devicesSub => 'Up to 3 devices on PRO';
|
||||||
@override
|
@override
|
||||||
String get thisDevice => 'This device';
|
String get thisDevice => 'This device';
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -129,9 +129,15 @@ class StringsZh extends AppText {
|
|||||||
@override
|
@override
|
||||||
String get accChange => '修改';
|
String get accChange => '修改';
|
||||||
@override
|
@override
|
||||||
|
String get deviceLimitTitle => '设备数已达上限';
|
||||||
|
@override
|
||||||
|
String get deviceLimitDesc => '你的套餐最多 %s 台设备,请移除一台以继续。';
|
||||||
|
@override
|
||||||
|
String get deviceLimitRemoveOldest => '移除最久未用';
|
||||||
|
@override
|
||||||
String get myDevices => '我的设备';
|
String get myDevices => '我的设备';
|
||||||
@override
|
@override
|
||||||
String get devicesSub => 'PRO 套餐最多 5 台设备同时在线';
|
String get devicesSub => 'PRO 套餐最多 3 台设备同时在线';
|
||||||
@override
|
@override
|
||||||
String get thisDevice => '本机';
|
String get thisDevice => '本机';
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import 'state/connection_provider.dart';
|
|||||||
import 'state/nodes_provider.dart';
|
import 'state/nodes_provider.dart';
|
||||||
import 'state/settings_provider.dart';
|
import 'state/settings_provider.dart';
|
||||||
import 'system_tray.dart';
|
import 'system_tray.dart';
|
||||||
|
import 'screens/device_limit_screen.dart';
|
||||||
import 'widgets/auth_screen.dart';
|
import 'widgets/auth_screen.dart';
|
||||||
import 'widgets/onboarding_screen.dart';
|
import 'widgets/onboarding_screen.dart';
|
||||||
import 'widgets/pangolin_logo.dart';
|
import 'widgets/pangolin_logo.dart';
|
||||||
@@ -272,6 +273,11 @@ class _RootFlowState extends ConsumerState<RootFlow> {
|
|||||||
if (!auth.isLoggedIn) {
|
if (!auth.isLoggedIn) {
|
||||||
return AuthScreen(t: t, onDone: () { _afterAuth(); });
|
return AuthScreen(t: t, onDone: () { _afterAuth(); });
|
||||||
}
|
}
|
||||||
|
// 设备数超上限挡板(#16):登录成功但活跃设备超套餐上限 → 先移除设备降到上限内。
|
||||||
|
final deviceLimit = ref.watch(deviceLimitProvider);
|
||||||
|
if (deviceLimit != null) {
|
||||||
|
return DeviceLimitScreen(t: t, limit: deviceLimit);
|
||||||
|
}
|
||||||
if (_forceOnboarding) {
|
if (_forceOnboarding) {
|
||||||
return OnboardingScreen(t: t, onDone: () { _afterOnboarding(); });
|
return OnboardingScreen(t: t, onDone: () { _afterOnboarding(); });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} 天前';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,14 +26,63 @@ class AuthApiException implements Exception {
|
|||||||
|
|
||||||
/// 登录 / 注册成功后返回的 JWT 令牌对。
|
/// 登录 / 注册成功后返回的 JWT 令牌对。
|
||||||
class AuthTokens {
|
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 accessToken;
|
||||||
final String refreshToken;
|
final String refreshToken;
|
||||||
|
|
||||||
|
/// 非 null 表示登录成功但账户活跃设备数超套餐上限——登录仍生效(token 已发),
|
||||||
|
/// 客户端须先让用户移除设备(降到上限内)才进主界面。见 device_limit 挡板。
|
||||||
|
final DeviceLimit? deviceLimit;
|
||||||
|
|
||||||
factory AuthTokens.fromJson(Map<String, dynamic> m) => AuthTokens(
|
factory AuthTokens.fromJson(Map<String, dynamic> m) => AuthTokens(
|
||||||
accessToken: m['access_token'] as String? ?? '',
|
accessToken: m['access_token'] as String? ?? '',
|
||||||
refreshToken: m['refresh_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?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
/// 可在测试中 override,注入 stub TokenStore(避免 FlutterSecureStorage 平台依赖)。
|
/// 可在测试中 override,注入 stub TokenStore(避免 FlutterSecureStorage 平台依赖)。
|
||||||
final tokenStoreProvider = Provider<TokenStore>((_) => const TokenStore());
|
final tokenStoreProvider = Provider<TokenStore>((_) => const TokenStore());
|
||||||
|
|
||||||
|
/// 登录时活跃设备超套餐上限的信号(来自登录响应 device_limit)。非 null → 顶层路由
|
||||||
|
/// 挡在「移除设备」页(登录已成功、token 已发,但先降到上限内才进主界面)。用户在
|
||||||
|
/// DeviceLimitScreen 移除设备降到上限内后置回 null,放行进主界面;登出时也清空。
|
||||||
|
final deviceLimitProvider = StateProvider<DeviceLimit?>((_) => null);
|
||||||
|
|
||||||
final authProvider =
|
final authProvider =
|
||||||
StateNotifierProvider<AuthNotifier, AuthState>(
|
StateNotifierProvider<AuthNotifier, AuthState>(
|
||||||
(ref) => AuthNotifier(ref.watch(tokenStoreProvider)),
|
(ref) => AuthNotifier(ref.watch(tokenStoreProvider)),
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
|||||||
);
|
);
|
||||||
await ref.read(tokenStoreProvider).saveLastEmail(_email.text.trim());
|
await ref.read(tokenStoreProvider).saveLastEmail(_email.text.trim());
|
||||||
await ref.read(authProvider.notifier).saveTokens(tokens);
|
await ref.read(authProvider.notifier).saveTokens(tokens);
|
||||||
|
// 超限信号(非 null 时顶层路由挡到「移除设备」页;null 清除旧状态)。
|
||||||
|
ref.read(deviceLimitProvider.notifier).state = tokens.deviceLimit;
|
||||||
if (mounted) widget.onDone();
|
if (mounted) widget.onDone();
|
||||||
} on AuthApiException catch (e) {
|
} on AuthApiException catch (e) {
|
||||||
if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; });
|
if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; });
|
||||||
|
|||||||
Reference in New Issue
Block a user