Files
pangolin/client/lib/widgets/account_screens.dart
T
wangjia 58526b09e6
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
feat(client): P6 DevicesScreen 重做 + DevicePlatform 枚举
回应「状态码用枚举更好」:新增 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>
2026-06-29 06:10:37 +08:00

456 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// account_screens.dart — 账户子页(套餐选择 / 设备管理 / 兑换 & 购买 / 联系我们)
//
// 文案全部经 AppText(l10n,单显)。套餐数字以 design/CLAUDE.md §7 为准。
// App 内无任何支付表单——购买仅引导至外部渠道。
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/device_identity.dart';
import '../services/auth_api.dart';
import '../state/account_providers.dart';
import 'pangolin_button.dart';
import 'pangolin_icons.dart';
import 'plan_card.dart';
/// 通用:带返回箭头的子页骨架。embedded=true 时只返回内容(desktop 内容区下钻,
/// 返回/标题由外层 shell 顶栏提供),否则整页 Scaffoldmobile/tablet 全屏 push)。
class _SubScaffold extends StatelessWidget {
const _SubScaffold({required this.title, required this.child, this.onBack, this.embedded = false});
final String title;
final Widget child;
final VoidCallback? onBack;
final bool embedded;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
if (embedded) return child;
return Scaffold(
backgroundColor: c.bg,
body: SafeArea(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 16, 10),
child: Row(children: [
IconButton(
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1),
),
Text(title, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
]),
),
Expanded(child: child),
]),
),
);
}
}
/// ── 套餐选择 ── (价格/额度来自 GET /v1/plans;当前档由 me.plan 判定;
/// 功能清单仍取 l10n,后端无此字段)
class PlansScreen extends ConsumerWidget {
const PlansScreen({super.key, required this.t, this.onChoose, this.onBack, this.embedded = false});
final AppText t;
final ValueChanged<String>? onChoose;
final VoidCallback? onBack;
final bool embedded;
List<String> _feats(String code) => switch (code) {
'free' => t.featsFree,
'pro' => t.featsPro,
'team' => t.featsTeam,
_ => const [],
};
// 套餐名是 UI 文案(后端 plans 表无名称列),按 code 取 l10n。
String _name(String code) => switch (code) {
'free' => t.freePlan,
'pro' => t.proPlan,
'team' => t.teamPlan,
_ => code,
};
@override
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
final plansAsync = ref.watch(plansProvider);
final myPlan = ref.watch(meProvider).valueOrNull?.plan ?? 'free';
Widget body() => plansAsync.when(
loading: () => const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())),
error: (_, __) => Center(
child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry', style: PangolinText.body.copyWith(color: c.fg3)))),
data: (plans) => ListView(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
children: [
for (final p in plans)
Padding(
padding: EdgeInsets.only(bottom: 14, top: p.code == 'pro' ? 12 : 0),
child: PlanCard(
name: _name(p.code),
price: p.priceLabel(),
period: t.perMonth,
features: _feats(p.code),
ctaLabel: p.code == myPlan ? t.current : (p.code == 'team' ? t.choose : t.upgrade),
featured: p.code == 'pro',
isCurrent: p.code == myPlan,
popularLabel: p.code == 'pro' ? t.mostPopular : null,
onPressed: () => onChoose?.call(p.code),
),
),
],
),
);
return _SubScaffold(title: t.choosePlan, onBack: onBack, embedded: embedded, child: body());
}
}
/// ── 设备管理 ── (真实 GET /v1/me/devices;移除 = DELETE。本机高亮待 P6 设备 id)
class DevicesScreen extends ConsumerWidget {
const DevicesScreen({super.key, required this.t, this.onBack, this.embedded = false});
final AppText t;
final VoidCallback? onBack;
final bool embedded;
IconData _icon(DevicePlatform p) => switch (p) {
DevicePlatform.ios || DevicePlatform.android => PangolinIcons.smartphone,
DevicePlatform.macos || DevicePlatform.windows || DevicePlatform.linux => PangolinIcons.laptop,
DevicePlatform.unknown => PangolinIcons.monitorSmartphone,
};
// 相对时间(最后登录);null → 从未登录。
String _rel(DateTime? ts) {
final zh = t.lang == AppLang.zh;
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';
if (d.inHours < 24) return zh ? '${d.inHours} 小时前' : '${d.inHours}h ago';
return zh ? '${d.inDays} 天前' : '${d.inDays}d ago';
}
@override
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())),
error: (_, __) => Center(
child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry', style: PangolinText.body.copyWith(color: c.fg3)))),
data: (devices) => ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
Text(t.devicesSub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
const SizedBox(height: 12),
if (devices.isEmpty)
Text(t.lang == AppLang.zh ? '暂无已登录设备' : 'No devices yet',
style: PangolinText.sm.copyWith(color: c.fg3))
else
Container(
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(context, ref, c, devices[i], devices[i].uuid == localId, i < devices.length - 1),
]),
),
]),
);
return _SubScaffold(title: t.myDevices, onBack: onBack, embedded: embedded, child: content());
}
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: 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.platformKind), size: 19, color: c.accent)),
const SizedBox(width: 12),
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,成功后刷新账户)
class RedeemScreen extends ConsumerStatefulWidget {
const RedeemScreen({super.key, required this.t, this.onBack, this.embedded = false});
final AppText t;
final VoidCallback? onBack;
final bool embedded;
@override
ConsumerState<RedeemScreen> createState() => _RedeemScreenState();
}
class _RedeemScreenState extends ConsumerState<RedeemScreen> {
final _code = TextEditingController();
bool _ok = false;
bool _busy = false;
String? _err;
@override
void dispose() {
_code.dispose();
super.dispose();
}
Future<void> _redeem() async {
final code = _code.text.trim();
if (code.isEmpty) return;
setState(() {
_busy = true;
_err = null;
});
try {
await ref.read(accountApiProvider).redeem(code);
// 订阅变化 → 刷新账户聚合,套餐横幅/额度随之更新。
await ref.read(meProvider.notifier).refresh();
if (mounted) setState(() => _ok = true);
} on AuthApiException catch (e) {
if (mounted) setState(() => _err = widget.t.lang == AppLang.zh ? e.messageZh : e.messageEn);
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final t = widget.t;
final channels = [
(icon: PangolinIcons.shoppingBag, name: t.chStore, sub: 'shop.pangolin.vpn', accent: true),
(icon: PangolinIcons.send, name: 'Telegram', sub: '@PangolinVPN_bot', accent: false),
(icon: PangolinIcons.messageCircle, name: 'LINE', sub: '@pangolinvpn', accent: false),
(icon: PangolinIcons.mail, name: t.chEmail, sub: 'buy@pangolin.vpn', accent: false),
];
return _SubScaffold(
title: t.redeemTitle,
onBack: widget.onBack,
embedded: widget.embedded,
child: ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.xl), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(t.redeemCodeTitle, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 14.5)),
const SizedBox(height: 12),
if (_ok)
Row(children: [
Icon(PangolinIcons.checkCircle, size: 20, color: c.success),
const SizedBox(width: 9),
Text(t.redeemOk, style: PangolinText.body.copyWith(color: c.success, fontWeight: FontWeight.w600)),
])
else
Row(children: [
Expanded(child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(color: c.bg, borderRadius: BorderRadius.circular(PangolinRadius.md), border: Border.all(color: c.borderStrong, width: 1.5)),
child: TextField(
controller: _code,
textCapitalization: TextCapitalization.characters,
style: PangolinText.mono.copyWith(color: c.fg1, letterSpacing: 1.5),
// kBareInput 关掉主题边框/填充泄漏(聚焦时橙框错位/被裁),聚焦态交给外层 Border。
decoration: kBareInput.copyWith(contentPadding: const EdgeInsets.symmetric(vertical: 13), hintText: t.redeemPh, hintStyle: PangolinText.sm.copyWith(color: c.fg3)),
onChanged: (_) => setState(() {}),
),
)),
const SizedBox(width: 10),
PangolinButton(label: t.redeemBtn, onPressed: (_busy || _code.text.trim().length < 4) ? null : _redeem),
]),
if (_err != null) ...[
const SizedBox(height: 10),
Text(_err!, style: PangolinText.caption.copyWith(color: c.danger, fontWeight: FontWeight.w500)),
],
]),
),
const SizedBox(height: 22),
Text(t.buyTitle, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 14.5)),
const SizedBox(height: 6),
Text(t.buySub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400, height: 1.5)),
const SizedBox(height: 14),
for (final ch in channels) Padding(padding: const EdgeInsets.only(bottom: 10), child: _channel(c, ch.icon, ch.name, ch.sub, ch.accent)),
]),
);
}
Widget _channel(PangolinScheme c, IconData icon, String name, String sub, bool accent) {
return InkWell(
borderRadius: BorderRadius.circular(PangolinRadius.lg),
onTap: () {},
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: accent ? c.accentSubtle : c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: accent ? c.accentBorder : c.border), boxShadow: PangolinShadow.sm),
child: Row(children: [
Container(width: 40, height: 40, decoration: BoxDecoration(color: accent ? c.accent : c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)),
child: Icon(icon, size: 20, color: accent ? PangolinColors.white : c.accent)),
const SizedBox(width: 13),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(name, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
Text(sub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
])),
Icon(PangolinIcons.externalLink, size: 16, color: c.fg3),
]),
),
);
}
}
/// ── 联系我们 ──
class ContactScreen extends StatelessWidget {
const ContactScreen({super.key, required this.t, this.onBack});
final AppText t;
final VoidCallback? onBack;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final channels = [
(icon: PangolinIcons.send, name: 'Telegram', sub: '@PangolinVPN_bot', accent: true),
(icon: PangolinIcons.messageCircle, name: 'LINE', sub: '@pangolinvpn', accent: false),
(icon: PangolinIcons.mail, name: t.contactEmail, sub: 'support@pangolin.vpn', accent: false),
(icon: PangolinIcons.shoppingBag, name: t.contactStore, sub: 'shop.pangolin.vpn', accent: false),
];
return _SubScaffold(
title: t.contactTitle,
onBack: onBack,
child: ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
Text(t.contactIntro, style: PangolinText.sm.copyWith(color: c.fg2, height: 1.6)),
const SizedBox(height: 16),
for (final ch in channels) Padding(padding: const EdgeInsets.only(bottom: 10), child: _contact(c, ch.icon, ch.name, ch.sub, ch.accent)),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(t.contactHoursTitle, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600)),
const SizedBox(height: 5),
Text(t.contactHours, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
]),
),
]),
);
}
Widget _contact(PangolinScheme c, IconData icon, String name, String sub, bool accent) {
return InkWell(
borderRadius: BorderRadius.circular(PangolinRadius.lg),
onTap: () {},
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: accent ? c.accentSubtle : c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: accent ? c.accentBorder : c.border), boxShadow: PangolinShadow.sm),
child: Row(children: [
Container(width: 40, height: 40, decoration: BoxDecoration(color: accent ? c.accent : c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)),
child: Icon(icon, size: 20, color: accent ? PangolinColors.white : c.accent)),
const SizedBox(width: 13),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(name, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
Text(sub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
])),
Icon(PangolinIcons.externalLink, size: 16, color: c.fg3),
]),
),
);
}
}