Files
pangolin/client/lib/screens/nodes_page.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

198 lines
7.1 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.
// nodes_page.dart — 节点页(置顶智能选择推荐卡 + 搜索 + 列表/双列网格)
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../l10n/app_text.dart';
import '../models/node.dart';
import '../pangolin_theme.dart';
import '../state/app_providers.dart';
import '../state/connection_provider.dart';
import '../state/nodes_provider.dart';
import '../widgets/app_top_bar.dart';
import '../widgets/country_code.dart';
import '../widgets/pangolin_icons.dart';
import '../widgets/server_tile.dart';
import '../widgets/smart_select_card.dart';
class NodesPage extends ConsumerStatefulWidget {
const NodesPage({super.key, required this.isWide, required this.onPicked});
final bool isWide;
final VoidCallback onPicked;
@override
ConsumerState<NodesPage> createState() => _NodesPageState();
}
class _NodesPageState extends ConsumerState<NodesPage> {
String _q = '';
void _pick(String code) {
ref.read(selectedNodeCodeProvider.notifier).state = code;
ref.read(connectionProvider.notifier).onNodeChanged();
widget.onPicked();
}
List<Node> _filtered(List<Node> nodes) {
if (_q.trim().isEmpty) return nodes;
final q = _q.toLowerCase();
return nodes
.where((n) => (n.nameZh + n.nameEn + n.code).toLowerCase().contains(q))
.toList();
}
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final t = ref.watch(appTextProvider);
final selected = ref.watch(selectedNodeCodeProvider);
final nodes = _filtered(ref.watch(nodesProvider).valueOrNull ?? const <Node>[]);
final smart = selected == kSmartNodeCode;
final smartCard = SmartSelectCard(t: t, selected: smart, onTap: () => _pick(kSmartNodeCode));
final search = _SearchBox(hint: t.searchPh, onChanged: (v) => setState(() => _q = v));
if (widget.isWide) {
// 对照 dapp.jsx DServerspadding 4/28、网格 gap 10。智能卡置顶(CLAUDE.md §5 规范,三端一致)。
return ListView(
padding: const EdgeInsets.fromLTRB(28, 4, 28, 24),
children: [
smartCard,
const SizedBox(height: 14),
ConstrainedBox(constraints: const BoxConstraints(maxWidth: 380), child: search),
const SizedBox(height: 14),
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 4.4,
children: [
for (final n in nodes)
_NodeGridTile(node: n, lang: t.lang, active: !smart && n.code == selected, onTap: () => _pick(n.code)),
],
),
],
);
}
// 窄屏:顶栏 + 标题 + 推荐卡 + 搜索 + 分组列表
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
AppTopBar(brand: t.brand),
PageTitle(title: t.chooseNode),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
children: [
smartCard,
const SizedBox(height: 12),
search,
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 < nodes.length; i++) ...[
ServerTile(
node: nodes[i],
lang: t.lang,
active: !smart && nodes[i].code == selected,
onTap: () => _pick(nodes[i].code),
),
if (i < nodes.length - 1) Divider(height: 1, thickness: 1, color: c.border),
],
]),
),
],
),
),
]);
}
}
class _SearchBox extends StatelessWidget {
const _SearchBox({required this.hint, required this.onChanged});
final String hint;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Container(
decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)),
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(children: [
Icon(PangolinIcons.search, size: 18, color: c.fg3),
const SizedBox(width: 10),
Expanded(
child: TextField(
onChanged: onChanged,
style: PangolinText.sm.copyWith(color: c.fg1, fontSize: 15),
decoration: InputDecoration(
isCollapsed: true,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
border: InputBorder.none,
hintText: hint,
hintStyle: PangolinText.sm.copyWith(color: c.fg3, fontSize: 15),
),
),
),
]),
);
}
}
/// 宽屏双列网格中的节点块。
class _NodeGridTile extends StatelessWidget {
const _NodeGridTile({required this.node, required this.lang, required this.active, required this.onTap});
final Node node;
final AppLang lang;
final bool active;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Material(
color: active ? c.accentSubtle : c.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(PangolinRadius.lg),
side: BorderSide(color: active ? c.accentBorder : c.border, width: 1.5),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 11),
child: Row(children: [
CountryCode(code: node.code, active: active),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Text(node.localizedName(lang),
overflow: TextOverflow.ellipsis,
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5)),
Text(node.localizedSub(lang),
overflow: TextOverflow.ellipsis,
style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
]),
),
const SizedBox(width: 8),
Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [
Text(node.pingLabel, style: PangolinText.mono.copyWith(color: c.fg2, fontSize: 12)),
const SizedBox(height: 5),
SignalBars(ping: node.ping),
]),
if (active) ...[const SizedBox(width: 8), Icon(PangolinIcons.check, size: 18, color: c.accent)],
]),
),
),
);
}
}