diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index a5e3d5c..0b4ef8c 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -649,10 +649,18 @@ abstract class AppText { String get routingTypeDomainSuffixCi; // 域名后缀 · CI 源 / Domain suffix · CI source String get routingTypeDomainSuffixMirror; // 域名后缀 · 镜像 / Domain suffix · Mirror String get routingTypeGeosite; // GeoSite 规则集 / GeoSite rule-set + String get routingTypeDomain; // 域名 / Domain + String get routingTypeDomainKeyword; // 域名关键字 / Domain keyword + String get routingTypeIpCidr; // IP 段(CIDR) / IP range (CIDR) + String get routingTypeGeoip; // GeoIP 规则集 / GeoIP rule-set String get routingActDirect; // 直连 / Direct String get routingActProxy; // 走隧道 / Tunnel String get routingActReject; // 拒绝 / Reject String get routingForcedRow; // ⚠ 该域名由系统强制走隧道,此规则不生效 / ⚠ Forced to tunnel by the system; this rule is inactive + String get routingRuleShadowed; // 已被上面规则覆盖 / Overridden by a rule above + String get routingModeNote; // 「我的规则」仅在智能分流模式生效说明 / "My rules" only applies in Smart mode note + String get routingRuleValueHint; // 目标输入框占位 / Target input placeholder + String get routingNoRules; // 暂无自定义规则 / No custom rules yet String get routingAddRule; // 添加规则 / Add rule String get routingImport; // 从文本导入 / Import from text String get routingReset; // 重置默认 / Reset defaults diff --git a/client/lib/l10n/strings_en.dart b/client/lib/l10n/strings_en.dart index 73c0d39..5f7db51 100644 --- a/client/lib/l10n/strings_en.dart +++ b/client/lib/l10n/strings_en.dart @@ -543,6 +543,14 @@ class StringsEn extends AppText { @override String get routingTypeGeosite => 'GeoSite rule-set'; @override + String get routingTypeDomain => 'Domain'; + @override + String get routingTypeDomainKeyword => 'Domain keyword'; + @override + String get routingTypeIpCidr => 'IP range (CIDR)'; + @override + String get routingTypeGeoip => 'GeoIP rule-set'; + @override String get routingActDirect => 'Direct'; @override String get routingActProxy => 'Tunnel'; @@ -551,6 +559,14 @@ class StringsEn extends AppText { @override String get routingForcedRow => '⚠ Forced to tunnel by the system; this rule is inactive'; @override + String get routingRuleShadowed => 'Overridden by a rule above'; + @override + String get routingModeNote => '"My rules" only take effect in Smart mode. Global / Direct mode ignores custom rules entirely (system-forced rules still apply).'; + @override + String get routingRuleValueHint => 'e.g. example.com or 192.168.1.0/24'; + @override + String get routingNoRules => 'No custom rules yet'; + @override String get routingAddRule => 'Add rule'; @override String get routingImport => 'Import from text'; diff --git a/client/lib/l10n/strings_es.dart b/client/lib/l10n/strings_es.dart index 5f0b342..50722b9 100644 --- a/client/lib/l10n/strings_es.dart +++ b/client/lib/l10n/strings_es.dart @@ -543,6 +543,14 @@ class StringsEs extends AppText { @override String get routingTypeGeosite => 'Conjunto de reglas GeoSite'; @override + String get routingTypeDomain => 'Dominio'; + @override + String get routingTypeDomainKeyword => 'Palabra clave de dominio'; + @override + String get routingTypeIpCidr => 'Rango de IP (CIDR)'; + @override + String get routingTypeGeoip => 'Conjunto de reglas GeoIP'; + @override String get routingActDirect => 'Directo'; @override String get routingActProxy => 'Túnel'; @@ -551,6 +559,14 @@ class StringsEs extends AppText { @override String get routingForcedRow => '⚠ El sistema fuerza el túnel para este dominio; esta regla no se aplica'; @override + String get routingRuleShadowed => 'Sustituida por una regla anterior'; + @override + String get routingModeNote => '"Mis reglas" solo se aplican en modo Inteligente. El modo Global / Directo ignora las reglas personalizadas (las reglas forzadas por el sistema siguen aplicándose).'; + @override + String get routingRuleValueHint => 'p. ej. example.com o 192.168.1.0/24'; + @override + String get routingNoRules => 'Aún no hay reglas personalizadas'; + @override String get routingAddRule => 'Añadir regla'; @override String get routingImport => 'Importar desde texto'; diff --git a/client/lib/l10n/strings_ja.dart b/client/lib/l10n/strings_ja.dart index 7a3cdac..b14b2a2 100644 --- a/client/lib/l10n/strings_ja.dart +++ b/client/lib/l10n/strings_ja.dart @@ -543,6 +543,14 @@ class StringsJa extends AppText { @override String get routingTypeGeosite => 'GeoSite ルールセット'; @override + String get routingTypeDomain => 'ドメイン'; + @override + String get routingTypeDomainKeyword => 'ドメインキーワード'; + @override + String get routingTypeIpCidr => 'IP範囲(CIDR)'; + @override + String get routingTypeGeoip => 'GeoIP ルールセット'; + @override String get routingActDirect => '直接接続'; @override String get routingActProxy => 'トンネル'; @@ -551,6 +559,14 @@ class StringsJa extends AppText { @override String get routingForcedRow => '⚠ このドメインはシステムにより強制的にトンネル経由となり、このルールは無効です'; @override + String get routingRuleShadowed => '上のルールに上書きされました'; + @override + String get routingModeNote => '「マイルール」はスマート分流モードでのみ有効です。全体プロキシ / 全体直接接続では独自ルールは無視されます(システム強制ルールは引き続き適用)。'; + @override + String get routingRuleValueHint => '例:example.com または 192.168.1.0/24'; + @override + String get routingNoRules => 'カスタムルールはまだありません'; + @override String get routingAddRule => 'ルールを追加'; @override String get routingImport => 'テキストから読み込み'; diff --git a/client/lib/l10n/strings_ko.dart b/client/lib/l10n/strings_ko.dart index 1d4cb9c..0ecc8d9 100644 --- a/client/lib/l10n/strings_ko.dart +++ b/client/lib/l10n/strings_ko.dart @@ -543,6 +543,14 @@ class StringsKo extends AppText { @override String get routingTypeGeosite => 'GeoSite 규칙 세트'; @override + String get routingTypeDomain => '도메인'; + @override + String get routingTypeDomainKeyword => '도메인 키워드'; + @override + String get routingTypeIpCidr => 'IP 대역(CIDR)'; + @override + String get routingTypeGeoip => 'GeoIP 규칙 세트'; + @override String get routingActDirect => '직접 연결'; @override String get routingActProxy => '터널'; @@ -551,6 +559,14 @@ class StringsKo extends AppText { @override String get routingForcedRow => '⚠ 이 도메인은 시스템에 의해 강제로 터널을 사용하며, 이 규칙은 적용되지 않습니다'; @override + String get routingRuleShadowed => '위 규칙에 의해 재정의됨'; + @override + String get routingModeNote => '"내 규칙"은 스마트 분산 모드에서만 적용됩니다. 전체 프록시 / 전체 직접 연결에서는 사용자 규칙이 무시됩니다(시스템 강제 규칙은 계속 적용).'; + @override + String get routingRuleValueHint => '예: example.com 또는 192.168.1.0/24'; + @override + String get routingNoRules => '아직 사용자 규칙이 없습니다'; + @override String get routingAddRule => '규칙 추가'; @override String get routingImport => '텍스트에서 가져오기'; diff --git a/client/lib/l10n/strings_ru.dart b/client/lib/l10n/strings_ru.dart index a064b62..b5931b7 100644 --- a/client/lib/l10n/strings_ru.dart +++ b/client/lib/l10n/strings_ru.dart @@ -543,6 +543,14 @@ class StringsRu extends AppText { @override String get routingTypeGeosite => 'Набор правил GeoSite'; @override + String get routingTypeDomain => 'Домен'; + @override + String get routingTypeDomainKeyword => 'Ключевое слово домена'; + @override + String get routingTypeIpCidr => 'Диапазон IP (CIDR)'; + @override + String get routingTypeGeoip => 'Набор правил GeoIP'; + @override String get routingActDirect => 'Напрямую'; @override String get routingActProxy => 'Туннель'; @@ -551,6 +559,14 @@ class StringsRu extends AppText { @override String get routingForcedRow => '⚠ Домен принудительно направлен через туннель системой; правило неактивно'; @override + String get routingRuleShadowed => 'Перекрыто правилом выше'; + @override + String get routingModeNote => '«Мои правила» действуют только в режиме умной маршрутизации. Глобальный прокси / прямое соединение игнорируют пользовательские правила (принудительные системные правила по-прежнему применяются).'; + @override + String get routingRuleValueHint => 'например, example.com или 192.168.1.0/24'; + @override + String get routingNoRules => 'Пользовательских правил пока нет'; + @override String get routingAddRule => 'Добавить правило'; @override String get routingImport => 'Импорт из текста'; diff --git a/client/lib/l10n/strings_zh.dart b/client/lib/l10n/strings_zh.dart index 094e2df..a05f384 100644 --- a/client/lib/l10n/strings_zh.dart +++ b/client/lib/l10n/strings_zh.dart @@ -543,6 +543,14 @@ class StringsZh extends AppText { @override String get routingTypeGeosite => 'GeoSite 规则集'; @override + String get routingTypeDomain => '域名'; + @override + String get routingTypeDomainKeyword => '域名关键字'; + @override + String get routingTypeIpCidr => 'IP 段(CIDR)'; + @override + String get routingTypeGeoip => 'GeoIP 规则集'; + @override String get routingActDirect => '直连'; @override String get routingActProxy => '走隧道'; @@ -551,6 +559,14 @@ class StringsZh extends AppText { @override String get routingForcedRow => '⚠ 该域名由系统强制走隧道,此规则不生效'; @override + String get routingRuleShadowed => '已被上面规则覆盖'; + @override + String get routingModeNote => '「我的规则」仅在智能分流模式生效;全局代理 / 全部直连为一刀切,忽略自定义规则(系统强制层仍保留)。'; + @override + String get routingRuleValueHint => '如 example.com 或 192.168.1.0/24'; + @override + String get routingNoRules => '暂无自定义规则'; + @override String get routingAddRule => '添加规则'; @override String get routingImport => '从文本导入'; diff --git a/client/lib/widgets/pangolin_icons.dart b/client/lib/widgets/pangolin_icons.dart index 331847f..20c9224 100644 --- a/client/lib/widgets/pangolin_icons.dart +++ b/client/lib/widgets/pangolin_icons.dart @@ -42,6 +42,7 @@ class PangolinIcons { static const alertTriangle = LucideIcons.alertTriangle; static const x = LucideIcons.x; static const power = LucideIcons.power; + static const plus = LucideIcons.plus; static const loaderCircle = LucideIcons.loaderCircle; static const globe = LucideIcons.globe; static const user = LucideIcons.user; diff --git a/client/lib/widgets/routing_screen.dart b/client/lib/widgets/routing_screen.dart new file mode 100644 index 0000000..0f51641 --- /dev/null +++ b/client/lib/widgets/routing_screen.dart @@ -0,0 +1,459 @@ +// routing_screen.dart — 分流规则子屏(代理模式段选 + 内置规则 + 我的规则增删排序 + 添加弹层 + 冲突提示) +// +// 视觉对照 design/prototype/screens/ui-mobile.html(data-sub="routing")/ui-desktop.html +// (data-view="routing")。状态层 routing_provider.dart(Task 6):routingProfileProvider +// 的 setMode/setBuiltin/addRule/removeRule/reorder。 +// +// 冲突提示两类(prototype 里只是静态示例,这里落成真computed 逻辑): +// ①「已被上面规则覆盖」——同 type+value 的自定义规则被更靠前的规则遮蔽(纯本地计算, +// 首命中生效语义决定)。 +// ②「系统强制走隧道,此规则不生效」——命中系统锁定目标。当前 RoutingProfile 契约 +// 只下发 builtin 三个布尔开关,并未下发具体锁定域名清单(PANGOLIN_PRIVATE_SPLIT_DOMAINS +// 只在服务端 env,未经 API 暴露给客户端),故本屏对②采用保守启发式:仅当自定义规则 +// 类型为 ip_cidr 且落在私网/回环地址段(RFC1918 + 127.0.0.0/8)时标记——这部分恒被 +// 内置「局域网 / 私网直连」(builtin.lanDirect,强制不可关)接管,与用户规则动作冲突。 +// 域名级私有服务分流(如 git.yanmeiai.com)不在此启发式覆盖范围,需服务端把锁定域名 +// 清单下发给客户端后再补全(见 task-7-report.md 里的疑虑)。 +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../l10n/app_text.dart'; +import '../models/routing_profile.dart'; +import '../pangolin_theme.dart'; +import '../state/app_providers.dart'; +import '../state/routing_provider.dart'; +import 'pangolin_button.dart'; +import 'pangolin_field.dart'; +import 'pangolin_icons.dart'; +import 'seg_switch.dart'; +import 'sub_scaffold.dart'; + +/// 分流模式段选的选项顺序,与 [RoutingProfile.mode] 后端取值一一对应 +/// (UI 第二档「智能分流」= 后端 `rule`)。 +const _kModeValues = ['global', 'rule', 'direct']; + +class RoutingScreen extends ConsumerWidget { + const RoutingScreen({super.key, this.onBack, this.embedded = false}); + + final VoidCallback? onBack; + final bool embedded; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final c = context.pangolin; + final t = ref.watch(appTextProvider); + final profileAsync = ref.watch(routingProfileProvider); + + return SubScaffold( + title: t.routingRulesTitle, + onBack: onBack, + embedded: embedded, + child: profileAsync.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.loadFailedRetry, style: PangolinText.body.copyWith(color: c.fg3)), + ), + ), + data: (profile) => _RoutingBody(t: t, profile: profile), + ), + ); + } +} + +class _RoutingBody extends ConsumerWidget { + const _RoutingBody({required this.t, required this.profile}); + final AppText t; + final RoutingProfile profile; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final c = context.pangolin; + final notifier = ref.read(routingProfileProvider.notifier); + final smart = profile.mode == 'rule'; + final modeIdx = _kModeValues.indexOf(profile.mode); + final selectedIdx = modeIdx < 0 ? 1 : modeIdx; + + return ListView( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), + children: [ + // ── 代理模式段选 ── + _secLabel(c, t.routingProxyMode), + SegSwitch( + options: [ + (icon: PangolinIcons.globe, label: t.routingModeGlobal), + (icon: PangolinIcons.zap, label: t.routingModeSmart), + (icon: PangolinIcons.arrowRight, label: t.routingModeDirect), + ], + selectedIndex: selectedIdx, + onChanged: (i) => notifier.setMode(_kModeValues[i]), + ), + const SizedBox(height: 8), + Text(t.routingModeNote, style: PangolinText.caption.copyWith(color: c.fg3, height: 1.5)), + const SizedBox(height: 20), + + // ── 内置规则 ── + _secLabel(c, t.routingBuiltin), + _cardFlush(c, [ + _builtinToggleRow( + context, c, + icon: PangolinIcons.globe, + title: t.routingCnDirect, + sub: 'GeoIP / GeoSite CN', + value: profile.builtin.chinaDirect, + onChanged: (v) => notifier.setBuiltin(profile.builtin.copyWith(chinaDirect: v)), + last: false, + ), + _forcedRow(c, icon: PangolinIcons.home, title: t.routingLanDirect, sub: t.routingLanForced, pill: t.routingForcedPill), + ]), + const SizedBox(height: 20), + + // ── 我的规则(仅智能分流模式生效,其余模式灰化 + 忽略提示已由上方 modeNote 说明) ── + Opacity( + opacity: smart ? 1 : 0.5, + child: IgnorePointer( + ignoring: !smart, + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + _secLabel(c, t.routingMyRulesOrdered), + if (profile.rules.isEmpty) + _cardFlush(c, [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20), + child: Center(child: Text(t.routingNoRules, style: PangolinText.sm.copyWith(color: c.fg3))), + ), + ]) + else + Container( + decoration: BoxDecoration( + color: c.surface, + border: Border.all(color: c.border), + borderRadius: BorderRadius.circular(PangolinRadius.lg), + boxShadow: PangolinShadow.sm, + ), + clipBehavior: Clip.antiAlias, + child: ReorderableListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + onReorder: (oldIndex, newIndex) => notifier.reorder(oldIndex, newIndex), + children: [ + for (var i = 0; i < profile.rules.length; i++) + _ruleRow(context, c, t, i, profile.rules[i], + _conflictFor(profile.rules, i), i < profile.rules.length - 1, + onDelete: () => notifier.removeRule(i)), + ], + ), + ), + const SizedBox(height: 12), + PangolinButton( + label: t.routingAddRule, + icon: PangolinIcons.plus, + variant: PangolinButtonVariant.secondary, + expand: true, + onPressed: smart ? () => _openAddDialog(context, ref, t) : null, + ), + ]), + ), + ), + const SizedBox(height: 20), + + // ── FINAL 兜底 ── + _secLabel(c, t.routingFinalShort), + _cardFlush(c, [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + child: Row(children: [ + Expanded( + child: Text(t.routingFinalFallback, + style: PangolinText.body.copyWith(color: c.fg1, fontSize: 14, fontWeight: FontWeight.w500)), + ), + _actionPill(c, t, profile.finalAction), + ]), + ), + ]), + ], + ); + } + + Future _openAddDialog(BuildContext context, WidgetRef ref, AppText t) async { + final rule = await showDialog(context: context, builder: (_) => _AddRuleDialog(t: t)); + if (rule == null) return; + await ref.read(routingProfileProvider.notifier).addRule(rule); + } + + Widget _builtinToggleRow( + BuildContext context, + PangolinScheme c, { + required IconData icon, + required String title, + required String sub, + required bool value, + required ValueChanged onChanged, + required bool last, + }) { + return Container( + decoration: BoxDecoration(border: last ? null : Border(bottom: BorderSide(color: c.border))), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + child: Row(children: [ + _iconBox(c, icon), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(title, style: PangolinText.body.copyWith(color: c.fg1, fontSize: 14, fontWeight: FontWeight.w500)), + Text(sub, style: PangolinText.caption.copyWith(color: c.fg3)), + ]), + ), + Switch(value: value, activeThumbColor: c.accent, onChanged: onChanged), + ]), + ); + } + + Widget _forcedRow(PangolinScheme c, {required IconData icon, required String title, required String sub, required String pill}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + child: Row(children: [ + _iconBox(c, icon), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(title, style: PangolinText.body.copyWith(color: c.fg1, fontSize: 14, fontWeight: FontWeight.w500)), + Text(sub, style: PangolinText.caption.copyWith(color: c.fg3)), + ]), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)), + child: Text(pill, style: PangolinText.caption.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 11)), + ), + ]), + ); + } + + Widget _iconBox(PangolinScheme c, IconData icon) => Container( + width: 34, + height: 34, + decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)), + child: Icon(icon, size: 17, color: c.accent), + ); + + Widget _ruleRow( + BuildContext context, + PangolinScheme c, + AppText t, + int i, + RoutingRule rule, + _RuleConflict conflict, + bool divider, { + required VoidCallback onDelete, + }) { + final dim = conflict != _RuleConflict.none; + final subText = switch (conflict) { + _RuleConflict.shadowed => t.routingRuleShadowed, + _RuleConflict.systemLocked => t.routingForcedRow, + _RuleConflict.none => _routingTypeLabel(t, rule.type), + }; + return Container( + key: ValueKey('rt_rule_$i'), + decoration: BoxDecoration(border: divider ? Border(bottom: BorderSide(color: c.border)) : null), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + child: Opacity( + opacity: dim ? 0.55 : 1, + child: Row(children: [ + ReorderableDragStartListener( + index: i, + child: Padding( + padding: const EdgeInsets.only(right: 10), + child: Text('≡', style: PangolinText.body.copyWith(color: c.fg3, fontWeight: FontWeight.w700)), + ), + ), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(rule.value, style: PangolinText.mono.copyWith(color: c.fg1, fontSize: 14)), + const SizedBox(height: 2), + Text(subText, style: PangolinText.caption.copyWith(color: dim ? c.warning : c.fg3)), + ]), + ), + const SizedBox(width: 8), + _actionPill(c, t, rule.action), + IconButton( + onPressed: onDelete, + icon: Icon(PangolinIcons.trash2, size: 17, color: c.fg3), + padding: const EdgeInsets.only(left: 6), + constraints: const BoxConstraints(), + ), + ]), + ), + ); + } +} + +/// 规则行冲突类型:先判系统锁定(与规则顺序无关),再判是否被更靠前的同规则遮蔽。 +enum _RuleConflict { none, shadowed, systemLocked } + +_RuleConflict _conflictFor(List rules, int i) { + final r = rules[i]; + if (_looksSystemLocked(r)) return _RuleConflict.systemLocked; + for (var j = 0; j < i; j++) { + final o = rules[j]; + if (o.type == r.type && o.value.trim().toLowerCase() == r.value.trim().toLowerCase()) { + return _RuleConflict.shadowed; + } + } + return _RuleConflict.none; +} + +/// 私网/回环地址段启发式(RFC1918 + 127.0.0.0/8),见文件头注释——真正的系统锁定域名清单 +/// 未经 API 下发,这里只覆盖 ip_cidr 类型且落在该地址段的情形。 +final _privateIpPrefix = RegExp(r'^(10\.|192\.168\.|127\.|172\.(1[6-9]|2\d|3[01])\.)'); +bool _looksSystemLocked(RoutingRule r) => r.type == 'ip_cidr' && _privateIpPrefix.hasMatch(r.value); + +String _routingTypeLabel(AppText t, String type) => switch (type) { + 'domain' => t.routingTypeDomain, + 'domain_suffix' => t.routingTypeDomainSuffix, + 'domain_keyword' => t.routingTypeDomainKeyword, + 'ip_cidr' => t.routingTypeIpCidr, + 'geoip' => t.routingTypeGeoip, + 'geosite' => t.routingTypeGeosite, + _ => type, + }; + +Widget _actionPill(PangolinScheme c, AppText t, String action) { + late Color bg, fg; + late String label; + switch (action) { + case 'direct': + bg = c.successSubtle; + fg = c.success; + label = t.routingActDirect; + case 'reject': + bg = c.dangerSubtle; + fg = c.danger; + label = t.routingActReject; + case 'proxy': + default: + bg = c.warningSubtle; + fg = c.warning; + label = t.routingActProxy; + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(PangolinRadius.full)), + child: Text(label, style: PangolinText.caption.copyWith(color: fg, fontWeight: FontWeight.w700, fontSize: 11)), + ); +} + +Widget _secLabel(PangolinScheme c, String text) => Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 10), + child: Text(text, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 13)), + ); + +Widget _cardFlush(PangolinScheme c, List children) => Container( + decoration: BoxDecoration( + color: c.surface, + border: Border.all(color: c.border), + borderRadius: BorderRadius.circular(PangolinRadius.lg), + boxShadow: PangolinShadow.sm, + ), + clipBehavior: Clip.antiAlias, + child: Column(children: children), + ); + +/// 「添加规则」弹层:类型 chip 选择 + 目标输入(mono)+ 动作段选。 +/// 仿 account_screens.dart:_renameDialog 的 AlertDialog 表单范式。 +class _AddRuleDialog extends StatefulWidget { + const _AddRuleDialog({required this.t}); + final AppText t; + + @override + State<_AddRuleDialog> createState() => _AddRuleDialogState(); +} + +class _AddRuleDialogState extends State<_AddRuleDialog> { + String _type = kRuleTypes.first; + String _action = kRuleActions.first; + final _value = TextEditingController(); + + @override + void dispose() { + _value.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final c = context.pangolin; + final t = widget.t; + final canSave = _value.text.trim().isNotEmpty; + + return AlertDialog( + backgroundColor: c.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)), + title: Text(t.routingAddRule, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700)), + content: SizedBox( + width: 340, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [for (final ty in kRuleTypes) _typeChip(c, t, ty)], + ), + const SizedBox(height: 14), + PangolinFieldBox( + fill: c.bg, + child: TextField( + controller: _value, + autofocus: true, + style: PangolinText.mono.copyWith(color: c.fg1, fontSize: 14), + decoration: bareInputDecoration( + contentPadding: const EdgeInsets.symmetric(vertical: 13), + hintText: t.routingRuleValueHint, + hintStyle: PangolinText.sm.copyWith(color: c.fg3), + ), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(height: 14), + SegSwitch( + options: [ + (icon: PangolinIcons.checkCircle, label: t.routingActDirect), + (icon: PangolinIcons.zap, label: t.routingActProxy), + (icon: PangolinIcons.x, label: t.routingActReject), + ], + selectedIndex: kRuleActions.indexOf(_action), + onChanged: (i) => setState(() => _action = kRuleActions[i]), + ), + ]), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(t.devCancel, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)), + ), + TextButton( + onPressed: canSave + ? () => Navigator.pop(context, RoutingRule(type: _type, value: _value.text.trim(), action: _action)) + : null, + child: Text(t.devSave, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)), + ), + ], + ); + } + + Widget _typeChip(PangolinScheme c, AppText t, String type) { + final active = type == _type; + return GestureDetector( + onTap: () => setState(() => _type = type), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: active ? c.accentSubtle : c.bgSubtle, + border: Border.all(color: active ? c.accentBorder : c.border), + borderRadius: BorderRadius.circular(PangolinRadius.full), + ), + child: Text(_routingTypeLabel(t, type), + style: PangolinText.caption.copyWith(color: active ? c.accent : c.fg2, fontWeight: FontWeight.w600)), + ), + ); + } +} diff --git a/client/test/widget/routing_screen_test.dart b/client/test/widget/routing_screen_test.dart new file mode 100644 index 0000000..0131a4e --- /dev/null +++ b/client/test/widget/routing_screen_test.dart @@ -0,0 +1,113 @@ +// routing_screen_test.dart — 分流规则子屏冒烟测试:模式段选 + 规则行 + 添加按钮均可见。 +// +// 仿 test/widget/invite_page_test.dart 的 _FakeNotifier 范式:直接 extends +// RoutingProfileNotifier 覆写 build(),绕过真实账户 API。 +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/l10n/strings_zh.dart'; +import 'package:pangolin_vpn/models/routing_profile.dart'; +import 'package:pangolin_vpn/pangolin_theme.dart'; +import 'package:pangolin_vpn/state/app_providers.dart'; +import 'package:pangolin_vpn/state/routing_provider.dart'; +import 'package:pangolin_vpn/widgets/routing_screen.dart'; + +import '../helpers/harness.dart'; + +void main() { + setUpAll(disableGoogleFontsFetching); + + testWidgets('routing screen shows mode seg + rules + add', (tester) async { + final profile = RoutingProfile.fromJson({ + 'mode': 'rule', + 'builtin': {'china_direct': true, 'lan_direct': true, 'private_via_tunnel': true}, + 'rules': [ + {'type': 'domain_suffix', 'value': 'x.com', 'action': 'direct'}, + ], + 'final': 'proxy', + }); + + await tester.pumpWidget(ProviderScope( + overrides: [ + appTextProvider.overrideWithValue(StringsZh()), + routingProfileProvider.overrideWith(() => _FakeRoutingNotifier(profile)), + ], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: PangolinTheme.light, + home: const RoutingScreen(), + ), + )); + await tester.pumpAndSettle(); + + // 模式段选(全局/智能分流/全部直连)存在。 + expect(find.text(StringsZh().routingModeSmart), findsWidgets); + // 规则行:目标值展示。 + expect(find.text('x.com'), findsOneWidget); + // 添加规则按钮存在。 + expect(find.text(StringsZh().routingAddRule), findsOneWidget); + }); + + testWidgets('non-smart mode dims the my-rules section', (tester) async { + final profile = RoutingProfile.fromJson({ + 'mode': 'global', + 'builtin': {'china_direct': true, 'lan_direct': true, 'private_via_tunnel': true}, + 'rules': [ + {'type': 'domain_suffix', 'value': 'x.com', 'action': 'direct'}, + ], + 'final': 'proxy', + }); + + await tester.pumpWidget(ProviderScope( + overrides: [ + appTextProvider.overrideWithValue(StringsZh()), + routingProfileProvider.overrideWith(() => _FakeRoutingNotifier(profile)), + ], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: PangolinTheme.light, + home: const RoutingScreen(), + ), + )); + await tester.pumpAndSettle(); + + final opacity = tester.widget(find + .ancestor(of: find.text(StringsZh().routingMyRulesOrdered), matching: find.byType(Opacity)) + .first); + expect(opacity.opacity, lessThan(1)); + }); + + testWidgets('a rule shadowed by an earlier identical rule is flagged', (tester) async { + final profile = RoutingProfile.fromJson({ + 'mode': 'rule', + 'builtin': {'china_direct': true, 'lan_direct': true, 'private_via_tunnel': true}, + 'rules': [ + {'type': 'domain_suffix', 'value': 'dup.com', 'action': 'direct'}, + {'type': 'domain_suffix', 'value': 'dup.com', 'action': 'proxy'}, + ], + 'final': 'proxy', + }); + + await tester.pumpWidget(ProviderScope( + overrides: [ + appTextProvider.overrideWithValue(StringsZh()), + routingProfileProvider.overrideWith(() => _FakeRoutingNotifier(profile)), + ], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: PangolinTheme.light, + home: const RoutingScreen(), + ), + )); + await tester.pumpAndSettle(); + + expect(find.text(StringsZh().routingRuleShadowed), findsOneWidget); + }); +} + +class _FakeRoutingNotifier extends RoutingProfileNotifier { + _FakeRoutingNotifier(this._v); + final RoutingProfile _v; + @override + Future build() async => _v; +} diff --git a/design/i18n/strings.json b/design/i18n/strings.json index 506fe19..da9bb79 100644 --- a/design/i18n/strings.json +++ b/design/i18n/strings.json @@ -2129,6 +2129,38 @@ "ru": "Набор правил GeoSite", "es": "Conjunto de reglas GeoSite" }, + "routingTypeDomain": { + "zh": "域名", + "en": "Domain", + "ja": "ドメイン", + "ko": "도메인", + "ru": "Домен", + "es": "Dominio" + }, + "routingTypeDomainKeyword": { + "zh": "域名关键字", + "en": "Domain keyword", + "ja": "ドメインキーワード", + "ko": "도메인 키워드", + "ru": "Ключевое слово домена", + "es": "Palabra clave de dominio" + }, + "routingTypeIpCidr": { + "zh": "IP 段(CIDR)", + "en": "IP range (CIDR)", + "ja": "IP範囲(CIDR)", + "ko": "IP 대역(CIDR)", + "ru": "Диапазон IP (CIDR)", + "es": "Rango de IP (CIDR)" + }, + "routingTypeGeoip": { + "zh": "GeoIP 规则集", + "en": "GeoIP rule-set", + "ja": "GeoIP ルールセット", + "ko": "GeoIP 규칙 세트", + "ru": "Набор правил GeoIP", + "es": "Conjunto de reglas GeoIP" + }, "routingActDirect": { "zh": "直连", "en": "Direct", @@ -2161,6 +2193,38 @@ "ru": "⚠ Домен принудительно направлен через туннель системой; правило неактивно", "es": "⚠ El sistema fuerza el túnel para este dominio; esta regla no se aplica" }, + "routingRuleShadowed": { + "zh": "已被上面规则覆盖", + "en": "Overridden by a rule above", + "ja": "上のルールに上書きされました", + "ko": "위 규칙에 의해 재정의됨", + "ru": "Перекрыто правилом выше", + "es": "Sustituida por una regla anterior" + }, + "routingModeNote": { + "zh": "「我的规则」仅在智能分流模式生效;全局代理 / 全部直连为一刀切,忽略自定义规则(系统强制层仍保留)。", + "en": "\"My rules\" only take effect in Smart mode. Global / Direct mode ignores custom rules entirely (system-forced rules still apply).", + "ja": "「マイルール」はスマート分流モードでのみ有効です。全体プロキシ / 全体直接接続では独自ルールは無視されます(システム強制ルールは引き続き適用)。", + "ko": "\"내 규칙\"은 스마트 분산 모드에서만 적용됩니다. 전체 프록시 / 전체 직접 연결에서는 사용자 규칙이 무시됩니다(시스템 강제 규칙은 계속 적용).", + "ru": "«Мои правила» действуют только в режиме умной маршрутизации. Глобальный прокси / прямое соединение игнорируют пользовательские правила (принудительные системные правила по-прежнему применяются).", + "es": "\"Mis reglas\" solo se aplican en modo Inteligente. El modo Global / Directo ignora las reglas personalizadas (las reglas forzadas por el sistema siguen aplicándose)." + }, + "routingRuleValueHint": { + "zh": "如 example.com 或 192.168.1.0/24", + "en": "e.g. example.com or 192.168.1.0/24", + "ja": "例:example.com または 192.168.1.0/24", + "ko": "예: example.com 또는 192.168.1.0/24", + "ru": "например, example.com или 192.168.1.0/24", + "es": "p. ej. example.com o 192.168.1.0/24" + }, + "routingNoRules": { + "zh": "暂无自定义规则", + "en": "No custom rules yet", + "ja": "カスタムルールはまだありません", + "ko": "아직 사용자 규칙이 없습니다", + "ru": "Пользовательских правил пока нет", + "es": "Aún no hay reglas personalizadas" + }, "routingAddRule": { "zh": "添加规则", "en": "Add rule",