feat(client): P6 DevicesScreen 重做 + DevicePlatform 枚举
ci-pangolin / Lint — shellcheck (push) Successful in 7s
ci-pangolin / OpenAPI Sync Check (push) Successful in 19s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 7s
ci-pangolin / Flutter — analyze + test (push) Successful in 23s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 5s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 5s
ci-pangolin / Go — build + test (push) Successful in 9s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Successful in 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m12s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 15s

回应「状态码用枚举更好」:新增 DevicePlatform 枚举(线格式 fromWire 解析,
UI 只碰枚举,穷尽 switch),Device.platformKind getter;platform 图标按枚举 switch。
DevicesScreen 按 §7 视觉稿重做:行内 名称+「本机」标 / 平台·客户端版本·最后登录 /
在线绿点光晕·离线灰点 + 行尾 ⋯ 菜单(强制退出 / 清除登录信息·危险红)+ 危险二次
确认弹窗;本机行不显示 ⋯(不自我踢)。l10n 新增在线/离线/最后登录/从未登录/强制
退出/清除登录信息/取消 + 确认文案(中英);「当前设备」改「本机」。
新图标 moreVertical/trash/alertTriangle。localDeviceIdProvider 供本机判定。
测试:devices_screen widget 3 例(本机标+在线/离线+版本渲染 / ⋯菜单→强制退出→
确认→POST logout / 清除→DELETE);全量 flutter test 58 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-29 06:10:37 +08:00
parent bcc114088c
commit 58526b09e6
9 changed files with 332 additions and 26 deletions
+10
View File
@@ -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;
+18
View File
@@ -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';
+19 -1
View File
@@ -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 => '联系我们';
+37 -1
View File
@@ -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;
+4
View File
@@ -132,3 +132,7 @@ class DeviceIdentity {
/// 单例 DeviceIdentity(测试可 override)。
final deviceIdentityProvider = Provider<DeviceIdentity>((ref) => DeviceIdentity());
/// 本机 device_id(供「本机」高亮;未就绪时为空)。
final localDeviceIdProvider =
FutureProvider<String>((ref) => ref.read(deviceIdentityProvider).deviceId());
+110 -20
View File
@@ -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 = <String>[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<String>(
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<bool> _confirm(BuildContext context, PangolinScheme c,
{required String title, required String body, required String action}) async {
final ok = await showDialog<bool>(
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,成功后刷新账户)
+3
View File
@@ -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];
+127
View File
@@ -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<String?> loadAccessToken() async => 'test-token';
@override
Future<String?> loadRefreshToken() async => null;
@override
Future<void> saveTokens({required String access, required String refresh}) async {}
@override
Future<void> clear() async {}
@override
Future<void> markOnboarded() async {}
@override
Future<bool> isOnboarded() async => true;
@override
Future<void> saveLastEmail(String email) async {}
@override
Future<String?> 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 = <String>[];
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<void> 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<String>);
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<String>));
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');
});
}
@@ -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` 新增文案
- [ ] goldenDevicesScreen 新 goldenMac 生成,非 CI-gated
- [ ] 测试:widget(在线/离线/菜单/确认/调用)
- [x] 测试:widget(在线/离线/菜单/确认/调用)
- [ ] 验收:与 §7 视觉稿一致,五端生效
## Verification