// 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 的自定义规则被更靠前的规则遮蔽(纯本地计算, // 首命中生效语义决定)。 // ②「系统强制走隧道,此规则不生效」——命中系统锁定目标,两类启发式并存: // - ip_cidr 落在私网/回环地址段(RFC1918 + 127.0.0.0/8)——恒被内置「局域网 / 私网 // 直连」(builtin.lanDirect,强制不可关)接管。 // - 域名类规则(domain/domain_suffix/domain_keyword)命中 RoutingProfile. // systemLockedDomains(FT-A 起 GET /v1/me/routing 下发的私有服务域名清单, // PANGOLIN_PRIVATE_SPLIT_DOMAINS)——服务端渲染时恒强制走隧道,与用户规则动作冲突。 import 'dart:io' show InternetAddress, InternetAddressType; 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 '../services/auth_api.dart' show AuthApiException; 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), ), ); } } /// 变更类操作统一守卫:await + 失败弹 SnackBar(不再静默回滚 / 抛未捕获异步异常)。 /// _persist 失败已回滚 state 并 rethrow,这里兜住并把原因告知用户;AuthApiException /// 带服务端双语文案(含 routing_invalid 校验错误),其余异常回退通用「保存失败」。 Future _guardSave(BuildContext context, AppText t, Future Function() op) async { try { await op(); } on AuthApiException catch (e) { if (!context.mounted) return; _showRoutingError(context, t.lang == AppLang.zh ? e.messageZh : e.messageEn); } catch (_) { if (!context.mounted) return; _showRoutingError(context, t.routingSaveFailed); } } void _showRoutingError(BuildContext context, String msg) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); } 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) => _guardSave(context, t, () => 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) => _guardSave(context, t, () => 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) => _guardSave(context, t, () => 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, profile.systemLockedDomains), i < profile.rules.length - 1, onDelete: () => _guardSave(context, t, () => 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), ]), ), ]), const SizedBox(height: 20), // ── 重置默认(桌面/移动共用同一按钮;二次确认防误触) ── PangolinButton( label: t.routingReset, icon: PangolinIcons.refreshCw, variant: PangolinButtonVariant.ghost, expand: true, onPressed: () => _confirmReset(context, ref, c, t), ), ], ); } Future _confirmReset(BuildContext context, WidgetRef ref, PangolinScheme c, AppText t) async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: c.surface, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)), title: Row(children: [ Container( width: 34, height: 34, decoration: BoxDecoration(color: c.dangerSubtle, shape: BoxShape.circle), child: Icon(PangolinIcons.refreshCw, size: 18, color: c.danger), ), const SizedBox(width: 12), Expanded( child: Text(t.routingResetConfirmTitle, overflow: TextOverflow.ellipsis, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700)), ), ]), content: Text(t.routingResetConfirmBody, style: PangolinText.sm.copyWith(color: c.fg2, height: 1.5)), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text(t.devCancel, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)), ), TextButton( onPressed: () => Navigator.pop(ctx, true), child: Text(t.routingResetConfirmAction, style: PangolinText.sm.copyWith(color: c.danger, fontWeight: FontWeight.w700)), ), ], ), ); if (ok != true) return; if (!context.mounted) return; await _guardSave(context, t, () => ref.read(routingProfileProvider.notifier).resetToDefault()); } Future _openAddDialog(BuildContext context, WidgetRef ref, AppText t) async { final rule = await showDialog(context: context, builder: (_) => _AddRuleDialog(t: t)); if (rule == null) return; if (!context.mounted) return; await _guardSave(context, t, () => 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, List systemLockedDomains) { final r = rules[i]; if (_looksSystemLocked(r, systemLockedDomains)) 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),见文件头注释。 final _privateIpPrefix = RegExp(r'^(10\.|192\.168\.|127\.|172\.(1[6-9]|2\d|3[01])\.)'); /// 系统锁定判定:ip_cidr 私网/回环启发式,或域名类规则命中 [systemLockedDomains]。 bool _looksSystemLocked(RoutingRule r, List systemLockedDomains) { if (r.type == 'ip_cidr') return _privateIpPrefix.hasMatch(r.value); if (systemLockedDomains.isEmpty) return false; final value = r.value.trim().toLowerCase(); if (value.isEmpty) return false; switch (r.type) { case 'domain': return systemLockedDomains.any((d) => d.toLowerCase() == value); case 'domain_suffix': return systemLockedDomains.any((d) { final dl = d.toLowerCase(); return dl == value || dl.endsWith('.$value'); }); case 'domain_keyword': return systemLockedDomains.any((d) => d.toLowerCase().contains(value)); default: return false; } } 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(); } /// 字段级预校验:只拦明显格式错(空由按钮禁用兜底),避免"乐观显示→服务端 400→静默消失"。 /// 语义级(保留段/catch-all direct)仍由服务端权威判定,经 SnackBar 呈现。返回 null=通过。 String? _valueError(AppText t) { final v = _value.text.trim(); if (v.isEmpty) return null; switch (_type) { case 'ip_cidr': if (!_isValidCidr(v)) return t.routingRuleValueInvalid; case 'geoip': case 'geosite': if (v.toLowerCase() != 'cn') return t.routingRuleValueInvalid; // geo 白名单仅 cn } return null; } bool _isValidCidr(String s) { final parts = s.split('/'); if (parts.length != 2) return false; final prefix = int.tryParse(parts[1]); if (prefix == null) return false; final addr = InternetAddress.tryParse(parts[0]); if (addr == null) return false; final max = addr.type == InternetAddressType.IPv6 ? 128 : 32; return prefix >= 0 && prefix <= max; } @override Widget build(BuildContext context) { final c = context.pangolin; final t = widget.t; final valueError = _valueError(t); final canSave = _value.text.trim().isNotEmpty && valueError == null; 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(() {}), ), ), if (valueError != null) Padding( padding: const EdgeInsets.only(top: 6, left: 4), child: Text(valueError, style: PangolinText.caption.copyWith(color: c.danger)), ), 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)), ), ); } }