Files
pangolin/client/lib/widgets/account_screens.dart
T
wangjia f20eddad55
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled
feat(client): P4 节点实测延迟/设备/套餐价/兑换/统计接真(#6 6D)
- latency_probe.dart:并行 TCP 握手实测各节点 per-client RTT(服务端无法代知)。
- nodes_provider:解析 /v1/nodes 的 host/port,后台测延迟回填 Node.ping;智能选择
  按实测最小;去掉 8 个伪造演示节点(空列表用中性占位,不再伪造服务器)。
- Node:+host/port/copyWith/pingLabel(未测显示 —);各端 ping 显示改 pingLabel。
- account_screens:PlansScreen 接 /v1/plans(价格 priceLabel + me 标当前档);
  DevicesScreen 接 /v1/me/devices + 移除 DELETE + last_seen;RedeemScreen 接
  /v1/redeem(成功刷新 me,失败显示后端文案)。
- stats_page:周柱接 me.weekly_gb,本月流量/时长接 usage(30),延迟用生效节点实测。
- 顺带补登记早前 log_time.dart 的删除(log.dart 重构遗漏 stage)。

flutter analyze 0 error;115 tests passed;受影响 4 golden 重生成。
本机设备高亮 + 真实 device id 随 P6(device_info_plus)落地。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 00:04:25 +08:00

357 lines
17 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/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 [],
};
@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: p.localizedName(t.lang),
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(String platform) => switch (platform.toLowerCase()) {
'ios' || 'android' => PangolinIcons.smartphone,
'macos' || 'windows' || 'linux' => PangolinIcons.laptop,
_ => PangolinIcons.monitorSmartphone,
};
String _lastSeen(DateTime? ts) {
final zh = t.lang == AppLang.zh;
if (ts == null) return zh ? '从未在线' : 'never';
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);
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(ref, c, devices[i], i < devices.length - 1),
]),
),
]),
);
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;
return Container(
decoration: BoxDecoration(border: divider ? Border(bottom: BorderSide(color: c.border)) : null),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
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)),
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)),
),
]),
);
}
}
/// ── 兑换 & 购买 ── (兑换码走 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),
decoration: InputDecoration(border: InputBorder.none, isCollapsed: true, 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),
]),
),
);
}
}