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 thisDevice;
|
||||
String get remove;
|
||||
// 设备数超上限挡板(#16):%s = 套餐设备上限数
|
||||
String get deviceLimitTitle;
|
||||
String get deviceLimitDesc; // "你的套餐最多 %s 台设备,移除一台以继续。"
|
||||
String get deviceLimitRemoveOldest;
|
||||
// 设备管理(P6):在线状态 / 操作 / 危险确认
|
||||
String get devOnline;
|
||||
String get devOffline;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(); });
|
||||
}
|
||||
|
||||
@@ -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 令牌对。
|
||||
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?,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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; });
|
||||
|
||||
Reference in New Issue
Block a user