feat(client): P4 节点实测延迟/设备/套餐价/兑换/统计接真(#6 6D)
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

- 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>
This commit is contained in:
wangjia
2026-06-19 00:04:25 +08:00
parent bb39b36d84
commit f20eddad55
15 changed files with 321 additions and 162 deletions
+130 -87
View File
@@ -3,9 +3,13 @@
// 文案全部经 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';
@@ -43,133 +47,149 @@ class _SubScaffold extends StatelessWidget {
}
}
/// ── 套餐选择 ──
class PlansScreen extends StatelessWidget {
/// ── 套餐选择 ── (价格/额度来自 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) {
final plans = [
(id: 'free', name: t.freePlan, price: '¥0', cta: t.current, featured: false, current: true, pop: null as String?, feats: t.featsFree),
(id: 'pro', name: t.proPlan, price: '¥25', cta: t.upgrade, featured: true, current: false, pop: t.mostPopular, feats: t.featsPro),
(id: 'team', name: t.teamPlan, price: '¥99', cta: t.choose, featured: false, current: false, pop: null as String?, feats: t.featsTeam),
];
return _SubScaffold(
title: t.choosePlan,
onBack: onBack,
embedded: embedded,
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
children: [
for (final p in plans)
Padding(
padding: EdgeInsets.only(bottom: 14, top: p.pop != null ? 12 : 0),
child: PlanCard(
name: p.name, price: p.price, period: t.perMonth,
features: p.feats, ctaLabel: p.cta, featured: p.featured, isCurrent: p.current,
popularLabel: p.pop, onPressed: () => onChoose?.call(p.id),
),
),
],
),
);
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());
}
}
/// ── 设备管理 ──
class DeviceItem {
const DeviceItem({required this.id, required this.icon, required this.name, required this.os, required this.active, this.me = false});
final String id, name, os, active;
final IconData icon;
final bool me;
}
class DevicesScreen extends StatefulWidget {
/// ── 设备管理 ── (真实 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;
@override
State<DevicesScreen> createState() => _DevicesScreenState();
}
class _DevicesScreenState extends State<DevicesScreen> {
late List<DeviceItem> _devices = [
DeviceItem(id: '1', icon: PangolinIcons.laptop, name: 'MacBook Pro', os: 'macOS 26', active: widget.t.thisDevice, me: true),
DeviceItem(id: '2', icon: PangolinIcons.smartphone, name: 'iPhone 17', os: 'iOS 26', active: '2 min'),
DeviceItem(id: '3', icon: PangolinIcons.monitorSmartphone, name: 'iPad Air', os: 'iPadOS 26', active: widget.t.lang == AppLang.zh ? '3 小时前' : '3h ago'),
];
IconData _icon(String platform) => switch (platform.toLowerCase()) {
'ios' || 'android' => PangolinIcons.smartphone,
'macos' || 'windows' || 'linux' => PangolinIcons.laptop,
_ => PangolinIcons.monitorSmartphone,
};
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return _SubScaffold(
title: widget.t.myDevices,
onBack: widget.onBack,
embedded: widget.embedded,
child: ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
Text(widget.t.devicesSub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
const SizedBox(height: 12),
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(c, _devices[i], i < _devices.length - 1),
]),
),
]),
);
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';
}
Widget _row(PangolinScheme c, DeviceItem d, bool divider) {
@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(d.icon, size: 19, color: c.accent)),
child: Icon(_icon(d.platform), size: 19, color: c.accent)),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Flexible(child: Text(d.name, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5))),
if (d.me)
Container(
margin: const EdgeInsets.only(left: 7), padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
decoration: BoxDecoration(color: c.successSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
child: Text(d.active, style: PangolinText.caption.copyWith(color: c.success, fontWeight: FontWeight.w600, fontSize: 10.5)),
),
]),
Text(name, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5)),
const SizedBox(height: 1),
Text(d.me ? d.os : '${d.os} · ${d.active}', style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
Text('${d.platform} · ${_lastSeen(d.lastSeen)}', style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
])),
if (!d.me)
OutlinedButton(
onPressed: () => setState(() => _devices.removeWhere((x) => x.id == d.id)),
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(widget.t.remove, style: PangolinText.caption.copyWith(fontWeight: FontWeight.w600)),
),
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)),
),
]),
);
}
}
/// ── 兑换 & 购买 ──
class RedeemScreen extends StatefulWidget {
/// ── 兑换 & 购买 ── (兑换码走 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
State<RedeemScreen> createState() => _RedeemScreenState();
ConsumerState<RedeemScreen> createState() => _RedeemScreenState();
}
class _RedeemScreenState extends State<RedeemScreen> {
class _RedeemScreenState extends ConsumerState<RedeemScreen> {
final _code = TextEditingController();
bool _ok = false;
bool _busy = false;
String? _err;
@override
void dispose() {
@@ -177,6 +197,25 @@ class _RedeemScreenState extends State<RedeemScreen> {
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;
@@ -218,8 +257,12 @@ class _RedeemScreenState extends State<RedeemScreen> {
),
)),
const SizedBox(width: 10),
PangolinButton(label: t.redeemBtn, onPressed: _code.text.trim().length >= 4 ? () => setState(() => _ok = true) : null),
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),
+1 -1
View File
@@ -65,7 +65,7 @@ class ServerTile extends StatelessWidget {
],
),
),
Text('${node.ping}ms', style: PangolinText.mono.copyWith(color: c.fg2, fontSize: 12)),
Text(node.pingLabel, style: PangolinText.mono.copyWith(color: c.fg2, fontSize: 12)),
const SizedBox(width: 8),
SignalBars(ping: node.ping),
if (active) ...[const SizedBox(width: 10), Icon(PangolinIcons.check, size: 18, color: c.accent)],