diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index 3c09dbe..acf8a28 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -88,6 +88,16 @@ abstract class AppText { String get devicesSub; String get thisDevice; String get remove; + // 设备管理(P6):在线状态 / 操作 / 危险确认 + String get devOnline; + String get devOffline; + String get devLastLogin; // 「最后登录」前缀 + String get devNeverLogin; + String get devForceLogout; + String get devClearLogin; + String get devForceLogoutConfirm; // 弹窗正文(含设备名占位 %s) + String get devClearLoginConfirm; + String get devCancel; String get redeemEntry; String get contactEntry; String get signOut; diff --git a/client/lib/l10n/strings_en.dart b/client/lib/l10n/strings_en.dart index b59a3b2..d54aeeb 100644 --- a/client/lib/l10n/strings_en.dart +++ b/client/lib/l10n/strings_en.dart @@ -128,6 +128,24 @@ class StringsEn extends AppText { @override String get remove => 'Remove'; @override + String get devOnline => 'Online'; + @override + String get devOffline => 'Offline'; + @override + String get devLastLogin => 'Last login'; + @override + String get devNeverLogin => 'Never logged in'; + @override + String get devForceLogout => 'Force logout'; + @override + String get devClearLogin => 'Clear login info'; + @override + String get devForceLogoutConfirm => 'This revokes the device\'s login session — it will be kicked offline and must sign in again. The device stays in the list.'; + @override + String get devClearLoginConfirm => 'This removes the device from the list entirely and revokes its login session and data-plane credential. The device must sign in again to be used.'; + @override + String get devCancel => 'Cancel'; + @override String get redeemEntry => 'Redeem & buy'; @override String get contactEntry => 'Contact us'; diff --git a/client/lib/l10n/strings_zh.dart b/client/lib/l10n/strings_zh.dart index 7318413..f803a52 100644 --- a/client/lib/l10n/strings_zh.dart +++ b/client/lib/l10n/strings_zh.dart @@ -123,10 +123,28 @@ class StringsZh extends AppText { @override String get devicesSub => 'PRO 套餐最多 5 台设备同时在线'; @override - String get thisDevice => '当前设备'; + String get thisDevice => '本机'; @override String get remove => '移除'; @override + String get devOnline => '在线'; + @override + String get devOffline => '离线'; + @override + String get devLastLogin => '最后登录'; + @override + String get devNeverLogin => '从未登录'; + @override + String get devForceLogout => '强制退出'; + @override + String get devClearLogin => '清除登录信息'; + @override + String get devForceLogoutConfirm => '将吊销该设备的登录会话,它会被踢下线、需重新登录。设备仍保留在列表中。'; + @override + String get devClearLoginConfirm => '将从设备列表彻底移除此设备,并吊销其登录会话与数据面凭证。该设备需重新登录才能再次使用。'; + @override + String get devCancel => '取消'; + @override String get redeemEntry => '兑换 & 购买'; @override String get contactEntry => '联系我们'; diff --git a/client/lib/models/device.dart b/client/lib/models/device.dart index 9f0bee8..4025d89 100644 --- a/client/lib/models/device.dart +++ b/client/lib/models/device.dart @@ -1,4 +1,37 @@ // device.dart — 已登录设备(GET /v1/me/devices)。 + +/// 设备平台。线格式是字符串(与服务端 devices.platform 对齐),在 model 边界经 +/// [fromWire] 解析为枚举,UI/逻辑层只碰枚举(穷尽 switch + 拼错编译期报错)。 +enum DevicePlatform { + ios, + android, + windows, + macos, + linux, + unknown; + + static DevicePlatform fromWire(String s) => switch (s.toLowerCase().trim()) { + 'ios' => DevicePlatform.ios, + 'android' => DevicePlatform.android, + 'windows' => DevicePlatform.windows, + 'macos' => DevicePlatform.macos, + 'linux' => DevicePlatform.linux, + _ => DevicePlatform.unknown, + }; + + /// 展示名(大小写规范化)。 + String get label => switch (this) { + DevicePlatform.ios => 'iOS', + DevicePlatform.android => 'Android', + DevicePlatform.windows => 'Windows', + DevicePlatform.macos => 'macOS', + DevicePlatform.linux => 'Linux', + DevicePlatform.unknown => '—', + }; + + bool get isMobile => this == DevicePlatform.ios || this == DevicePlatform.android; +} + class Device { const Device({ required this.uuid, @@ -13,9 +46,12 @@ class Device { final String uuid; final String name; - /// 'ios' | 'android' | 'windows' | 'macos' | 'linux'。 + /// 平台线格式(原始字符串,保留以便回传/调试)。UI 用 [platformKind]。 final String platform; + /// 平台枚举(由线格式解析)。 + DevicePlatform get platformKind => DevicePlatform.fromWire(platform); + /// 最近活跃(UTC);从未上线为 null。 final DateTime? lastSeen; diff --git a/client/lib/services/device_identity.dart b/client/lib/services/device_identity.dart index 8bf1b22..339f1e4 100644 --- a/client/lib/services/device_identity.dart +++ b/client/lib/services/device_identity.dart @@ -132,3 +132,7 @@ class DeviceIdentity { /// 单例 DeviceIdentity(测试可 override)。 final deviceIdentityProvider = Provider((ref) => DeviceIdentity()); + +/// 本机 device_id(供「本机」高亮;未就绪时为空)。 +final localDeviceIdProvider = + FutureProvider((ref) => ref.read(deviceIdentityProvider).deviceId()); diff --git a/client/lib/widgets/account_screens.dart b/client/lib/widgets/account_screens.dart index ed60b21..d1f8b59 100644 --- a/client/lib/widgets/account_screens.dart +++ b/client/lib/widgets/account_screens.dart @@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../l10n/app_text.dart'; import '../models/device.dart'; import '../pangolin_theme.dart'; +import '../services/device_identity.dart'; import '../services/auth_api.dart'; import '../state/account_providers.dart'; import 'pangolin_button.dart'; @@ -114,15 +115,16 @@ class DevicesScreen extends ConsumerWidget { final VoidCallback? onBack; final bool embedded; - IconData _icon(String platform) => switch (platform.toLowerCase()) { - 'ios' || 'android' => PangolinIcons.smartphone, - 'macos' || 'windows' || 'linux' => PangolinIcons.laptop, - _ => PangolinIcons.monitorSmartphone, + IconData _icon(DevicePlatform p) => switch (p) { + DevicePlatform.ios || DevicePlatform.android => PangolinIcons.smartphone, + DevicePlatform.macos || DevicePlatform.windows || DevicePlatform.linux => PangolinIcons.laptop, + DevicePlatform.unknown => PangolinIcons.monitorSmartphone, }; - String _lastSeen(DateTime? ts) { + // 相对时间(最后登录);null → 从未登录。 + String _rel(DateTime? ts) { final zh = t.lang == AppLang.zh; - if (ts == null) return zh ? '从未在线' : 'never'; + if (ts == null) return t.devNeverLogin; final d = DateTime.now().difference(ts.toLocal()); if (d.inMinutes < 1) return zh ? '刚刚' : 'just now'; if (d.inMinutes < 60) return zh ? '${d.inMinutes} 分钟前' : '${d.inMinutes} min ago'; @@ -134,6 +136,7 @@ class DevicesScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final c = context.pangolin; final devicesAsync = ref.watch(devicesProvider); + final localId = ref.watch(localDeviceIdProvider).valueOrNull ?? ''; Widget content() => devicesAsync.when( loading: () => const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())), @@ -150,7 +153,8 @@ class DevicesScreen extends ConsumerWidget { decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm), clipBehavior: Clip.antiAlias, child: Column(children: [ - for (var i = 0; i < devices.length; i++) _row(ref, c, devices[i], i < devices.length - 1), + for (var i = 0; i < devices.length; i++) + _row(context, ref, c, devices[i], devices[i].uuid == localId, i < devices.length - 1), ]), ), ]), @@ -159,28 +163,114 @@ class DevicesScreen extends ConsumerWidget { return _SubScaffold(title: t.myDevices, onBack: onBack, embedded: embedded, child: content()); } - Widget _row(WidgetRef ref, PangolinScheme c, Device d, bool divider) { - final name = d.name.isNotEmpty ? d.name : d.platform; + Widget _row(BuildContext context, WidgetRef ref, PangolinScheme c, Device d, bool isLocal, bool divider) { + final name = d.name.isNotEmpty ? d.name : d.platformKind.label; + final subParts = [d.platformKind.label]; + if (d.clientVersion.isNotEmpty) subParts.add(d.clientVersion); + if (!d.online) subParts.add('${t.devLastLogin} ${_rel(d.lastLogin)}'); + return Container( decoration: BoxDecoration(border: divider ? Border(bottom: BorderSide(color: c.border)) : null), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), child: Row(children: [ Container(width: 38, height: 38, decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)), - child: Icon(_icon(d.platform), size: 19, color: c.accent)), + child: Icon(_icon(d.platformKind), size: 19, color: c.accent)), const SizedBox(width: 12), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(name, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5)), - const SizedBox(height: 1), - Text('${d.platform} · ${_lastSeen(d.lastSeen)}', style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), - ])), - OutlinedButton( - onPressed: () => ref.read(devicesProvider.notifier).remove(d.uuid), - style: OutlinedButton.styleFrom(foregroundColor: c.fg2, side: BorderSide(color: c.borderStrong, width: 1.5), shape: const StadiumBorder(), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), minimumSize: Size.zero), - child: Text(t.remove, style: PangolinText.caption.copyWith(fontWeight: FontWeight.w600)), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Row(children: [ + Flexible(child: Text(name, overflow: TextOverflow.ellipsis, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5))), + if (isLocal) ...[const SizedBox(width: 7), _badge(c, t.thisDevice)], + ]), + const SizedBox(height: 2), + Text(subParts.join(' · '), overflow: TextOverflow.ellipsis, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), + ]), ), + const SizedBox(width: 8), + _statusDot(c, d.online), + if (!isLocal) _menu(context, ref, c, d) else const SizedBox(width: 8), ]), ); } + + Widget _badge(PangolinScheme c, String text) => Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1), + decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(6), border: Border.all(color: c.accentBorder)), + child: Text(text, style: PangolinText.caption.copyWith(color: c.accent, fontWeight: FontWeight.w700, fontSize: 10)), + ); + + Widget _statusDot(PangolinScheme c, bool online) { + final col = online ? c.success : c.fg3; + return Row(mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 7, height: 7, + decoration: BoxDecoration( + color: col, shape: BoxShape.circle, + boxShadow: online ? [BoxShadow(color: c.success.withValues(alpha: 0.25), spreadRadius: 3)] : null, + ), + ), + const SizedBox(width: 5), + Text(online ? t.devOnline : t.devOffline, style: PangolinText.caption.copyWith(color: col, fontWeight: FontWeight.w600, fontSize: 12)), + ]); + } + + Widget _menu(BuildContext context, WidgetRef ref, PangolinScheme c, Device d) => PopupMenuButton( + icon: Icon(PangolinIcons.moreVertical, size: 18, color: c.fg3), + color: c.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.md), side: BorderSide(color: c.border)), + itemBuilder: (_) => [ + PopupMenuItem(value: 'logout', child: Row(children: [ + Icon(PangolinIcons.logOut, size: 16, color: c.fg2), const SizedBox(width: 10), + Text(t.devForceLogout, style: PangolinText.sm.copyWith(color: c.fg1)), + ])), + PopupMenuItem(value: 'clear', child: Row(children: [ + Icon(PangolinIcons.trash, size: 16, color: c.danger), const SizedBox(width: 10), + Text(t.devClearLogin, style: PangolinText.sm.copyWith(color: c.danger)), + ])), + ], + onSelected: (v) async { + final isClear = v == 'clear'; + final label = isClear ? t.devClearLogin : t.devForceLogout; + final shown = d.name.isNotEmpty ? d.name : d.platformKind.label; + final ok = await _confirm(context, c, + title: '$label「$shown」', + body: isClear ? t.devClearLoginConfirm : t.devForceLogoutConfirm, + action: label); + if (!ok) return; + final n = ref.read(devicesProvider.notifier); + if (isClear) { + await n.remove(d.uuid); + } else { + await n.forceLogout(d.uuid); + } + }, + ); + + // 危险操作二次确认弹窗(走 token 配色)。 + Future _confirm(BuildContext context, PangolinScheme c, + {required String title, required String body, required String action}) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: c.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)), + title: Row(children: [ + Container(width: 34, height: 34, decoration: BoxDecoration(color: c.dangerSubtle, shape: BoxShape.circle), + child: Icon(PangolinIcons.alertTriangle, size: 18, color: c.danger)), + const SizedBox(width: 12), + Expanded(child: Text(title, overflow: TextOverflow.ellipsis, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700))), + ]), + content: Text(body, style: PangolinText.sm.copyWith(color: c.fg2, height: 1.5)), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), + child: Text(t.devCancel, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600))), + TextButton(onPressed: () => Navigator.pop(ctx, true), + child: Text(action, style: PangolinText.sm.copyWith(color: c.danger, fontWeight: FontWeight.w700))), + ], + ), + ); + return ok ?? false; + } } /// ── 兑换 & 购买 ── (兑换码走 POST /v1/redeem,成功后刷新账户) diff --git a/client/lib/widgets/pangolin_icons.dart b/client/lib/widgets/pangolin_icons.dart index a3dfe75..f548dd2 100644 --- a/client/lib/widgets/pangolin_icons.dart +++ b/client/lib/widgets/pangolin_icons.dart @@ -55,6 +55,9 @@ class PangolinIcons { static const laptop = LucideIcons.laptop; static const smartphone = LucideIcons.smartphone; static const monitorSmartphone = LucideIcons.smartphone; // 回退 + static const moreVertical = LucideIcons.moreVertical; + static const trash = LucideIcons.trash2; + static const alertTriangle= LucideIcons.alertTriangle; static IconData? byName(String name) => _byName[name]; diff --git a/client/test/widget/devices_screen_test.dart b/client/test/widget/devices_screen_test.dart new file mode 100644 index 0000000..6e28e36 --- /dev/null +++ b/client/test/widget/devices_screen_test.dart @@ -0,0 +1,127 @@ +// devices_screen_test.dart — P6「我的设备」渲染 + 强制退出/清除交互。 +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pangolin_vpn/l10n/strings_zh.dart'; +import 'package:pangolin_vpn/pangolin_theme.dart'; +import 'package:pangolin_vpn/services/api_client.dart'; +import 'package:pangolin_vpn/services/device_identity.dart'; +import 'package:pangolin_vpn/services/token_store.dart'; +import 'package:pangolin_vpn/state/account_providers.dart'; +import 'package:pangolin_vpn/state/auth_provider.dart'; +import 'package:pangolin_vpn/widgets/account_screens.dart'; + +import '../helpers/harness.dart'; + +class _LoggedInTokenStore implements TokenStore { + const _LoggedInTokenStore(); + @override + Future loadAccessToken() async => 'test-token'; + @override + Future loadRefreshToken() async => null; + @override + Future saveTokens({required String access, required String refresh}) async {} + @override + Future clear() async {} + @override + Future markOnboarded() async {} + @override + Future isOnboarded() async => true; + @override + Future saveLastEmail(String email) async {} + @override + Future loadLastEmail() async => null; +} + +final _devicesJson = { + 'devices': [ + {'uuid': 'local-uuid', 'name': 'MacBook Pro', 'platform': 'macos', 'online': true, 'client_version': 'v1.0.10', 'last_seen': '2026-06-29T10:00:00Z'}, + {'uuid': 'phone-uuid', 'name': 'iPhone 15', 'platform': 'ios', 'online': false, 'client_version': 'v1.0.9', 'last_login': '2026-06-28T09:00:00Z'}, + ], +}; + +void main() { + setUpAll(disableGoogleFontsFetching); + const t = StringsZh(); + + final recorded = []; + http.Client mockClient() => MockClient((req) async { + recorded.add('${req.method} ${req.url.path}'); + if (req.method == 'GET' && req.url.path == '/v1/me/devices') { + return http.Response(jsonEncode(_devicesJson), 200, headers: {'content-type': 'application/json'}); + } + return http.Response('', 204); // logout / delete + }); + + Future pump(WidgetTester tester) async { + recorded.clear(); + await tester.binding.setSurfaceSize(const Size(440, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(ProviderScope( + overrides: [ + tokenStoreProvider.overrideWithValue(const _LoggedInTokenStore()), + apiClientProvider.overrideWithValue(ApiClient( + baseUrl: 'http://test.local', + getToken: () => 'test-token', + refresh: () async => false, + client: mockClient(), + )), + localDeviceIdProvider.overrideWith((ref) => 'local-uuid'), + ], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: PangolinTheme.light, + home: DevicesScreen(t: t), + ), + )); + await tester.pumpAndSettle(); + } + + testWidgets('渲染:本机标 + 在线/离线 + 客户端版本', (tester) async { + await pump(tester); + expect(find.text(t.thisDevice), findsOneWidget); // 本机仅本机行 + expect(find.text(t.devOnline), findsOneWidget); + expect(find.text(t.devOffline), findsOneWidget); + expect(find.text('v1.0.10'), findsNothing); // 版本拼进副标题,不是独立 Text + expect(find.textContaining('v1.0.9'), findsOneWidget); // 离线行副标题含版本 + expect(find.text('MacBook Pro'), findsOneWidget); + expect(find.text('iPhone 15'), findsOneWidget); + }); + + testWidgets('非本机行 ⋯ 菜单 → 强制退出 → 确认 → 调 logout 接口', (tester) async { + await pump(tester); + // 仅非本机(iPhone)有 ⋯ 菜单。 + final menu = find.byType(PopupMenuButton); + expect(menu, findsOneWidget); + await tester.tap(menu); + await tester.pumpAndSettle(); + expect(find.text(t.devForceLogout), findsOneWidget); + expect(find.text(t.devClearLogin), findsOneWidget); + + await tester.tap(find.text(t.devForceLogout)); + await tester.pumpAndSettle(); + // 确认弹窗 + 危险动作按钮。 + expect(find.text(t.devCancel), findsOneWidget); + await tester.tap(find.widgetWithText(TextButton, t.devForceLogout)); + await tester.pumpAndSettle(); + + expect(recorded.any((r) => r == 'POST /v1/me/devices/phone-uuid/logout'), isTrue, + reason: '应调强制退出接口: $recorded'); + }); + + testWidgets('清除登录信息 → 确认 → 调 DELETE 接口', (tester) async { + await pump(tester); + await tester.tap(find.byType(PopupMenuButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text(t.devClearLogin)); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(TextButton, t.devClearLogin)); + await tester.pumpAndSettle(); + expect(recorded.any((r) => r == 'DELETE /v1/me/devices/phone-uuid'), isTrue, + reason: '应调清除接口: $recorded'); + }); +} diff --git a/docs/superpowers/plans/2026-06-29-device-session-management.md b/docs/superpowers/plans/2026-06-29-device-session-management.md index 73e17fe..782eca7 100644 --- a/docs/superpowers/plans/2026-06-29-device-session-management.md +++ b/docs/superpowers/plans/2026-06-29-device-session-management.md @@ -52,11 +52,11 @@ - 占位:清除联动 `totp_trusted_until`(P2 已预留列)。 ## P6 · UI 重做(DevicesScreen,五端) -- [ ] `DevicesScreen` 重做:名称/平台·版本/在线绿点/最后登录 + 本机标 + 行尾 ⋯ 菜单 + 危险确认弹窗;走 token -- [ ] 宽/窄屏适配;空/加载态 -- [ ] l10n:`app_text.dart`+`strings_{zh,en}.dart` 新增文案 +- [x] `DevicesScreen` 重做:名称/平台·版本/在线绿点/最后登录 + 本机标 + 行尾 ⋯ 菜单 + 危险确认弹窗;走 token +- [x] 宽/窄屏适配;空/加载态 +- [x] l10n:`app_text.dart`+`strings_{zh,en}.dart` 新增文案 - [ ] golden:DevicesScreen 新 golden(Mac 生成,非 CI-gated) -- [ ] 测试:widget(在线/离线/菜单/确认/调用) +- [x] 测试:widget(在线/离线/菜单/确认/调用) - [ ] 验收:与 §7 视觉稿一致,五端生效 ## Verification