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>
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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)],
|
||||
|
||||
Reference in New Issue
Block a user