Files
pangolin/client/lib/screens/nodes_page.dart
T
wangjia 8049659660
ci-pangolin / Lint — shellcheck (push) Successful in 8s
ci-pangolin / OpenAPI Sync Check (push) Successful in 24s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 6s
ci-pangolin / Flutter — analyze + test (push) Successful in 24s
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 18s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Successful in 4m18s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 13s
feat(client): 节点 status=down 在列表置灰+「不可用」+禁选(#7 客户端侧)
#7 服务端已在 agent 离线时把节点判 down,但客户端 Node 模型没读 status、
列表照常显示。补:Node.status 字段 + 解析 /v1/nodes 的 status;ServerTile/
_NodeGridTile 在 isDown 时 Opacity 置灰 + 末尾「不可用」+ onTap 禁用。全平台
共享(lib/widgets+screens)。up 节点 Opacity 1.0 无变化,golden 不动。加 down
状态 widget 测试。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:35:52 +08:00

208 lines
7.6 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;
final down = node.isDown; // 节点不可用(agent 离线):置灰 + 「不可用」+ 禁选。
final unavail = lang == AppLang.zh ? '不可用' : 'Unavailable';
return Opacity(
opacity: down ? 0.55 : 1.0,
child: Material(
color: (active && !down) ? c.accentSubtle : c.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(PangolinRadius.lg),
side: BorderSide(color: (active && !down) ? c.accentBorder : c.border, width: 1.5),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: down ? null : onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 11),
child: Row(children: [
CountryCode(code: node.code, active: active && !down),
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),
if (down)
Text(unavail,
style: PangolinText.caption.copyWith(color: c.danger, fontWeight: FontWeight.w600, fontSize: 12))
else ...[
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)],
],
]),
),
),
),
);
}
}