From 617b43083da02eda68d2863d44b3ffc1f9c40cea Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Tue, 7 Jul 2026 14:32:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(client/i18n):=20B1=20=E7=A1=AC=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E6=96=87=E6=A1=88=206=20=E8=AF=AD=E5=8C=96=20+=20B2?= =?UTF-8?q?=20=E8=AF=AD=E8=A8=80=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B2 持久化:localeProvider 由 StateProvider 改 StateNotifier(LocaleNotifier), 默认英文,选择存 shared_preferences(key pg_lang),重启保留;新增 appTextFor(lang) helper 供非 Consumer 场景复用。两处切换 .state= 改 .set()。 B1 硬编码 UI 文案 6 语化(AppLangMisc 扩展,exhaustive switch 不会漏): - 不可用 / 流媒体优化 / 节点未就绪 / 连接失败 / 加载失败 / 暂无设备 / 改邮箱 - 相对时间(刚刚/X分钟前/…)relativeTime(Duration) - 修 auth_screen 错误消息 bug:原 _errorZh 永远显示中文(连英文用户都中招)→ 按当前语言取 messageZh/En(非中文显英文) 仍回退英文(服务端数据,需服务端多语字段):节点/套餐名(nameZh/nameEn)、 API 错误消息(messageZh/En)。flutter analyze 仅 2 个既有 withOpacity info。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u --- client/lib/l10n/app_text.dart | 168 ++++++++++++++++++++++ client/lib/models/node.dart | 2 +- client/lib/screens/account_page.dart | 2 +- client/lib/screens/nodes_page.dart | 2 +- client/lib/screens/settings_page.dart | 2 +- client/lib/state/app_providers.dart | 54 ++++++- client/lib/state/connection_provider.dart | 7 +- client/lib/widgets/account_screens.dart | 13 +- client/lib/widgets/auth_screen.dart | 22 +-- client/lib/widgets/server_tile.dart | 2 +- 10 files changed, 239 insertions(+), 35 deletions(-) diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index d926e4d..04f7e15 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -34,6 +34,174 @@ extension AppLangLabel on AppLang { } } +/// 少量「拿不到 AppText 实例的场景」(model / 非 Consumer 的 tile,只有 AppLang) +/// 用到的零散文案。exhaustive switch 保证 6 语全覆盖,不会漏译回退英文。 +/// 仍归属 l10n 层(scan-redline 覆盖本文件),不违反「文案集中」铁律。 +extension AppLangMisc on AppLang { + /// 节点不可用(agent 离线)。 + String get unavailable { + switch (this) { + case AppLang.zh: + return '不可用'; + case AppLang.en: + return 'Unavailable'; + case AppLang.ja: + return '利用不可'; + case AppLang.ko: + return '사용 불가'; + case AppLang.ru: + return 'Недоступно'; + case AppLang.es: + return 'No disponible'; + } + } + + /// 节点标签:流媒体优化。 + String get tagStreaming { + switch (this) { + case AppLang.zh: + return '流媒体优化'; + case AppLang.en: + return 'Streaming'; + case AppLang.ja: + return 'ストリーミング最適化'; + case AppLang.ko: + return '스트리밍 최적화'; + case AppLang.ru: + return 'Оптимизация стриминга'; + case AppLang.es: + return 'Optimización de streaming'; + } + } + + /// 节点尚未就绪(列表加载中/为空)。 + String get nodesNotReady { + switch (this) { + case AppLang.zh: + return '节点尚未就绪,请稍候重试'; + case AppLang.en: + return 'Nodes not ready, please retry'; + case AppLang.ja: + return 'ノードの準備ができていません。しばらくして再試行してください'; + case AppLang.ko: + return '노드가 아직 준비되지 않았습니다. 잠시 후 다시 시도하세요'; + case AppLang.ru: + return 'Узлы не готовы, повторите попытку'; + case AppLang.es: + return 'Nodos no listos, inténtalo de nuevo'; + } + } + + /// 连接失败(通用兜底)。 + String get connectFailed { + switch (this) { + case AppLang.zh: + return '连接失败,请重试'; + case AppLang.en: + return 'Connection failed, please retry'; + case AppLang.ja: + return '接続に失敗しました。再試行してください'; + case AppLang.ko: + return '연결에 실패했습니다. 다시 시도하세요'; + case AppLang.ru: + return 'Не удалось подключиться, повторите попытку'; + case AppLang.es: + return 'Error de conexión, inténtalo de nuevo'; + } + } + + /// 数据加载失败。 + String get loadFailedRetry { + switch (this) { + case AppLang.zh: + return '加载失败,请重试'; + case AppLang.en: + return 'Failed to load, retry'; + case AppLang.ja: + return '読み込みに失敗しました。再試行してください'; + case AppLang.ko: + return '불러오기에 실패했습니다. 다시 시도하세요'; + case AppLang.ru: + return 'Не удалось загрузить, повторите'; + case AppLang.es: + return 'Error al cargar, reintentar'; + } + } + + /// 无已登录设备。 + String get noDevices { + switch (this) { + case AppLang.zh: + return '暂无已登录设备'; + case AppLang.en: + return 'No devices yet'; + case AppLang.ja: + return 'ログイン済みのデバイスはありません'; + case AppLang.ko: + return '로그인된 기기가 없습니다'; + case AppLang.ru: + return 'Пока нет устройств'; + case AppLang.es: + return 'Aún no hay dispositivos'; + } + } + + /// 「改邮箱」按钮短标签。 + String get changeEmail { + switch (this) { + case AppLang.zh: + return '改邮箱'; + case AppLang.en: + return 'Change'; + case AppLang.ja: + return '変更'; + case AppLang.ko: + return '변경'; + case AppLang.ru: + return 'Изменить'; + case AppLang.es: + return 'Cambiar'; + } + } + + /// 相对时间(设备最后在线):刚刚 / X 分钟前 / X 小时前 / X 天前。 + String relativeTime(Duration d) { + final m = d.inMinutes, h = d.inHours, days = d.inDays; + switch (this) { + case AppLang.zh: + if (m < 1) return '刚刚'; + if (m < 60) return '$m 分钟前'; + if (h < 24) return '$h 小时前'; + return '$days 天前'; + case AppLang.en: + if (m < 1) return 'just now'; + if (m < 60) return '$m min ago'; + if (h < 24) return '${h}h ago'; + return '${days}d ago'; + case AppLang.ja: + if (m < 1) return 'たった今'; + if (m < 60) return '$m 分前'; + if (h < 24) return '$h 時間前'; + return '$days 日前'; + case AppLang.ko: + if (m < 1) return '방금'; + if (m < 60) return '$m분 전'; + if (h < 24) return '$h시간 전'; + return '$days일 전'; + case AppLang.ru: + if (m < 1) return 'только что'; + if (m < 60) return '$m мин назад'; + if (h < 24) return '$h ч назад'; + return '$days дн назад'; + case AppLang.es: + if (m < 1) return 'ahora mismo'; + if (m < 60) return 'hace $m min'; + if (h < 24) return 'hace $h h'; + return 'hace $days d'; + } + } +} + /// 全部界面文案的抽象契约。zh / en 各实现一份。 abstract class AppText { const AppText(); diff --git a/client/lib/models/node.dart b/client/lib/models/node.dart index 5fd0035..4a1263a 100644 --- a/client/lib/models/node.dart +++ b/client/lib/models/node.dart @@ -66,7 +66,7 @@ class Node { String localizedSub(AppLang lang) { switch (tag) { case NodeTag.streaming: - return lang == AppLang.zh ? '流媒体优化' : 'Streaming'; + return lang.tagStreaming; case NodeTag.p2p: return 'P2P'; case NodeTag.none: diff --git a/client/lib/screens/account_page.dart b/client/lib/screens/account_page.dart index 9bc3e84..75ea50a 100644 --- a/client/lib/screens/account_page.dart +++ b/client/lib/screens/account_page.dart @@ -75,7 +75,7 @@ class AccountPage extends ConsumerWidget { _CustomRow( icon: PangolinIcons.globe, title: t.language, - trailing: _LangSwitch(lang: lang, onChange: (l) => ref.read(localeProvider.notifier).state = l), + trailing: _LangSwitch(lang: lang, onChange: (l) => ref.read(localeProvider.notifier).set(l)), ), Divider(height: 1, color: c.border), _CustomRow( diff --git a/client/lib/screens/nodes_page.dart b/client/lib/screens/nodes_page.dart index 03e2ff4..be8b797 100644 --- a/client/lib/screens/nodes_page.dart +++ b/client/lib/screens/nodes_page.dart @@ -267,7 +267,7 @@ class _NodeGridTile extends StatelessWidget { Widget build(BuildContext context) { final c = context.pangolin; final down = node.isDown; // 节点不可用(agent 离线):置灰 + 「不可用」+ 禁选。 - final unavail = lang == AppLang.zh ? '不可用' : 'Unavailable'; + final unavail = lang.unavailable; return Opacity( opacity: down ? 0.55 : 1.0, child: Material( diff --git a/client/lib/screens/settings_page.dart b/client/lib/screens/settings_page.dart index 31c8e27..9ada980 100644 --- a/client/lib/screens/settings_page.dart +++ b/client/lib/screens/settings_page.dart @@ -72,7 +72,7 @@ class SettingsPage extends ConsumerWidget { const SizedBox(height: 16), // 配置组 _Card(children: [ - _Row(title: t.language, right: _LangSwitch(lang: lang, onPick: (l) => ref.read(localeProvider.notifier).state = l)), + _Row(title: t.language, right: _LangSwitch(lang: lang, onPick: (l) => ref.read(localeProvider.notifier).set(l))), _Row( title: t.darkAppearance, sub: isDark ? t.stateOn : t.followLight, diff --git a/client/lib/state/app_providers.dart b/client/lib/state/app_providers.dart index b4e5a2a..673d8c1 100644 --- a/client/lib/state/app_providers.dart +++ b/client/lib/state/app_providers.dart @@ -1,6 +1,7 @@ // app_providers.dart — 语言 / 主题 / 套餐视角等基础状态(Riverpod) import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../l10n/app_text.dart'; import '../l10n/strings_en.dart'; @@ -12,12 +13,10 @@ import '../l10n/strings_zh.dart'; import 'account_providers.dart'; import 'auth_provider.dart'; -/// 当前语言(单显)。设置/账户页切换。默认英文(国际化默认语种)。 -final localeProvider = StateProvider((ref) => AppLang.en); - -/// 由语言派生的文案资源——UI 一律通过它取文案,不写死字面量。 -final appTextProvider = Provider((ref) { - switch (ref.watch(localeProvider)) { +/// AppLang → 文案资源实例。供 [appTextProvider] 与「拿不到 Consumer 的场景」 +/// (model / 非 Consumer 的 tile,只有 AppLang)复用,避免各处重写 switch。 +AppText appTextFor(AppLang lang) { + switch (lang) { case AppLang.zh: return const StringsZh(); case AppLang.en: @@ -31,7 +30,48 @@ final appTextProvider = Provider((ref) { case AppLang.es: return const StringsEs(); } -}); +} + +/// 当前语言(单显)。默认英文(国际化默认语种);用户选择持久化到 +/// shared_preferences(key `pg_lang`,存枚举 name),重启保留 —— 原来无持久化, +/// 切了语言重启会丢。设置/账户页经 `.notifier).set(lang)` 切换。 +class LocaleNotifier extends StateNotifier { + LocaleNotifier() : super(AppLang.en) { + _load(); + } + static const _key = 'pg_lang'; + + Future _load() async { + try { + final saved = (await SharedPreferences.getInstance()).getString(_key); + if (saved != null) { + for (final l in AppLang.values) { + if (l.name == saved) { + state = l; + break; + } + } + } + } catch (_) { + /* 读失败保持默认 en */ + } + } + + Future set(AppLang lang) async { + state = lang; + try { + await (await SharedPreferences.getInstance()).setString(_key, lang.name); + } catch (_) { + /* 持久化失败忽略,本次会话仍生效 */ + } + } +} + +final localeProvider = + StateNotifierProvider((ref) => LocaleNotifier()); + +/// 由语言派生的文案资源——UI 一律通过它取文案,不写死字面量。 +final appTextProvider = Provider((ref) => appTextFor(ref.watch(localeProvider))); /// 主题模式。默认跟随系统;设置页可显式切深色。 final themeModeProvider = StateProvider((ref) => ThemeMode.system); diff --git a/client/lib/state/connection_provider.dart b/client/lib/state/connection_provider.dart index ff08086..07a38f6 100644 --- a/client/lib/state/connection_provider.dart +++ b/client/lib/state/connection_provider.dart @@ -203,7 +203,8 @@ class ConnectionController extends StateNotifier { state = const ConnectionState(phase: VpnPhase.connecting); final node = _ref.read(effectiveNodeProvider); - final zh = _ref.read(localeProvider) == AppLang.zh; + final lang = _ref.read(localeProvider); + final zh = lang == AppLang.zh; // 仅用于服务端 e.messageZh/En 的二选一(下方) logLine('Connect', '_connect node=${node.code} uuid=${node.uuid.isEmpty ? "EMPTY" : "ok"} ' 'selected=${_ref.read(selectedNodeCodeProvider)} nodes=${(_ref.read(nodesProvider).valueOrNull ?? const []).length}'); @@ -212,7 +213,7 @@ class ConnectionController extends StateNotifier { if (mounted) { state = ConnectionState( phase: VpnPhase.off, - error: zh ? '节点尚未就绪,请稍候重试' : 'Nodes not ready, please retry', + error: lang.nodesNotReady, ); } return; @@ -244,7 +245,7 @@ class ConnectionController extends StateNotifier { if (mounted) { state = ConnectionState( phase: VpnPhase.off, - error: zh ? '连接失败,请重试' : 'Connection failed, please retry', + error: lang.connectFailed, ); } } diff --git a/client/lib/widgets/account_screens.dart b/client/lib/widgets/account_screens.dart index e137e59..35c3672 100644 --- a/client/lib/widgets/account_screens.dart +++ b/client/lib/widgets/account_screens.dart @@ -98,7 +98,7 @@ class PlansScreen extends ConsumerWidget { Widget body() => plansAsync.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 == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry', style: PangolinText.body.copyWith(color: c.fg3)))), + child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang.loadFailedRetry, style: PangolinText.body.copyWith(color: c.fg3)))), data: (plans) => ListView( padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), children: [ @@ -140,13 +140,8 @@ class DevicesScreen extends ConsumerWidget { // 相对时间(最后登录);null → 从未登录。 String _rel(DateTime? ts) { - final zh = t.lang == AppLang.zh; if (ts == null) return t.devNeverLogin; - final d = DateTime.now().difference(ts.toLocal()); - if (d.inMinutes < 1) return zh ? '刚刚' : 'just now'; - if (d.inMinutes < 60) return zh ? '${d.inMinutes} 分钟前' : '${d.inMinutes} min ago'; - if (d.inHours < 24) return zh ? '${d.inHours} 小时前' : '${d.inHours}h ago'; - return zh ? '${d.inDays} 天前' : '${d.inDays}d ago'; + return t.lang.relativeTime(DateTime.now().difference(ts.toLocal())); } @override @@ -158,12 +153,12 @@ class DevicesScreen extends ConsumerWidget { Widget content() => devicesAsync.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 == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry', style: PangolinText.body.copyWith(color: c.fg3)))), + child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang.loadFailedRetry, style: PangolinText.body.copyWith(color: c.fg3)))), data: (devices) => ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [ Text(t.devicesSub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), const SizedBox(height: 12), if (devices.isEmpty) - Text(t.lang == AppLang.zh ? '暂无已登录设备' : 'No devices yet', + Text(t.lang.noDevices, style: PangolinText.sm.copyWith(color: c.fg3)) else Container( diff --git a/client/lib/widgets/auth_screen.dart b/client/lib/widgets/auth_screen.dart index 2d7634e..e3b4baf 100644 --- a/client/lib/widgets/auth_screen.dart +++ b/client/lib/widgets/auth_screen.dart @@ -39,7 +39,7 @@ class _AuthScreenState extends ConsumerState bool _sent = false; bool _loading = false; bool _pwVisible = false; - String? _errorZh; + String? _error; final _email = TextEditingController(); final _code = TextEditingController(); @@ -90,17 +90,17 @@ class _AuthScreenState extends ConsumerState // ── 认证操作(逻辑不变)────────────────────────────────────────── Future _sendCode() async { - setState(() { _loading = true; _errorZh = null; }); + setState(() { _loading = true; _error = null; }); try { await _api.sendCode(_email.text.trim()); if (mounted) setState(() { _sent = true; _loading = false; }); } on AuthApiException catch (e) { - if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; }); + if (mounted) setState(() { _error = widget.t.lang == AppLang.zh ? e.messageZh : e.messageEn; _loading = false; }); } } Future _doRegister() async { - setState(() { _loading = true; _errorZh = null; }); + setState(() { _loading = true; _error = null; }); try { final device = (await ref.read(deviceIdentityProvider).meta()).toJson(); final tokens = await _api.register( @@ -113,12 +113,12 @@ class _AuthScreenState extends ConsumerState await ref.read(authProvider.notifier).saveTokens(tokens); if (mounted) widget.onDone(); } on AuthApiException catch (e) { - if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; }); + if (mounted) setState(() { _error = widget.t.lang == AppLang.zh ? e.messageZh : e.messageEn; _loading = false; }); } } Future _doLogin() async { - setState(() { _loading = true; _errorZh = null; }); + setState(() { _loading = true; _error = null; }); try { final device = (await ref.read(deviceIdentityProvider).meta()).toJson(); final tokens = await _api.login( @@ -132,7 +132,7 @@ class _AuthScreenState extends ConsumerState ref.read(deviceLimitProvider.notifier).state = tokens.deviceLimit; if (mounted) widget.onDone(); } on AuthApiException catch (e) { - if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; }); + if (mounted) setState(() { _error = widget.t.lang == AppLang.zh ? e.messageZh : e.messageEn; _loading = false; }); } } @@ -167,7 +167,7 @@ class _AuthScreenState extends ConsumerState _mode = m; _step = 0; _sent = false; - _errorZh = null; + _error = null; }); // ── UI ────────────────────────────────────────────────────── @@ -278,7 +278,7 @@ class _AuthScreenState extends ConsumerState child: Column(children: [ _segTabs(c, t), const SizedBox(height: 22), - if (_errorZh != null) ...[ + if (_error != null) ...[ Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( @@ -289,7 +289,7 @@ class _AuthScreenState extends ConsumerState child: Row(children: [ Icon(PangolinIcons.x, size: 16, color: c.danger), const SizedBox(width: 8), - Expanded(child: Text(_errorZh!, style: PangolinText.sm.copyWith(color: c.danger))), + Expanded(child: Text(_error!, style: PangolinText.sm.copyWith(color: c.danger))), ]), ), const SizedBox(height: 14), @@ -457,7 +457,7 @@ class _AuthScreenState extends ConsumerState style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600))), GestureDetector( onTap: () => setState(() => _step = 0), - child: Text(t.lang == AppLang.zh ? '改邮箱' : 'Change', + child: Text(t.lang.changeEmail, style: PangolinText.caption.copyWith(color: c.accent, fontWeight: FontWeight.w600, fontSize: 13)), ), ]), diff --git a/client/lib/widgets/server_tile.dart b/client/lib/widgets/server_tile.dart index e36a3cc..8d008aa 100644 --- a/client/lib/widgets/server_tile.dart +++ b/client/lib/widgets/server_tile.dart @@ -44,7 +44,7 @@ class ServerTile extends StatelessWidget { Widget build(BuildContext context) { final c = context.pangolin; final down = node.isDown; // 节点不可用(agent 离线):置灰 + 「不可用」+ 禁选。 - final unavail = lang == AppLang.zh ? '不可用' : 'Unavailable'; + final unavail = lang.unavailable; return InkWell( onTap: down ? null : onTap, child: Opacity(