diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index d06402b..6793529 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -665,5 +665,8 @@ abstract class AppText { String get routingAddRule; // 添加规则 / Add rule String get routingImport; // 从文本导入 / Import from text String get routingReset; // 重置默认 / Reset defaults + String get routingResetConfirmTitle; // 重置为默认规则? / Reset to defaults? + String get routingResetConfirmBody; // 重置确认弹层正文 / Reset confirm dialog body + String get routingResetConfirmAction; // 重置(确认按钮) / Reset (confirm action) String get routingBack; // 返回设置 / Back to settings } diff --git a/client/lib/l10n/strings_en.dart b/client/lib/l10n/strings_en.dart index 9039b3e..e1ec154 100644 --- a/client/lib/l10n/strings_en.dart +++ b/client/lib/l10n/strings_en.dart @@ -575,6 +575,12 @@ class StringsEn extends AppText { @override String get routingReset => 'Reset defaults'; @override + String get routingResetConfirmTitle => 'Reset to defaults?'; + @override + String get routingResetConfirmBody => 'This clears all your custom rules and restores the factory routing table.'; + @override + String get routingResetConfirmAction => 'Reset'; + @override String get routingBack => 'Back to settings'; @override List get days7 => const ['M', 'T', 'W', 'T', 'F', 'S', 'S']; diff --git a/client/lib/l10n/strings_es.dart b/client/lib/l10n/strings_es.dart index 4f19091..ee76fbd 100644 --- a/client/lib/l10n/strings_es.dart +++ b/client/lib/l10n/strings_es.dart @@ -575,6 +575,12 @@ class StringsEs extends AppText { @override String get routingReset => 'Restablecer'; @override + String get routingResetConfirmTitle => '¿Restablecer las reglas predeterminadas?'; + @override + String get routingResetConfirmBody => 'Esto borra todas tus reglas personalizadas y restaura la tabla de enrutamiento de fábrica.'; + @override + String get routingResetConfirmAction => 'Restablecer'; + @override String get routingBack => 'Volver a ajustes'; @override List get days7 => const ['L', 'M', 'X', 'J', 'V', 'S', 'D']; diff --git a/client/lib/l10n/strings_ja.dart b/client/lib/l10n/strings_ja.dart index 5b4209b..14ac49f 100644 --- a/client/lib/l10n/strings_ja.dart +++ b/client/lib/l10n/strings_ja.dart @@ -575,6 +575,12 @@ class StringsJa extends AppText { @override String get routingReset => 'デフォルトに戻す'; @override + String get routingResetConfirmTitle => 'デフォルトのルールに戻しますか?'; + @override + String get routingResetConfirmBody => 'カスタムルールをすべて削除し、出荷時の分流テーブルに戻します。'; + @override + String get routingResetConfirmAction => 'リセット'; + @override String get routingBack => '設定に戻る'; @override List get days7 => const ['月', '火', '水', '木', '金', '土', '日']; diff --git a/client/lib/l10n/strings_ko.dart b/client/lib/l10n/strings_ko.dart index 0b735e6..85f0637 100644 --- a/client/lib/l10n/strings_ko.dart +++ b/client/lib/l10n/strings_ko.dart @@ -575,6 +575,12 @@ class StringsKo extends AppText { @override String get routingReset => '기본값 재설정'; @override + String get routingResetConfirmTitle => '기본 규칙으로 재설정할까요?'; + @override + String get routingResetConfirmBody => '사용자 지정 규칙을 모두 삭제하고 초기 분산 규칙으로 복원합니다.'; + @override + String get routingResetConfirmAction => '재설정'; + @override String get routingBack => '설정으로 돌아가기'; @override List get days7 => const ['월', '화', '수', '목', '금', '토', '일']; diff --git a/client/lib/l10n/strings_ru.dart b/client/lib/l10n/strings_ru.dart index b8dcf8a..e5b1627 100644 --- a/client/lib/l10n/strings_ru.dart +++ b/client/lib/l10n/strings_ru.dart @@ -575,6 +575,12 @@ class StringsRu extends AppText { @override String get routingReset => 'Сбросить настройки'; @override + String get routingResetConfirmTitle => 'Сбросить к правилам по умолчанию?'; + @override + String get routingResetConfirmBody => 'Это удалит все ваши пользовательские правила и восстановит заводскую таблицу маршрутизации.'; + @override + String get routingResetConfirmAction => 'Сбросить'; + @override String get routingBack => 'Назад к настройкам'; @override List get days7 => const ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; diff --git a/client/lib/l10n/strings_zh.dart b/client/lib/l10n/strings_zh.dart index e6a53bd..10bafe4 100644 --- a/client/lib/l10n/strings_zh.dart +++ b/client/lib/l10n/strings_zh.dart @@ -575,6 +575,12 @@ class StringsZh extends AppText { @override String get routingReset => '重置默认'; @override + String get routingResetConfirmTitle => '重置为默认规则?'; + @override + String get routingResetConfirmBody => '将清除你自定义的所有规则,恢复到出厂分流表。'; + @override + String get routingResetConfirmAction => '重置'; + @override String get routingBack => '返回设置'; @override List get days7 => const ['一', '二', '三', '四', '五', '六', '日']; diff --git a/client/lib/models/routing_profile.dart b/client/lib/models/routing_profile.dart index cc0c8cc..271b821 100644 --- a/client/lib/models/routing_profile.dart +++ b/client/lib/models/routing_profile.dart @@ -1,8 +1,10 @@ // routing_profile.dart — 可配置分流档案(GET/POST /v1/me/routing)。 // // 对齐后端契约(server/internal/httpapi 分流端点): -// {mode, builtin:{china_direct,lan_direct,private_via_tunnel}, rules:[...], final}。 +// {mode, builtin:{china_direct,lan_direct,private_via_tunnel}, rules:[...], final, +// system_locked_domains:[...]}(FT-A 起响应含只读私有服务域名清单)。 // 手写 fromJson/toJson(项目无 json_serializable,仿 lib/models/me.dart)。 +import 'package:flutter/foundation.dart' show listEquals; /// 分流模式。 /// 'rule' = 按规则(内置 + 自定义) | 'global' = 全局代理 | 'direct' = 全局直连。 @@ -55,8 +57,24 @@ class Builtin { lanDirect: lanDirect ?? this.lanDirect, privateViaTunnel: privateViaTunnel ?? this.privateViaTunnel, ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Builtin && + runtimeType == other.runtimeType && + chinaDirect == other.chinaDirect && + lanDirect == other.lanDirect && + privateViaTunnel == other.privateViaTunnel; + + @override + int get hashCode => Object.hash(chinaDirect, lanDirect, privateViaTunnel); } +/// copyWith 的哨兵值:用于区分「未传参(保留原值)」与「显式传 null(清空该字段)」, +/// 仅 [RoutingRule.note] 需要(其它字段无「清空」语义,`??` 已够用)。 +const _noteSentinel = Object(); + /// 单条自定义分流规则。 /// /// **enabled 语义**(计划裁决):构造默认 `true`;[toJson] 恒输出 `enabled` 字段 @@ -95,20 +113,35 @@ class RoutingRule { 'enabled': enabled, }; + /// [note] 用哨兵:不传 → 保留原值;显式传 `null` → 清空为 null。 RoutingRule copyWith({ String? type, String? value, String? action, - String? note, + Object? note = _noteSentinel, bool? enabled, }) => RoutingRule( type: type ?? this.type, value: value ?? this.value, action: action ?? this.action, - note: note ?? this.note, + note: identical(note, _noteSentinel) ? this.note : note as String?, enabled: enabled ?? this.enabled, ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RoutingRule && + runtimeType == other.runtimeType && + type == other.type && + value == other.value && + action == other.action && + note == other.note && + enabled == other.enabled; + + @override + int get hashCode => Object.hash(type, value, action, note, enabled); } /// 分流档案(账户级,单份)。 @@ -118,6 +151,7 @@ class RoutingProfile { this.builtin = const Builtin(), this.rules = const [], this.finalAction = 'proxy', + this.systemLockedDomains = const [], }); final RoutingMode mode; @@ -127,6 +161,11 @@ class RoutingProfile { /// 兜底动作(JSON key 为保留字 `final`,Dart 侧改名)。'proxy' | 'direct'。 final String finalAction; + /// **只读**:系统强制走隧道的私有服务域名清单(服务端 `PANGOLIN_PRIVATE_SPLIT_DOMAINS`, + /// FT-A 起随 GET /v1/me/routing 下发)。`toJson()` 不输出——服务端忽略,且此字段 + /// 客户端不可写。 + final List systemLockedDomains; + factory RoutingProfile.fromJson(Map m) => RoutingProfile( mode: m['mode'] as String? ?? 'rule', builtin: m['builtin'] != null @@ -136,6 +175,8 @@ class RoutingProfile { .map((e) => RoutingRule.fromJson(e as Map)) .toList(), finalAction: m['final'] as String? ?? 'proxy', + systemLockedDomains: + (m['system_locked_domains'] as List?)?.cast() ?? const [], ); Map toJson() => { @@ -145,16 +186,48 @@ class RoutingProfile { 'final': finalAction, }; + /// 出厂默认档案(对齐服务端 `routing.Default()`):智能分流 + 内置全开 + 无自定义规则。 + /// 不含 systemLockedDomains(那是账户相关的只读回显,由调用方按需保留;见 + /// RoutingProfileNotifier.resetToDefault())。 + factory RoutingProfile.defaults() => const RoutingProfile( + mode: 'rule', + builtin: Builtin(), + rules: [], + finalAction: 'proxy', + ); + RoutingProfile copyWith({ RoutingMode? mode, Builtin? builtin, List? rules, String? finalAction, + List? systemLockedDomains, }) => RoutingProfile( mode: mode ?? this.mode, builtin: builtin ?? this.builtin, rules: rules ?? this.rules, finalAction: finalAction ?? this.finalAction, + systemLockedDomains: systemLockedDomains ?? this.systemLockedDomains, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RoutingProfile && + runtimeType == other.runtimeType && + mode == other.mode && + builtin == other.builtin && + listEquals(rules, other.rules) && + finalAction == other.finalAction && + listEquals(systemLockedDomains, other.systemLockedDomains); + + @override + int get hashCode => Object.hash( + mode, + builtin, + Object.hashAll(rules), + finalAction, + Object.hashAll(systemLockedDomains), ); } diff --git a/client/lib/state/routing_provider.dart b/client/lib/state/routing_provider.dart index 7bd730a..6698c8d 100644 --- a/client/lib/state/routing_provider.dart +++ b/client/lib/state/routing_provider.dart @@ -69,6 +69,14 @@ class RoutingProfileNotifier extends AsyncNotifier { return p.copyWith(rules: rules); }); + /// 重置为出厂默认档案(模式/内置开关/自定义规则/final 全部还原),但保留当前只读的 + /// [RoutingProfile.systemLockedDomains] 回显不丢——它不是用户可改的档案内容,只是 + /// 服务端下发的账户相关信息,与「重置」无关。复用 [_mutateAndSave]/[_persist]: + /// 乐观更新 + 失败回滚 rethrow + 存成功后连接态 on 则自动重连,同 addRule。 + Future resetToDefault() => _mutateAndSave( + (p) => RoutingProfile.defaults().copyWith(systemLockedDomains: p.systemLockedDomains), + ); + Future _mutateAndSave(RoutingProfile Function(RoutingProfile) transform) async { final prev = state.valueOrNull ?? const RoutingProfile(); final next = transform(prev); diff --git a/client/lib/widgets/routing_screen.dart b/client/lib/widgets/routing_screen.dart index 0f51641..aaa6058 100644 --- a/client/lib/widgets/routing_screen.dart +++ b/client/lib/widgets/routing_screen.dart @@ -7,13 +7,12 @@ // 冲突提示两类(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 里的疑虑)。 +// ②「系统强制走隧道,此规则不生效」——命中系统锁定目标,两类启发式并存: +// - 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 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -139,8 +138,10 @@ class _RoutingBody extends ConsumerWidget { 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, + _ruleRow( + context, c, t, i, profile.rules[i], + _conflictFor(profile.rules, i, profile.systemLockedDomains), + i < profile.rules.length - 1, onDelete: () => notifier.removeRule(i)), ], ), @@ -172,10 +173,56 @@ class _RoutingBody extends ConsumerWidget { ]), ), ]), + 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; + await 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; @@ -291,9 +338,9 @@ class _RoutingBody extends ConsumerWidget { /// 规则行冲突类型:先判系统锁定(与规则顺序无关),再判是否被更靠前的同规则遮蔽。 enum _RuleConflict { none, shadowed, systemLocked } -_RuleConflict _conflictFor(List rules, int i) { +_RuleConflict _conflictFor(List rules, int i, List systemLockedDomains) { final r = rules[i]; - if (_looksSystemLocked(r)) return _RuleConflict.systemLocked; + 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()) { @@ -303,10 +350,29 @@ _RuleConflict _conflictFor(List rules, int i) { return _RuleConflict.none; } -/// 私网/回环地址段启发式(RFC1918 + 127.0.0.0/8),见文件头注释——真正的系统锁定域名清单 -/// 未经 API 下发,这里只覆盖 ip_cidr 类型且落在该地址段的情形。 +/// 私网/回环地址段启发式(RFC1918 + 127.0.0.0/8),见文件头注释。 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); + +/// 系统锁定判定: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, diff --git a/client/test/unit/routing_profile_model_test.dart b/client/test/unit/routing_profile_model_test.dart index fd48fba..e326879 100644 --- a/client/test/unit/routing_profile_model_test.dart +++ b/client/test/unit/routing_profile_model_test.dart @@ -36,4 +36,67 @@ void main() { expect(p.builtin.lanDirect, isTrue); expect(p.builtin.privateViaTunnel, isTrue); }); + + test('systemLockedDomains:fromJson 读取,默认空,toJson 不输出(只读字段)', () { + final withLocks = RoutingProfile.fromJson({ + 'mode': 'rule', + 'rules': [], + 'final': 'proxy', + 'system_locked_domains': ['git.yanmeiai.com', 'admin.yanmeiai.com'], + }); + expect(withLocks.systemLockedDomains, ['git.yanmeiai.com', 'admin.yanmeiai.com']); + expect(withLocks.toJson().containsKey('system_locked_domains'), isFalse); + + final withoutLocks = RoutingProfile.fromJson({'mode': 'rule', 'rules': [], 'final': 'proxy'}); + expect(withoutLocks.systemLockedDomains, isEmpty); + }); + + test('RoutingProfile.defaults() 对齐服务端 routing.Default()', () { + final d = RoutingProfile.defaults(); + expect(d.mode, 'rule'); + expect(d.builtin.chinaDirect, isTrue); + expect(d.builtin.lanDirect, isTrue); + expect(d.builtin.privateViaTunnel, isTrue); + expect(d.rules, isEmpty); + expect(d.finalAction, 'proxy'); + }); + + test('RoutingRule.copyWith 用哨兵让 note 可清空为 null', () { + const r = RoutingRule(type: 'domain', value: 'a.com', action: 'proxy', note: 'ci'); + final cleared = r.copyWith(note: null); + expect(cleared.note, isNull, reason: '显式传 null 应清空 note,而不是被 ?? 吞掉'); + + final unchanged = r.copyWith(value: 'b.com'); + expect(unchanged.note, 'ci', reason: '不传 note 时应保留原值'); + }); + + test('RoutingRule / Builtin / RoutingProfile 值相等(operator==)', () { + const r1 = RoutingRule(type: 'domain', value: 'a.com', action: 'proxy', note: 'x'); + const r2 = RoutingRule(type: 'domain', value: 'a.com', action: 'proxy', note: 'x'); + const r3 = RoutingRule(type: 'domain', value: 'a.com', action: 'direct', note: 'x'); + expect(r1, r2); + expect(r1.hashCode, r2.hashCode); + expect(r1 == r3, isFalse); + + const b1 = Builtin(chinaDirect: true, lanDirect: false, privateViaTunnel: true); + const b2 = Builtin(chinaDirect: true, lanDirect: false, privateViaTunnel: true); + const b3 = Builtin(); + expect(b1, b2); + expect(b1.hashCode, b2.hashCode); + expect(b1 == b3, isFalse); + + const p1 = RoutingProfile(mode: 'rule', rules: [r1], finalAction: 'proxy'); + const p2 = RoutingProfile(mode: 'rule', rules: [r2], finalAction: 'proxy'); + final p3 = RoutingProfile.fromJson({ + 'mode': 'rule', + 'rules': [ + {'type': 'domain', 'value': 'a.com', 'action': 'proxy', 'note': 'x', 'enabled': true} + ], + 'final': 'proxy', + 'system_locked_domains': ['git.yanmeiai.com'], + }); + expect(p1, p2); + expect(p1.hashCode, p2.hashCode); + expect(p1 == p3, isFalse, reason: 'systemLockedDomains 不同应视为不相等'); + }); } diff --git a/client/test/unit/routing_provider_test.dart b/client/test/unit/routing_provider_test.dart index 2130a48..ef44a90 100644 --- a/client/test/unit/routing_provider_test.dart +++ b/client/test/unit/routing_provider_test.dart @@ -115,6 +115,36 @@ void main() { expect(api.saved, hasLength(3)); }); + test('resetToDefault() 还原出厂档案,保留 systemLockedDomains,并落盘', () async { + final api = _FakeAccountApi() + ..initial = RoutingProfile.fromJson({ + 'mode': 'global', + 'builtin': {'china_direct': false, 'lan_direct': true, 'private_via_tunnel': true}, + 'rules': [ + {'type': 'domain', 'value': 'a.com', 'action': 'reject'}, + ], + 'final': 'direct', + 'system_locked_domains': ['git.yanmeiai.com'], + }); + final c = _makeContainer(api); + addTearDown(c.dispose); + await _waitLoggedIn(c); + await c.read(routingProfileProvider.future); + + await c.read(routingProfileProvider.notifier).resetToDefault(); + + final state = c.read(routingProfileProvider).value!; + expect(state.mode, 'rule'); + expect(state.builtin.chinaDirect, isTrue); + expect(state.rules, isEmpty); + expect(state.finalAction, 'proxy'); + expect(state.systemLockedDomains, ['git.yanmeiai.com'], + reason: '只读字段随重置保留,不因重置默认档案而丢失'); + + expect(api.saved, hasLength(1)); + expect(api.saved.single.mode, 'rule'); + }); + test('保存失败:回退到变更前 state 且 rethrow,不静默吞', () async { final api = _FakeAccountApi(); final c = _makeContainer(api); diff --git a/client/test/widget/routing_screen_test.dart b/client/test/widget/routing_screen_test.dart index 0131a4e..df1797f 100644 --- a/client/test/widget/routing_screen_test.dart +++ b/client/test/widget/routing_screen_test.dart @@ -103,11 +103,101 @@ void main() { expect(find.text(StringsZh().routingRuleShadowed), findsOneWidget); }); + + testWidgets('a domain rule matching a system-locked domain is flagged forced', (tester) async { + final profile = RoutingProfile.fromJson({ + 'mode': 'rule', + 'builtin': {'china_direct': true, 'lan_direct': true, 'private_via_tunnel': true}, + 'rules': [ + {'type': 'domain_suffix', 'value': 'git.yanmeiai.com', 'action': 'direct'}, + ], + 'final': 'proxy', + 'system_locked_domains': ['git.yanmeiai.com'], + }); + + 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().routingForcedRow), findsOneWidget); + }); + + testWidgets('reset button opens confirm dialog and resets on confirm', (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', + }); + + final notifier = _FakeRoutingNotifier(profile); + await tester.pumpWidget(ProviderScope( + overrides: [ + appTextProvider.overrideWithValue(StringsZh()), + routingProfileProvider.overrideWith(() => notifier), + ], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: PangolinTheme.light, + home: const RoutingScreen(), + ), + )); + await tester.pumpAndSettle(); + + expect(find.text('x.com'), findsOneWidget); + + // 重置按钮在 FINAL 区之后,滚到可见再点(屏内不止一个 Scrollable,取外层 ListView)。 + await tester.scrollUntilVisible( + find.text(StringsZh().routingReset), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text(StringsZh().routingReset)); + await tester.pumpAndSettle(); + + // 确认弹层出现,取消不触发重置。 + expect(find.text(StringsZh().routingResetConfirmBody), findsOneWidget); + await tester.tap(find.text(StringsZh().devCancel)); + await tester.pumpAndSettle(); + expect(notifier.resetCalled, isFalse); + expect(find.text('x.com'), findsOneWidget); + + // 再次点击并确认 → 触发重置。 + await tester.tap(find.text(StringsZh().routingReset)); + await tester.pumpAndSettle(); + await tester.tap(find.text(StringsZh().routingResetConfirmAction)); + await tester.pumpAndSettle(); + + expect(notifier.resetCalled, isTrue); + expect(find.text('x.com'), findsNothing); + }); } class _FakeRoutingNotifier extends RoutingProfileNotifier { _FakeRoutingNotifier(this._v); final RoutingProfile _v; + bool resetCalled = false; + @override Future build() async => _v; + + /// 绕过真实 _persist(需要 accountApiProvider/网络),只验证「确认后触发重置」的 + /// UI 交互链路;真正的 _persist 复用行为由 routing_provider_test.dart 覆盖。 + @override + Future resetToDefault() async { + resetCalled = true; + state = AsyncData(RoutingProfile.defaults().copyWith(systemLockedDomains: _v.systemLockedDomains)); + } } diff --git a/design/i18n/strings.json b/design/i18n/strings.json index 6e749c9..ec41aaf 100644 --- a/design/i18n/strings.json +++ b/design/i18n/strings.json @@ -2257,6 +2257,30 @@ "ru": "Сбросить настройки", "es": "Restablecer" }, + "routingResetConfirmTitle": { + "zh": "重置为默认规则?", + "en": "Reset to defaults?", + "ja": "デフォルトのルールに戻しますか?", + "ko": "기본 규칙으로 재설정할까요?", + "ru": "Сбросить к правилам по умолчанию?", + "es": "¿Restablecer las reglas predeterminadas?" + }, + "routingResetConfirmBody": { + "zh": "将清除你自定义的所有规则,恢复到出厂分流表。", + "en": "This clears all your custom rules and restores the factory routing table.", + "ja": "カスタムルールをすべて削除し、出荷時の分流テーブルに戻します。", + "ko": "사용자 지정 규칙을 모두 삭제하고 초기 분산 규칙으로 복원합니다.", + "ru": "Это удалит все ваши пользовательские правила и восстановит заводскую таблицу маршрутизации.", + "es": "Esto borra todas tus reglas personalizadas y restaura la tabla de enrutamiento de fábrica." + }, + "routingResetConfirmAction": { + "zh": "重置", + "en": "Reset", + "ja": "リセット", + "ko": "재설정", + "ru": "Сбросить", + "es": "Restablecer" + }, "routingBack": { "zh": "返回设置", "en": "Back to settings",