From 3b6ef9fa68cb6aa03ea8b0e05347884d85415b5d Mon Sep 17 00:00:00 2001 From: xiaowen <0928du@gmail.com> Date: Sun, 28 Jun 2026 11:08:29 +0800 Subject: [PATCH 1/4] =?UTF-8?q?app:=20=E6=96=B0=E5=A2=9E=E5=A4=9A=E9=85=8D?= =?UTF-8?q?=E8=89=B2=E4=B8=BB=E9=A2=98=E7=B3=BB=E7=BB=9F=20+=20=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E4=B8=BB=E5=AF=86=E7=A0=81=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主题系统: - 新增 palette.dart,内置 4 套 Catppuccin 配色(Mocha/Macchiato/Frappe/Latte) - AppColors 由 const 改为可变字段,applyPalette 运行时切换,453 处调用点零改动 - 删除 230 处与 AppColors 绑定的 const(编译要求),不含 AppColors 的 const 保留 - 设置中心新增「外观」页:配色卡片网格 + 迷你预览,点击即时切换并持久化 - settings 增 themeId,main 监听 themeId 重建 MaterialApp 主密码锁: - 新增 lock_store.dart:加盐 10w 轮 SHA-256 哈希存 lock.json(权限600),不存明文 - 新增 lock_screen.dart 解锁界面,main 启动门禁(未设密码则直接进) - 设置中心新增「安全」页:设置/修改/取消主密码 验证:flutter analyze 0 问题,51 个测试全过,macOS 编译运行正常 Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/app/lib/core/i18n.dart | 8 +- clients/app/lib/core/lock_store.dart | 80 +++++ clients/app/lib/core/palette.dart | 169 ++++++++++ clients/app/lib/core/settings_store.dart | 8 + clients/app/lib/main.dart | 12 +- clients/app/lib/state/settings_provider.dart | 17 +- clients/app/lib/theme.dart | 76 +++-- clients/app/lib/ui/ai_pane.dart | 78 ++--- clients/app/lib/ui/audit_dialog.dart | 16 +- clients/app/lib/ui/dialogs.dart | 26 +- clients/app/lib/ui/dock_theme.dart | 12 +- clients/app/lib/ui/forward_dialog.dart | 38 +-- clients/app/lib/ui/keys_dialog.dart | 48 +-- clients/app/lib/ui/left_bar.dart | 34 +- clients/app/lib/ui/lock_screen.dart | 142 ++++++++ clients/app/lib/ui/right_bar.dart | 46 +-- clients/app/lib/ui/security_dialog.dart | 20 +- clients/app/lib/ui/settings_center.dart | 329 +++++++++++++++++-- clients/app/lib/ui/sftp_view.dart | 22 +- clients/app/lib/ui/snippets_dialog.dart | 28 +- clients/app/lib/ui/status_bar.dart | 24 +- clients/app/lib/ui/top_bar.dart | 10 +- 22 files changed, 987 insertions(+), 256 deletions(-) create mode 100644 clients/app/lib/core/lock_store.dart create mode 100644 clients/app/lib/core/palette.dart create mode 100644 clients/app/lib/ui/lock_screen.dart diff --git a/clients/app/lib/core/i18n.dart b/clients/app/lib/core/i18n.dart index 89095b7..9f3a939 100644 --- a/clients/app/lib/core/i18n.dart +++ b/clients/app/lib/core/i18n.dart @@ -22,7 +22,13 @@ const Map> _dict = { 'settings.nav.aiModel': {AppLang.zh: 'AI 模型', AppLang.en: 'AI Model'}, 'settings.nav.common': {AppLang.zh: '通用', AppLang.en: 'Common'}, 'settings.nav.terminal': {AppLang.zh: '终端', AppLang.en: 'Terminal'}, - 'settings.nav.theme': {AppLang.zh: '终端主题', AppLang.en: 'Terminal Theme'}, + 'settings.nav.theme': {AppLang.zh: '外观', AppLang.en: 'Appearance'}, + 'settings.theme.scheme': {AppLang.zh: '配色方案', AppLang.en: 'Color Scheme'}, + 'settings.theme.hint': { + AppLang.zh: '选择应用的整体配色,立即生效。', + AppLang.en: 'Pick the app color scheme. Applies instantly.' + }, + 'settings.nav.security': {AppLang.zh: '安全', AppLang.en: 'Security'}, 'settings.nav.shortcuts': {AppLang.zh: '快捷键', AppLang.en: 'Shortcuts'}, // 通用页 diff --git a/clients/app/lib/core/lock_store.dart b/clients/app/lib/core/lock_store.dart new file mode 100644 index 0000000..2e50f3f --- /dev/null +++ b/clients/app/lib/core/lock_store.dart @@ -0,0 +1,80 @@ +/// 本地主密码锁 —— 启动时校验,保护本地主机簿/密钥不被随手打开。 +/// +/// 安全设计: +/// - 不存明文,不存可逆密文,只存「加盐多轮 SHA-256」校验哈希。 +/// - 随机盐(16B)抗彩虹表;多轮迭代(stretch)抬高暴力成本。 +/// - 存 ~/.lowenssh/lock.json,权限 600。 +/// - 未设置主密码时无锁,直接进应用(不强制)。 +library; + +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; +import 'package:pointycastle/export.dart'; + +const int _saltLen = 16; +const int _rounds = 100000; // 迭代轮数,抬高暴力破解成本 + +String get _lockFile => + '${Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '.'}/.lowenssh/lock.json'; + +/// 加盐多轮 SHA-256:hash = SHA256^rounds(salt + pwd) +String _hash(String pwd, Uint8List salt) { + final digest = SHA256Digest(); + var cur = Uint8List.fromList([...salt, ...utf8.encode(pwd)]); + for (var i = 0; i < _rounds; i++) { + cur = digest.process(cur); + } + return base64.encode(cur); +} + +Uint8List _randomSalt() { + final rnd = Random.secure(); + final b = Uint8List(_saltLen); + for (var i = 0; i < _saltLen; i++) { + b[i] = rnd.nextInt(256); + } + return b; +} + +/// 是否已设置主密码 +bool hasMasterPassword() => File(_lockFile).existsSync(); + +/// 设置/修改主密码(覆盖写) +void setMasterPassword(String pwd) { + final salt = _randomSalt(); + final data = { + 'salt': base64.encode(salt), + 'hash': _hash(pwd, salt), + 'rounds': _rounds, + }; + final dir = Directory( + '${Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '.'}/.lowenssh'); + if (!dir.existsSync()) dir.createSync(recursive: true); + final f = File(_lockFile); + f.writeAsStringSync(const JsonEncoder.withIndent(' ').convert(data)); + // 权限 600(仅本人可读写) + try { + Process.runSync('chmod', ['600', _lockFile]); + } catch (_) {/* Windows 无 chmod,忽略 */} +} + +/// 取消主密码(删除锁文件) +void clearMasterPassword() { + final f = File(_lockFile); + if (f.existsSync()) f.deleteSync(); +} + +/// 校验主密码是否正确 +bool verifyMasterPassword(String pwd) { + final f = File(_lockFile); + if (!f.existsSync()) return true; // 没设锁视为通过 + try { + final j = jsonDecode(f.readAsStringSync()) as Map; + final salt = base64.decode(j['salt'] as String); + return _hash(pwd, Uint8List.fromList(salt)) == j['hash'] as String; + } catch (_) { + return false; + } +} diff --git a/clients/app/lib/core/palette.dart b/clients/app/lib/core/palette.dart new file mode 100644 index 0000000..3aa450c --- /dev/null +++ b/clients/app/lib/core/palette.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; + +/// 一套配色方案的数据载体。字段与 theme.dart 的 AppColors 一一对应。 +/// 新增配色只需 new 一个 AppPalette 实例,不必改调用点。 +class AppPalette { + final String id; // 持久化用的稳定标识 + final String name; // 显示名 + final Brightness brightness; // 亮/暗,给 Flutter ThemeData 用 + + final Color base; // 主背景 + final Color mantle; // 次背景(侧栏) + final Color crust; // 最深(终端/状态栏) + final Color surface0; // 边框/分隔 + final Color surface1; // 高亮边框 + final Color surface2; + final Color text; // 主文字 + final Color subtext; // 次文字 + final Color overlay; // 暗淡文字 + final Color blue; + final Color lavender; + final Color sapphire; + final Color green; + final Color yellow; + final Color peach; + final Color red; + final Color mauve; + final Color teal; + final Color pink; + + const AppPalette({ + required this.id, + required this.name, + required this.brightness, + required this.base, + required this.mantle, + required this.crust, + required this.surface0, + required this.surface1, + required this.surface2, + required this.text, + required this.subtext, + required this.overlay, + required this.blue, + required this.lavender, + required this.sapphire, + required this.green, + required this.yellow, + required this.peach, + required this.red, + required this.mauve, + required this.teal, + required this.pink, + }); +} + +/// 内置配色方案。均来自 Catppuccin 官方色板,结构一致只是色号不同。 +/// Mocha = 当前默认(暗);Macchiato/Frappe 是偏暖的暗色;Latte 是亮色。 +class Palettes { + /// Catppuccin Mocha(深暗,原默认) + static const mocha = AppPalette( + id: 'mocha', + name: 'Mocha 深暗', + brightness: Brightness.dark, + base: Color(0xFF1E1E2E), + mantle: Color(0xFF181825), + crust: Color(0xFF11111B), + surface0: Color(0xFF313244), + surface1: Color(0xFF45475A), + surface2: Color(0xFF585B70), + text: Color(0xFFCDD6F4), + subtext: Color(0xFFA6ADC8), + overlay: Color(0xFF6C7086), + blue: Color(0xFF89B4FA), + lavender: Color(0xFFB4BEFE), + sapphire: Color(0xFF74C7EC), + green: Color(0xFFA6E3A1), + yellow: Color(0xFFF9E2AF), + peach: Color(0xFFFAB387), + red: Color(0xFFF38BA8), + mauve: Color(0xFFCBA6F7), + teal: Color(0xFF94E2D5), + pink: Color(0xFFF5C2E7), + ); + + /// Catppuccin Macchiato(暗,比 Mocha 略亮偏暖) + static const macchiato = AppPalette( + id: 'macchiato', + name: 'Macchiato 暖暗', + brightness: Brightness.dark, + base: Color(0xFF24273A), + mantle: Color(0xFF1E2030), + crust: Color(0xFF181926), + surface0: Color(0xFF363A4F), + surface1: Color(0xFF494D64), + surface2: Color(0xFF5B6078), + text: Color(0xFFCAD3F5), + subtext: Color(0xFFA5ADCB), + overlay: Color(0xFF6E738D), + blue: Color(0xFF8AADF4), + lavender: Color(0xFFB7BDF8), + sapphire: Color(0xFF7DC4E4), + green: Color(0xFFA6DA95), + yellow: Color(0xFFEED49F), + peach: Color(0xFFF5A97F), + red: Color(0xFFED8796), + mauve: Color(0xFFC6A0F6), + teal: Color(0xFF8BD5CA), + pink: Color(0xFFF5BDE6), + ); + + /// Catppuccin Frappé(暗,更柔和的中间调) + static const frappe = AppPalette( + id: 'frappe', + name: 'Frappé 柔暗', + brightness: Brightness.dark, + base: Color(0xFF303446), + mantle: Color(0xFF292C3C), + crust: Color(0xFF232634), + surface0: Color(0xFF414559), + surface1: Color(0xFF51576D), + surface2: Color(0xFF626880), + text: Color(0xFFC6D0F5), + subtext: Color(0xFFA5ADCE), + overlay: Color(0xFF737994), + blue: Color(0xFF8CAAEE), + lavender: Color(0xFFBABBF1), + sapphire: Color(0xFF85C1DC), + green: Color(0xFFA6D189), + yellow: Color(0xFFE5C890), + peach: Color(0xFFEF9F76), + red: Color(0xFFE78284), + mauve: Color(0xFFCA9EE6), + teal: Color(0xFF81C8BE), + pink: Color(0xFFF4B8E4), + ); + + /// Catppuccin Latte(亮色) + static const latte = AppPalette( + id: 'latte', + name: 'Latte 亮色', + brightness: Brightness.light, + base: Color(0xFFEFF1F5), + mantle: Color(0xFFE6E9EF), + crust: Color(0xFFDCE0E8), + surface0: Color(0xFFCCD0DA), + surface1: Color(0xFFBCC0CC), + surface2: Color(0xFFACB0BE), + text: Color(0xFF4C4F69), + subtext: Color(0xFF5C5F77), + overlay: Color(0xFF8C8FA1), + blue: Color(0xFF1E66F5), + lavender: Color(0xFF7287FD), + sapphire: Color(0xFF209FB5), + green: Color(0xFF40A02B), + yellow: Color(0xFFDF8E1D), + peach: Color(0xFFFE640B), + red: Color(0xFFD20F39), + mauve: Color(0xFF8839EF), + teal: Color(0xFF179299), + pink: Color(0xFFEA76CB), + ); + + /// 全部内置方案,按显示顺序 + static const all = [mocha, macchiato, frappe, latte]; + + /// 按 id 取,找不到回退 Mocha + static AppPalette byId(String? id) => + all.firstWhere((p) => p.id == id, orElse: () => mocha); +} diff --git a/clients/app/lib/core/settings_store.dart b/clients/app/lib/core/settings_store.dart index 945779f..421e680 100644 --- a/clients/app/lib/core/settings_store.dart +++ b/clients/app/lib/core/settings_store.dart @@ -14,6 +14,9 @@ enum CursorStyle { block, underline, bar } class AppSettings { final AppLang lang; + // 外观 + final String themeId; // 配色方案 id(见 palette.dart) + // 终端设置(xterm 真实支持项) final double termFontSize; // 字号 final bool selectToCopy; // 选中即复制 @@ -23,6 +26,7 @@ class AppSettings { const AppSettings({ this.lang = AppLang.zh, + this.themeId = 'mocha', this.termFontSize = 12.5, this.selectToCopy = true, this.rightClickPaste = true, @@ -32,6 +36,7 @@ class AppSettings { factory AppSettings.fromJson(Map j) => AppSettings( lang: (j['lang'] as String?) == 'en' ? AppLang.en : AppLang.zh, + themeId: j['themeId'] as String? ?? 'mocha', termFontSize: (j['termFontSize'] as num?)?.toDouble() ?? 12.5, selectToCopy: j['selectToCopy'] as bool? ?? true, rightClickPaste: j['rightClickPaste'] as bool? ?? true, @@ -45,6 +50,7 @@ class AppSettings { Map toJson() => { 'lang': lang == AppLang.en ? 'en' : 'zh', + 'themeId': themeId, 'termFontSize': termFontSize, 'selectToCopy': selectToCopy, 'rightClickPaste': rightClickPaste, @@ -54,6 +60,7 @@ class AppSettings { AppSettings copyWith({ AppLang? lang, + String? themeId, double? termFontSize, bool? selectToCopy, bool? rightClickPaste, @@ -62,6 +69,7 @@ class AppSettings { }) => AppSettings( lang: lang ?? this.lang, + themeId: themeId ?? this.themeId, termFontSize: termFontSize ?? this.termFontSize, selectToCopy: selectToCopy ?? this.selectToCopy, rightClickPaste: rightClickPaste ?? this.rightClickPaste, diff --git a/clients/app/lib/main.dart b/clients/app/lib/main.dart index 6518ee4..434550d 100644 --- a/clients/app/lib/main.dart +++ b/clients/app/lib/main.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'theme.dart'; +import 'core/lock_store.dart'; import 'state/agent_provider.dart'; import 'state/connection_provider.dart'; +import 'state/settings_provider.dart'; import 'ui/app_shell.dart'; +import 'ui/lock_screen.dart'; void main() { runApp(const ProviderScope(child: LowenSshApp())); @@ -17,6 +20,9 @@ class LowenSshApp extends ConsumerStatefulWidget { } class _LowenSshAppState extends ConsumerState { + // 已设主密码时,启动需先解锁;未设则直接进入 + late bool _unlocked = !hasMasterPassword(); + @override void initState() { super.initState(); @@ -28,11 +34,15 @@ class _LowenSshAppState extends ConsumerState { @override Widget build(BuildContext context) { + // 监听配色变化:themeId 变 → 重建 MaterialApp → buildTheme 读到新 AppColors + ref.watch(settingsProvider.select((s) => s.themeId)); return MaterialApp( title: 'LowenSSH', debugShowCheckedModeBanner: false, theme: buildTheme(), - home: const AppShell(), + home: _unlocked + ? const AppShell() + : LockScreen(onUnlocked: () => setState(() => _unlocked = true)), ); } } diff --git a/clients/app/lib/state/settings_provider.dart b/clients/app/lib/state/settings_provider.dart index 93855ec..70c4c1a 100644 --- a/clients/app/lib/state/settings_provider.dart +++ b/clients/app/lib/state/settings_provider.dart @@ -1,11 +1,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/i18n.dart'; import '../core/settings_store.dart'; +import '../core/palette.dart'; +import '../theme.dart'; /// 通用设置 Notifier —— 语言等偏好,落盘持久化。 class SettingsNotifier extends Notifier { @override - AppSettings build() => loadSettings(); + AppSettings build() { + final s = loadSettings(); + // 启动即把持久化的配色应用到 AppColors(首帧之前) + applyPalette(Palettes.byId(s.themeId)); + return s; + } /// 切换语言(落盘 + 刷新,全应用即时重建) void setLang(AppLang lang) { @@ -14,6 +21,14 @@ class SettingsNotifier extends Notifier { state = next; } + /// 切换配色方案(应用到 AppColors + 落盘 + 刷新,main 监听后重建 MaterialApp) + void setTheme(String themeId) { + applyPalette(Palettes.byId(themeId)); + final next = state.copyWith(themeId: themeId); + saveSettings(next); + state = next; + } + /// 更新终端设置(任一字段,落盘 + 刷新) void updateTerminal({ double? termFontSize, diff --git a/clients/app/lib/theme.dart b/clients/app/lib/theme.dart index 18f3f2b..b06a58c 100644 --- a/clients/app/lib/theme.dart +++ b/clients/app/lib/theme.dart @@ -1,34 +1,66 @@ import 'package:flutter/material.dart'; +import 'core/palette.dart'; -/// Catppuccin Mocha 配色 —— 与 design/mockup.html 保持一致 -/// 暗色专业 IDE 风,是 App 端视觉基线 +/// 运行时配色 —— 字段由当前激活的 AppPalette 填充,支持主题切换。 +/// +/// 注意:字段是 `static Color`(非 const),所以调用点 **不能** 写 +/// `const TextStyle(color: AppColors.text)`。默认值取 Mocha,保证 +/// 首帧(applyPalette 调用前)也有正确颜色。 class AppColors { - static const base = Color(0xFF1E1E2E); // 主背景 - static const mantle = Color(0xFF181825); // 次背景(侧栏) - static const crust = Color(0xFF11111B); // 最深(终端/状态栏) - static const surface0 = Color(0xFF313244); // 边框/分隔 - static const surface1 = Color(0xFF45475A); // 高亮边框 - static const surface2 = Color(0xFF585B70); - static const text = Color(0xFFCDD6F4); // 主文字 - static const subtext = Color(0xFFA6ADC8); // 次文字 - static const overlay = Color(0xFF6C7086); // 暗淡文字 - static const blue = Color(0xFF89B4FA); - static const lavender = Color(0xFFB4BEFE); - static const sapphire = Color(0xFF74C7EC); - static const green = Color(0xFFA6E3A1); - static const yellow = Color(0xFFF9E2AF); - static const peach = Color(0xFFFAB387); - static const red = Color(0xFFF38BA8); - static const mauve = Color(0xFFCBA6F7); - static const teal = Color(0xFF94E2D5); - static const pink = Color(0xFFF5C2E7); + static Color base = Palettes.mocha.base; // 主背景 + static Color mantle = Palettes.mocha.mantle; // 次背景(侧栏) + static Color crust = Palettes.mocha.crust; // 最深(终端/状态栏) + static Color surface0 = Palettes.mocha.surface0; //边框/分隔 + static Color surface1 = Palettes.mocha.surface1; //高亮边框 + static Color surface2 = Palettes.mocha.surface2; + static Color text = Palettes.mocha.text; // 主文字 + static Color subtext = Palettes.mocha.subtext; // 次文字 + static Color overlay = Palettes.mocha.overlay; // 暗淡文字 + static Color blue = Palettes.mocha.blue; + static Color lavender = Palettes.mocha.lavender; + static Color sapphire = Palettes.mocha.sapphire; + static Color green = Palettes.mocha.green; + static Color yellow = Palettes.mocha.yellow; + static Color peach = Palettes.mocha.peach; + static Color red = Palettes.mocha.red; + static Color mauve = Palettes.mocha.mauve; + static Color teal = Palettes.mocha.teal; + static Color pink = Palettes.mocha.pink; +} + +/// 当前激活的配色(默认 Mocha) +AppPalette _current = Palettes.mocha; +AppPalette get currentPalette => _current; + +/// 应用一套配色到 AppColors。调用后需触发 UI rebuild(换 MaterialApp 的 theme)。 +void applyPalette(AppPalette p) { + _current = p; + AppColors.base = p.base; + AppColors.mantle = p.mantle; + AppColors.crust = p.crust; + AppColors.surface0 = p.surface0; + AppColors.surface1 = p.surface1; + AppColors.surface2 = p.surface2; + AppColors.text = p.text; + AppColors.subtext = p.subtext; + AppColors.overlay = p.overlay; + AppColors.blue = p.blue; + AppColors.lavender = p.lavender; + AppColors.sapphire = p.sapphire; + AppColors.green = p.green; + AppColors.yellow = p.yellow; + AppColors.peach = p.peach; + AppColors.red = p.red; + AppColors.mauve = p.mauve; + AppColors.teal = p.teal; + AppColors.pink = p.pink; } /// 等宽字体族(终端、命令、监控数值用) const String kMonoFont = 'monospace'; ThemeData buildTheme() { - final base = ThemeData.dark(useMaterial3: true); + final base = ThemeData(brightness: _current.brightness, useMaterial3: true); return base.copyWith( scaffoldBackgroundColor: AppColors.base, colorScheme: base.colorScheme.copyWith( diff --git a/clients/app/lib/ui/ai_pane.dart b/clients/app/lib/ui/ai_pane.dart index 9d12840..7006103 100644 --- a/clients/app/lib/ui/ai_pane.dart +++ b/clients/app/lib/ui/ai_pane.dart @@ -93,7 +93,7 @@ class _AiPaneState extends ConsumerState { child: st.items.isEmpty && st.pendingAsk == null ? Center( child: Text(l.t('ai.empty'), - style: const TextStyle( + style: TextStyle( fontSize: 12, color: AppColors.overlay)), ) : SingleChildScrollView( @@ -112,7 +112,7 @@ class _AiPaneState extends ConsumerState { const EdgeInsets.symmetric(horizontal: 12, vertical: 6), color: AppColors.red.withValues(alpha: .12), child: Text(l.t('ai.error', {'err': '${st.error}'}), - style: const TextStyle(fontSize: 11, color: AppColors.red)), + style: TextStyle(fontSize: 11, color: AppColors.red)), ), _inputBox(st.running, l), ], @@ -155,7 +155,7 @@ class _AiPaneState extends ConsumerState { child: Container( constraints: const BoxConstraints(maxWidth: 280), padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 8), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surface0, borderRadius: BorderRadius.only( topLeft: Radius.circular(8), @@ -165,7 +165,7 @@ class _AiPaneState extends ConsumerState { ), ), child: SelectableText(text, - style: const TextStyle(fontSize: 13, color: AppColors.text)), + style: TextStyle(fontSize: 13, color: AppColors.text)), ), ); @@ -181,17 +181,17 @@ class _AiPaneState extends ConsumerState { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.mantle, border: Border(bottom: BorderSide(color: AppColors.surface0)), ), child: Row( children: [ - const Icon(Icons.smart_toy_outlined, + Icon(Icons.smart_toy_outlined, size: 14, color: AppColors.subtext), const SizedBox(width: 7), Text(l.t('ai.agent'), - style: const TextStyle(fontSize: 12, color: AppColors.subtext)), + style: TextStyle(fontSize: 12, color: AppColors.subtext)), const SizedBox(width: 8), // 模型下拉 Expanded( @@ -220,7 +220,7 @@ class _AiPaneState extends ConsumerState { : AppColors.overlay), const SizedBox(width: 8), Text('${p.name} · ${p.model}', - style: const TextStyle( + style: TextStyle( fontSize: 12, color: AppColors.text)), ], ), @@ -237,12 +237,12 @@ class _AiPaneState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ Text(active.model, - style: const TextStyle( + style: TextStyle( fontSize: 11.5, fontWeight: FontWeight.w600, color: AppColors.text)), const SizedBox(width: 4), - const Icon(Icons.expand_more, + Icon(Icons.expand_more, size: 14, color: AppColors.subtext), ], ), @@ -261,11 +261,11 @@ class _AiPaneState extends ConsumerState { children: [ Row( children: [ - const Icon(Icons.smart_toy_outlined, + Icon(Icons.smart_toy_outlined, size: 13, color: AppColors.overlay), const SizedBox(width: 5), Text(ref.read(l10nProvider).t('ai.agent'), - style: const TextStyle( + style: TextStyle( fontSize: 10.5, color: AppColors.overlay)), ], ), @@ -274,7 +274,7 @@ class _AiPaneState extends ConsumerState { SelectionArea( child: GptMarkdown( text, - style: const TextStyle( + style: TextStyle( fontSize: 13, height: 1.5, color: AppColors.text), ), ), @@ -298,11 +298,11 @@ class _AiPaneState extends ConsumerState { children: [ Row( children: [ - const Icon(Icons.warning_amber_rounded, + Icon(Icons.warning_amber_rounded, size: 14, color: AppColors.yellow), const SizedBox(width: 6), Text(ref.read(l10nProvider).t('ai.askTitle'), - style: const TextStyle( + style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.yellow)), @@ -310,14 +310,14 @@ class _AiPaneState extends ConsumerState { ), const SizedBox(height: 5), Text(cmd, - style: const TextStyle( + style: TextStyle( fontFamily: kMonoFont, fontSize: 12, color: AppColors.peach)), const SizedBox(height: 5), Text(why, style: - const TextStyle(fontSize: 11, color: AppColors.subtext)), + TextStyle(fontSize: 11, color: AppColors.subtext)), const SizedBox(height: 8), Row( children: [ @@ -347,10 +347,10 @@ class _AiPaneState extends ConsumerState { children: [ Row( children: [ - const Icon(Icons.block, size: 14, color: AppColors.red), + Icon(Icons.block, size: 14, color: AppColors.red), const SizedBox(width: 6), Text(ref.read(l10nProvider).t('ai.blockedTitle'), - style: const TextStyle( + style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.red)), @@ -358,14 +358,14 @@ class _AiPaneState extends ConsumerState { ), const SizedBox(height: 5), Text(cmd, - style: const TextStyle( + style: TextStyle( fontFamily: kMonoFont, fontSize: 11.5, color: AppColors.peach)), const SizedBox(height: 5), Text(why, style: - const TextStyle(fontSize: 11, color: AppColors.subtext)), + TextStyle(fontSize: 11, color: AppColors.subtext)), ], ), ); @@ -394,7 +394,7 @@ class _AiPaneState extends ConsumerState { // AI 输入框 + 快捷键提示。running 时禁用并显示中断。 Widget _inputBox(bool running, L10n l) => Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.mantle, border: Border(top: BorderSide(color: AppColors.surface0)), ), @@ -416,13 +416,13 @@ class _AiPaneState extends ConsumerState { focusNode: _focusNode, enabled: !running, onSubmitted: (_) => _send(), - style: const TextStyle( + style: TextStyle( fontSize: 13, color: AppColors.text), decoration: InputDecoration( isDense: true, border: InputBorder.none, hintText: l.t('ai.inputHint'), - hintStyle: const TextStyle( + hintStyle: TextStyle( fontSize: 13, color: AppColors.overlay), ), ), @@ -436,7 +436,7 @@ class _AiPaneState extends ConsumerState { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const SizedBox( + SizedBox( width: 12, height: 12, child: CircularProgressIndicator( @@ -445,7 +445,7 @@ class _AiPaneState extends ConsumerState { ), const SizedBox(width: 6), Text(l.t('ai.interrupt'), - style: const TextStyle( + style: TextStyle( fontSize: 12, color: AppColors.yellow)), ], ), @@ -453,7 +453,7 @@ class _AiPaneState extends ConsumerState { : InkWell( onTap: _send, child: Text(l.t('ai.send'), - style: const TextStyle( + style: TextStyle( fontFamily: kMonoFont, fontSize: 12, color: AppColors.blue)), @@ -463,7 +463,7 @@ class _AiPaneState extends ConsumerState { ), const SizedBox(height: 6), DefaultTextStyle( - style: const TextStyle( + style: TextStyle( fontFamily: kMonoFont, fontSize: 10, color: AppColors.overlay), child: Wrap( spacing: 14, @@ -520,11 +520,11 @@ class _ReasoningTileState extends State<_ReasoningTile> { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.psychology_outlined, + Icon(Icons.psychology_outlined, size: 13, color: AppColors.overlay), const SizedBox(width: 5), Text(title, - style: const TextStyle( + style: TextStyle( fontSize: 11, color: AppColors.overlay)), const SizedBox(width: 3), Icon( @@ -542,12 +542,12 @@ class _ReasoningTileState extends State<_ReasoningTile> { Container( margin: const EdgeInsets.only(top: 4, left: 2), padding: const EdgeInsets.only(left: 9), - decoration: const BoxDecoration( + decoration: BoxDecoration( border: Border(left: BorderSide(color: AppColors.surface1, width: 2)), ), child: SelectableText(widget.text, - style: const TextStyle( + style: TextStyle( fontSize: 11.5, fontStyle: FontStyle.italic, color: AppColors.overlay)), @@ -610,7 +610,7 @@ class _ToolTileState extends State<_ToolTile> { const EdgeInsets.symmetric(horizontal: 9, vertical: 6), child: Row( children: [ - const Icon(Icons.terminal, + Icon(Icons.terminal, size: 13, color: AppColors.sapphire), const SizedBox(width: 7), // 命令(单行省略),占满中间 @@ -619,7 +619,7 @@ class _ToolTileState extends State<_ToolTile> { _display, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( fontFamily: kMonoFont, fontSize: 11.5, color: AppColors.peach), @@ -628,17 +628,17 @@ class _ToolTileState extends State<_ToolTile> { const SizedBox(width: 6), // 状态:执行中转圈 / 已执行绿勾 / 被阻止红叉 if (widget.running) - const SizedBox( + SizedBox( width: 11, height: 11, child: CircularProgressIndicator( strokeWidth: 1.5, color: AppColors.yellow), ) else if (widget.executed) - const Icon(Icons.check_circle_outline, + Icon(Icons.check_circle_outline, size: 12, color: AppColors.green) else - const Icon(Icons.block, + Icon(Icons.block, size: 12, color: AppColors.red), const SizedBox(width: 4), Icon( @@ -657,13 +657,13 @@ class _ToolTileState extends State<_ToolTile> { width: double.infinity, constraints: const BoxConstraints(maxHeight: 200), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.crust, border: Border(top: BorderSide(color: AppColors.surface0)), ), child: SingleChildScrollView( child: SelectableText(widget.result, - style: const TextStyle( + style: TextStyle( fontFamily: kMonoFont, fontSize: 11.5, color: AppColors.subtext)), diff --git a/clients/app/lib/ui/audit_dialog.dart b/clients/app/lib/ui/audit_dialog.dart index 1d89abf..4ceb5fd 100644 --- a/clients/app/lib/ui/audit_dialog.dart +++ b/clients/app/lib/ui/audit_dialog.dart @@ -13,7 +13,7 @@ Future showAuditDialog(BuildContext context) { backgroundColor: AppColors.mantle, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), - side: const BorderSide(color: AppColors.surface0), + side: BorderSide(color: AppColors.surface0), ), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560, maxHeight: 600), @@ -51,23 +51,23 @@ class _AuditBodyState extends ConsumerState<_AuditBody> { // 标题 + 清空 Row( children: [ - const Icon(Icons.receipt_long_outlined, + Icon(Icons.receipt_long_outlined, size: 16, color: AppColors.text), const SizedBox(width: 8), Text(l.t('audit.title'), - style: const TextStyle( + style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.text)), const SizedBox(width: 8), Text(l.t('audit.count', {'n': '${all.length}'}), - style: const TextStyle(fontSize: 11, color: AppColors.overlay)), + style: TextStyle(fontSize: 11, color: AppColors.overlay)), const Spacer(), if (all.isNotEmpty) TextButton( onPressed: () => ref.read(auditProvider.notifier).clear(), child: Text(l.t('common.clear'), - style: const TextStyle(fontSize: 12, color: AppColors.red)), + style: TextStyle(fontSize: 12, color: AppColors.red)), ), ], ), @@ -89,7 +89,7 @@ class _AuditBodyState extends ConsumerState<_AuditBody> { padding: const EdgeInsets.symmetric(vertical: 30), child: Text(l.t('audit.empty'), textAlign: TextAlign.center, - style: const TextStyle( + style: TextStyle( fontSize: 12, color: AppColors.overlay)), ) : ListView.separated( @@ -170,13 +170,13 @@ class _AuditBodyState extends ConsumerState<_AuditBody> { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(e.command, - style: const TextStyle( + style: TextStyle( fontFamily: kMonoFont, fontSize: 11.5, color: AppColors.text)), const SizedBox(height: 2), Text('${e.host} · ${_fmtTime(e.time)}${e.executed ? '' : l.t('audit.notExecuted')}', - style: const TextStyle( + style: TextStyle( fontSize: 10, color: AppColors.overlay)), ], ), diff --git a/clients/app/lib/ui/dialogs.dart b/clients/app/lib/ui/dialogs.dart index 2e56e54..90ad573 100644 --- a/clients/app/lib/ui/dialogs.dart +++ b/clients/app/lib/ui/dialogs.dart @@ -15,7 +15,7 @@ Future _showDark(BuildContext context, Widget child) { backgroundColor: AppColors.mantle, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), - side: const BorderSide(color: AppColors.surface0), + side: BorderSide(color: AppColors.surface0), ), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), @@ -49,17 +49,17 @@ class _FieldState extends State<_Field> { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(widget.label, - style: const TextStyle(fontSize: 11, color: AppColors.subtext)), + style: TextStyle(fontSize: 11, color: AppColors.subtext)), const SizedBox(height: 4), TextField( controller: widget.controller, obscureText: _hidden, - style: const TextStyle(fontSize: 13, color: AppColors.text), + style: TextStyle(fontSize: 13, color: AppColors.text), decoration: InputDecoration( isDense: true, hintText: widget.hint, hintStyle: - const TextStyle(fontSize: 12, color: AppColors.overlay), + TextStyle(fontSize: 12, color: AppColors.overlay), filled: true, fillColor: AppColors.base, contentPadding: @@ -80,11 +80,11 @@ class _FieldState extends State<_Field> { : null, enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(6), - borderSide: const BorderSide(color: AppColors.surface0), + borderSide: BorderSide(color: AppColors.surface0), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(6), - borderSide: const BorderSide(color: AppColors.blue), + borderSide: BorderSide(color: AppColors.blue), ), ), ), @@ -106,7 +106,7 @@ Widget _title(IconData icon, String text) => Padding( Icon(icon, size: 18, color: AppColors.blue), const SizedBox(width: 8), Text(text, - style: const TextStyle( + style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.text)), @@ -122,7 +122,7 @@ Widget _actions(BuildContext context, L10n l, TextButton( onPressed: () => Navigator.pop(context), child: Text(l.t('common.cancel'), - style: const TextStyle(color: AppColors.subtext)), + style: TextStyle(color: AppColors.subtext)), ), const SizedBox(width: 8), FilledButton( @@ -164,7 +164,7 @@ Future showAddHostDialog(BuildContext context, WidgetRef ref) { Padding( padding: const EdgeInsets.only(bottom: 4), child: Text(l.t('host.authMode'), - style: const TextStyle(fontSize: 11, color: AppColors.subtext)), + style: TextStyle(fontSize: 11, color: AppColors.subtext)), ), Row( children: [ @@ -240,7 +240,7 @@ Widget _keyDropdown(L10n l, border: Border.all(color: AppColors.surface0), ), child: Text(l.t('host.keyEmpty'), - style: const TextStyle(fontSize: 11.5, color: AppColors.overlay)), + style: TextStyle(fontSize: 11.5, color: AppColors.overlay)), ); } return Column( @@ -249,7 +249,7 @@ Widget _keyDropdown(L10n l, Padding( padding: const EdgeInsets.only(bottom: 4), child: Text(l.t('host.selectKey'), - style: const TextStyle(fontSize: 11, color: AppColors.subtext)), + style: TextStyle(fontSize: 11, color: AppColors.subtext)), ), Container( padding: const EdgeInsets.symmetric(horizontal: 10), @@ -264,8 +264,8 @@ Widget _keyDropdown(L10n l, isExpanded: true, dropdownColor: AppColors.mantle, hint: Text(l.t('host.pleaseSelect'), - style: const TextStyle(fontSize: 12, color: AppColors.overlay)), - style: const TextStyle(fontSize: 13, color: AppColors.text), + style: TextStyle(fontSize: 12, color: AppColors.overlay)), + style: TextStyle(fontSize: 13, color: AppColors.text), items: [ for (final k in keys) DropdownMenuItem(value: k.id, child: Text(k.name)), diff --git a/clients/app/lib/ui/dock_theme.dart b/clients/app/lib/ui/dock_theme.dart index 1f409ea..fbb25ff 100644 --- a/clients/app/lib/ui/dock_theme.dart +++ b/clients/app/lib/ui/dock_theme.dart @@ -12,33 +12,33 @@ TabbedViewThemeData buildTabbedTheme() { // tab 栏整体 theme.tabsArea ..color = AppColors.mantle - ..border = const Border( + ..border = Border( bottom: BorderSide(color: AppColors.surface0)) // 底部一条细分隔 ..initialGap = 0 ..middleGap = 0; // 单个 tab:无圆角无边框,紧凑 theme.tab - ..textStyle = const TextStyle(fontSize: 12, color: AppColors.subtext) + ..textStyle = TextStyle(fontSize: 12, color: AppColors.subtext) ..padding = const EdgeInsets.symmetric(horizontal: 14, vertical: 8) - ..decoration = const BoxDecoration(color: AppColors.mantle) + ..decoration = BoxDecoration(color: AppColors.mantle) ..normalButtonColor = AppColors.overlay ..hoverButtonColor = AppColors.text; // 选中态:背景提亮 + 顶部 2px 蓝线(VS Code 活动 tab 标志) theme.tab.selectedStatus ..fontColor = AppColors.text - ..decoration = const BoxDecoration( + ..decoration = BoxDecoration( color: AppColors.base, border: Border(top: BorderSide(color: AppColors.blue, width: 2)), ); // hover 态:轻微提亮 theme.tab.highlightedStatus.decoration = - const BoxDecoration(color: AppColors.surface0); + BoxDecoration(color: AppColors.surface0); // 内容区:去掉默认粗边框 - theme.contentArea.decoration = const BoxDecoration(color: AppColors.base); + theme.contentArea.decoration = BoxDecoration(color: AppColors.base); return theme; } diff --git a/clients/app/lib/ui/forward_dialog.dart b/clients/app/lib/ui/forward_dialog.dart index b70e5ab..d5138c5 100644 --- a/clients/app/lib/ui/forward_dialog.dart +++ b/clients/app/lib/ui/forward_dialog.dart @@ -14,7 +14,7 @@ Future showForwardDialog(BuildContext context) { backgroundColor: AppColors.mantle, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), - side: const BorderSide(color: AppColors.surface0), + side: BorderSide(color: AppColors.surface0), ), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560, maxHeight: 620), @@ -46,17 +46,17 @@ class _ForwardBody extends ConsumerWidget { // 标题 + 添加 Row( children: [ - const Icon(Icons.swap_horiz_outlined, + Icon(Icons.swap_horiz_outlined, size: 16, color: AppColors.text), const SizedBox(width: 8), Text(l.t('fwd.title'), - style: const TextStyle( + style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.text)), const SizedBox(width: 8), Text(l.t('fwd.count', {'n': '${list.length}'}), - style: const TextStyle(fontSize: 11, color: AppColors.overlay)), + style: TextStyle(fontSize: 11, color: AppColors.overlay)), const Spacer(), TextButton.icon( onPressed: conn.isConnected @@ -96,7 +96,7 @@ class _ForwardBody extends ConsumerWidget { conn.isConnected ? l.t('fwd.boundHint', {'host': hostName}) : l.t('fwd.notConnected'), - style: const TextStyle( + style: TextStyle( fontSize: 10.5, height: 1.4, color: AppColors.subtext)), ), ], @@ -109,7 +109,7 @@ class _ForwardBody extends ConsumerWidget { padding: const EdgeInsets.symmetric(vertical: 40), child: Text(l.t('fwd.empty'), textAlign: TextAlign.center, - style: const TextStyle( + style: TextStyle( fontSize: 12, color: AppColors.overlay)), ) : ListView.separated( @@ -157,7 +157,7 @@ class _ForwardBody extends ConsumerWidget { children: [ Text( 'localhost:${e.localPort} → ${e.remoteHost}:${e.remotePort}', - style: const TextStyle( + style: TextStyle( fontFamily: kMonoFont, fontSize: 12, color: AppColors.text)), @@ -188,7 +188,7 @@ class _ForwardBody extends ConsumerWidget { ), // 删除 IconButton( - icon: const Icon(Icons.delete_outline, + icon: Icon(Icons.delete_outline, size: 16, color: AppColors.red), splashRadius: 16, tooltip: l.t('common.delete'), @@ -215,7 +215,7 @@ void _showAddDialog(BuildContext context, WidgetRef ref) { backgroundColor: AppColors.mantle, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), - side: const BorderSide(color: AppColors.surface0), + side: BorderSide(color: AppColors.surface0), ), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 440), @@ -227,11 +227,11 @@ void _showAddDialog(BuildContext context, WidgetRef ref) { children: [ Row( children: [ - const Icon(Icons.swap_horiz_outlined, + Icon(Icons.swap_horiz_outlined, size: 18, color: AppColors.blue), const SizedBox(width: 8), Text(l.t('fwd.addTunnel'), - style: const TextStyle( + style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.text)), @@ -239,7 +239,7 @@ void _showAddDialog(BuildContext context, WidgetRef ref) { ), const SizedBox(height: 6), Text(l.t('fwd.addDesc'), - style: const TextStyle( + style: TextStyle( fontSize: 10.5, height: 1.4, color: AppColors.overlay)), const SizedBox(height: 14), _label(l.t('fwd.localPort')), @@ -254,7 +254,7 @@ void _showAddDialog(BuildContext context, WidgetRef ref) { const SizedBox(height: 8), Text(errorText!, style: - const TextStyle(fontSize: 11, color: AppColors.red)), + TextStyle(fontSize: 11, color: AppColors.red)), ], const SizedBox(height: 16), Row( @@ -263,7 +263,7 @@ void _showAddDialog(BuildContext context, WidgetRef ref) { TextButton( onPressed: () => Navigator.pop(ctx), child: Text(l.t('common.cancel'), - style: const TextStyle(color: AppColors.subtext)), + style: TextStyle(color: AppColors.subtext)), ), const SizedBox(width: 8), FilledButton( @@ -309,27 +309,27 @@ void _showAddDialog(BuildContext context, WidgetRef ref) { Widget _label(String text) => Padding( padding: const EdgeInsets.only(bottom: 4), child: Text(text, - style: const TextStyle(fontSize: 11, color: AppColors.subtext)), + style: TextStyle(fontSize: 11, color: AppColors.subtext)), ); Widget _input(TextEditingController c, {String? hint}) => TextField( controller: c, - style: const TextStyle(fontSize: 13, color: AppColors.text), + style: TextStyle(fontSize: 13, color: AppColors.text), decoration: InputDecoration( isDense: true, hintText: hint, - hintStyle: const TextStyle(fontSize: 11, color: AppColors.overlay), + hintStyle: TextStyle(fontSize: 11, color: AppColors.overlay), filled: true, fillColor: AppColors.base, contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(6), - borderSide: const BorderSide(color: AppColors.surface0), + borderSide: BorderSide(color: AppColors.surface0), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(6), - borderSide: const BorderSide(color: AppColors.blue), + borderSide: BorderSide(color: AppColors.blue), ), ), ); diff --git a/clients/app/lib/ui/keys_dialog.dart b/clients/app/lib/ui/keys_dialog.dart index 0190201..1ff0ebf 100644 --- a/clients/app/lib/ui/keys_dialog.dart +++ b/clients/app/lib/ui/keys_dialog.dart @@ -15,7 +15,7 @@ Future showKeysDialog(BuildContext context) { backgroundColor: AppColors.mantle, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), - side: const BorderSide(color: AppColors.surface0), + side: BorderSide(color: AppColors.surface0), ), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560, maxHeight: 620), @@ -44,22 +44,22 @@ class _KeysBody extends ConsumerWidget { // 标题 + 添加 Row( children: [ - const Icon(Icons.vpn_key_outlined, size: 16, color: AppColors.text), + Icon(Icons.vpn_key_outlined, size: 16, color: AppColors.text), const SizedBox(width: 8), Text(l.t('keys.title'), - style: const TextStyle( + style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.text)), const SizedBox(width: 8), Text(l.t('keys.count', {'n': '${keys.length}'}), - style: const TextStyle(fontSize: 11, color: AppColors.overlay)), + style: TextStyle(fontSize: 11, color: AppColors.overlay)), const Spacer(), TextButton.icon( onPressed: () => _showAddKeyDialog(context, ref), - icon: const Icon(Icons.add, size: 15, color: AppColors.blue), + icon: Icon(Icons.add, size: 15, color: AppColors.blue), label: Text(l.t('keys.add'), - style: const TextStyle(fontSize: 12, color: AppColors.blue)), + style: TextStyle(fontSize: 12, color: AppColors.blue)), ), ], ), @@ -70,7 +70,7 @@ class _KeysBody extends ConsumerWidget { padding: const EdgeInsets.symmetric(vertical: 40), child: Text(l.t('keys.empty'), textAlign: TextAlign.center, - style: const TextStyle( + style: TextStyle( fontSize: 12, color: AppColors.overlay)), ) : ListView.separated( @@ -102,26 +102,26 @@ class _KeysBody extends ConsumerWidget { ), child: Row( children: [ - const Icon(Icons.vpn_key, size: 15, color: AppColors.yellow), + Icon(Icons.vpn_key, size: 15, color: AppColors.yellow), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(k.name, - style: const TextStyle( + style: TextStyle( fontSize: 13, color: AppColors.text)), const SizedBox(height: 2), Text( '${k.passphraseEnc != null ? l.t('keys.withPassphrase') : ''}' '${usedBy > 0 ? l.t('keys.usedBy', {'n': '$usedBy'}) : l.t('keys.unused')}', - style: const TextStyle( + style: TextStyle( fontSize: 10, color: AppColors.overlay)), ], ), ), IconButton( - icon: const Icon(Icons.delete_outline, + icon: Icon(Icons.delete_outline, size: 16, color: AppColors.red), splashRadius: 16, tooltip: l.t('common.delete'), @@ -141,20 +141,20 @@ class _KeysBody extends ConsumerWidget { backgroundColor: AppColors.mantle, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), - side: const BorderSide(color: AppColors.surface0), + side: BorderSide(color: AppColors.surface0), ), title: Text(l.t('keys.deleteKey'), - style: const TextStyle(fontSize: 15, color: AppColors.text)), + style: TextStyle(fontSize: 15, color: AppColors.text)), content: Text( usedBy > 0 ? l.t('keys.deleteUsed', {'name': k.name, 'n': '$usedBy'}) : l.t('keys.deleteConfirm', {'name': k.name}), - style: const TextStyle(fontSize: 13, color: AppColors.subtext)), + style: TextStyle(fontSize: 13, color: AppColors.subtext)), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: Text(l.t('common.cancel'), - style: const TextStyle(color: AppColors.subtext)), + style: TextStyle(color: AppColors.subtext)), ), FilledButton( style: FilledButton.styleFrom( @@ -187,7 +187,7 @@ void _showAddKeyDialog(BuildContext context, WidgetRef ref) { backgroundColor: AppColors.mantle, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), - side: const BorderSide(color: AppColors.surface0), + side: BorderSide(color: AppColors.surface0), ), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), @@ -199,11 +199,11 @@ void _showAddKeyDialog(BuildContext context, WidgetRef ref) { children: [ Row( children: [ - const Icon(Icons.vpn_key_outlined, + Icon(Icons.vpn_key_outlined, size: 18, color: AppColors.blue), const SizedBox(width: 8), Text(l.t('keys.add'), - style: const TextStyle( + style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.text)), @@ -224,7 +224,7 @@ void _showAddKeyDialog(BuildContext context, WidgetRef ref) { const SizedBox(height: 8), Text(errorText!, style: - const TextStyle(fontSize: 11, color: AppColors.red)), + TextStyle(fontSize: 11, color: AppColors.red)), ], const SizedBox(height: 16), Row( @@ -233,7 +233,7 @@ void _showAddKeyDialog(BuildContext context, WidgetRef ref) { TextButton( onPressed: () => Navigator.pop(ctx), child: Text(l.t('common.cancel'), - style: const TextStyle(color: AppColors.subtext)), + style: TextStyle(color: AppColors.subtext)), ), const SizedBox(width: 8), FilledButton( @@ -276,7 +276,7 @@ void _showAddKeyDialog(BuildContext context, WidgetRef ref) { Widget _label(String text) => Padding( padding: const EdgeInsets.only(bottom: 4), child: Text(text, - style: const TextStyle(fontSize: 11, color: AppColors.subtext)), + style: TextStyle(fontSize: 11, color: AppColors.subtext)), ); Widget _input(TextEditingController c, @@ -295,18 +295,18 @@ Widget _input(TextEditingController c, decoration: InputDecoration( isDense: true, hintText: hint, - hintStyle: const TextStyle(fontSize: 11, color: AppColors.overlay), + hintStyle: TextStyle(fontSize: 11, color: AppColors.overlay), filled: true, fillColor: AppColors.base, contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(6), - borderSide: const BorderSide(color: AppColors.surface0), + borderSide: BorderSide(color: AppColors.surface0), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(6), - borderSide: const BorderSide(color: AppColors.blue), + borderSide: BorderSide(color: AppColors.blue), ), ), ); diff --git a/clients/app/lib/ui/left_bar.dart b/clients/app/lib/ui/left_bar.dart index ce3e7ec..7d8dc62 100644 --- a/clients/app/lib/ui/left_bar.dart +++ b/clients/app/lib/ui/left_bar.dart @@ -53,7 +53,7 @@ class LeftBar extends ConsumerWidget { query.isEmpty ? l.t('left.noHosts') : l.t('left.noMatch', {'q': query}), - style: const TextStyle( + style: TextStyle( fontSize: 11, color: AppColors.overlay)), ) else @@ -68,7 +68,7 @@ class LeftBar extends ConsumerWidget { borderRadius: BorderRadius.circular(6), ), child: Text(l.t('left.connectFail', {'err': '${conn.error}'}), - style: const TextStyle( + style: TextStyle( fontSize: 10.5, color: AppColors.red)), ), _divider(), @@ -105,14 +105,14 @@ class LeftBar extends ConsumerWidget { child: Row( children: [ Text(title.toUpperCase(), - style: const TextStyle( + style: TextStyle( fontSize: 10.5, letterSpacing: 1, color: AppColors.overlay)), const Spacer(), InkWell( onTap: () => showAddHostDialog(context, ref), - child: const Icon(Icons.add, size: 15, color: AppColors.subtext), + child: Icon(Icons.add, size: 15, color: AppColors.subtext), ), ], ), @@ -151,7 +151,7 @@ class LeftBar extends ConsumerWidget { children: [ // 状态:连接中转圈 / error红点 / 已连绿点 / 未连灰点 if (connecting) - const SizedBox( + SizedBox( width: 9, height: 9, child: CircularProgressIndicator( @@ -161,13 +161,13 @@ class LeftBar extends ConsumerWidget { Container( width: 7, height: 7, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.red, shape: BoxShape.circle), ) else _onlineDot(connected), const SizedBox(width: 9), - const Icon(Icons.dns_outlined, + Icon(Icons.dns_outlined, size: 15, color: AppColors.subtext), const SizedBox(width: 8), Expanded( @@ -176,7 +176,7 @@ class LeftBar extends ConsumerWidget { Flexible( child: Text(name, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( fontSize: 13, color: AppColors.text)), ), const SizedBox(width: 6), @@ -184,7 +184,7 @@ class LeftBar extends ConsumerWidget { Flexible( child: Text('${h.host}:${h.port}', overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( fontSize: 10.5, color: AppColors.overlay)), ), ], @@ -211,10 +211,10 @@ class LeftBar extends ConsumerWidget { height: 36, child: Row( children: [ - const Icon(Icons.delete_outline, size: 15, color: AppColors.red), + Icon(Icons.delete_outline, size: 15, color: AppColors.red), const SizedBox(width: 8), Text(l.t('left.deleteHost'), - style: const TextStyle(fontSize: 13, color: AppColors.text)), + style: TextStyle(fontSize: 13, color: AppColors.text)), ], ), ), @@ -236,19 +236,19 @@ class LeftBar extends ConsumerWidget { backgroundColor: AppColors.mantle, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), - side: const BorderSide(color: AppColors.surface0), + side: BorderSide(color: AppColors.surface0), ), title: Text(l.t('left.deleteHost'), - style: const TextStyle(fontSize: 15, color: AppColors.text)), + style: TextStyle(fontSize: 15, color: AppColors.text)), content: Text( l.t('left.deleteHostConfirm', {'name': name, 'addr': '${h.host}:${h.port}'}), - style: const TextStyle(fontSize: 13, color: AppColors.subtext)), + style: TextStyle(fontSize: 13, color: AppColors.subtext)), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: Text(l.t('common.cancel'), - style: const TextStyle(color: AppColors.subtext)), + style: TextStyle(color: AppColors.subtext)), ), FilledButton( style: FilledButton.styleFrom( @@ -325,7 +325,7 @@ class LeftBar extends ConsumerWidget { const SizedBox(width: 10), Expanded( child: Text(label, - style: const TextStyle( + style: TextStyle( fontSize: 13, color: AppColors.subtext)), ), if (badge != null) @@ -337,7 +337,7 @@ class LeftBar extends ConsumerWidget { borderRadius: BorderRadius.circular(10), ), child: Text(badge, - style: const TextStyle( + style: TextStyle( fontSize: 10, color: AppColors.subtext)), ), ], diff --git a/clients/app/lib/ui/lock_screen.dart b/clients/app/lib/ui/lock_screen.dart new file mode 100644 index 0000000..731f3e1 --- /dev/null +++ b/clients/app/lib/ui/lock_screen.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../theme.dart'; +import '../core/i18n.dart'; +import '../core/lock_store.dart'; +import '../core/settings_store.dart'; +import '../state/settings_provider.dart'; + +/// 解锁界面 —— 已设主密码时,启动先过这一关,验证通过才进 AppShell。 +class LockScreen extends ConsumerStatefulWidget { + final VoidCallback onUnlocked; + const LockScreen({super.key, required this.onUnlocked}); + + @override + ConsumerState createState() => _LockScreenState(); +} + +class _LockScreenState extends ConsumerState { + final _ctrl = TextEditingController(); + final _focus = FocusNode(); + String? _error; + bool _checking = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _focus.requestFocus()); + } + + @override + void dispose() { + _ctrl.dispose(); + _focus.dispose(); + super.dispose(); + } + + void _submit() { + if (_checking) return; + setState(() { + _checking = true; + _error = null; + }); + // 校验(多轮哈希,几十 ms,同步可接受) + final ok = verifyMasterPassword(_ctrl.text); + if (ok) { + widget.onUnlocked(); + } else { + setState(() { + _checking = false; + _error = _isZh ? '密码错误' : 'Wrong password'; + _ctrl.clear(); + }); + _focus.requestFocus(); + } + } + + bool get _isZh => ref.read(settingsProvider).lang == AppLang.zh; + + @override + Widget build(BuildContext context) { + final zh = _isZh; + return Scaffold( + backgroundColor: AppColors.base, + body: Center( + child: SizedBox( + width: 320, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('◈', + style: TextStyle(fontSize: 40, color: AppColors.blue)), + const SizedBox(height: 12), + Text('LowenSSH', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppColors.lavender, + letterSpacing: .5)), + const SizedBox(height: 6), + Text(zh ? '已锁定,请输入主密码' : 'Locked. Enter master password', + style: TextStyle(fontSize: 12.5, color: AppColors.overlay)), + const SizedBox(height: 22), + TextField( + controller: _ctrl, + focusNode: _focus, + obscureText: true, + autofocus: true, + onSubmitted: (_) => _submit(), + style: TextStyle(fontSize: 14, color: AppColors.text), + decoration: InputDecoration( + isDense: true, + filled: true, + fillColor: AppColors.mantle, + hintText: zh ? '主密码' : 'Master password', + hintStyle: TextStyle(color: AppColors.overlay), + prefixIcon: + Icon(Icons.lock_outline, size: 18, color: AppColors.overlay), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: _error != null + ? AppColors.red + : AppColors.surface0), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: AppColors.blue), + ), + ), + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: Text(_error!, + style: TextStyle(fontSize: 12, color: AppColors.red)), + ), + ], + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _checking ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: Text(zh ? '解锁' : 'Unlock', + style: TextStyle( + color: AppColors.crust, + fontWeight: FontWeight.w600)), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/clients/app/lib/ui/right_bar.dart b/clients/app/lib/ui/right_bar.dart index 205728e..2bc477c 100644 --- a/clients/app/lib/ui/right_bar.dart +++ b/clients/app/lib/ui/right_bar.dart @@ -81,7 +81,7 @@ class _RightBarState extends ConsumerState { } return Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( border: Border(bottom: BorderSide(color: AppColors.surface0)), ), child: Row( @@ -142,7 +142,7 @@ class _SecurityPanel extends ConsumerWidget { Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Text(l.t('right.noBlock'), - style: const TextStyle(fontSize: 11, color: AppColors.overlay)), + style: TextStyle(fontSize: 11, color: AppColors.overlay)), ) else for (final b in stats.blocked) @@ -175,7 +175,7 @@ class _SecurityPanel extends ConsumerWidget { color: color)), const SizedBox(height: 2), Text(label, - style: const TextStyle( + style: TextStyle( fontSize: 10, color: AppColors.overlay)), ], ), @@ -196,7 +196,7 @@ class _SecurityPanel extends ConsumerWidget { const SizedBox(width: 8), Expanded( child: Text(pattern, - style: const TextStyle( + style: TextStyle( fontFamily: kMonoFont, fontSize: 11, color: AppColors.subtext)), @@ -204,7 +204,7 @@ class _SecurityPanel extends ConsumerWidget { const SizedBox(width: 6), Text(hits, style: - const TextStyle(fontSize: 10, color: AppColors.overlay)), + TextStyle(fontSize: 10, color: AppColors.overlay)), ], ), ); @@ -235,7 +235,7 @@ class _SecurityPanel extends ConsumerWidget { Widget _logItem(String cmd, String meta, L10n l) => Container( margin: const EdgeInsets.only(bottom: 5), padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 7), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.base, borderRadius: BorderRadius.all(Radius.circular(6)), border: Border(left: BorderSide(color: AppColors.red, width: 2)), @@ -244,7 +244,7 @@ class _SecurityPanel extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(cmd, - style: const TextStyle( + style: TextStyle( fontFamily: kMonoFont, fontSize: 11, color: AppColors.peach)), @@ -253,10 +253,10 @@ class _SecurityPanel extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(meta, - style: const TextStyle( + style: TextStyle( fontSize: 10, color: AppColors.overlay)), Text(l.t('right.tempAllow'), - style: const TextStyle(fontSize: 10, color: AppColors.blue)), + style: TextStyle(fontSize: 10, color: AppColors.blue)), ], ), ], @@ -310,7 +310,7 @@ class _FilesPanelState extends ConsumerState<_FilesPanel> { padding: const EdgeInsets.symmetric(vertical: 20), child: Text(l.t('right.filesEmpty'), textAlign: TextAlign.center, - style: const TextStyle(fontSize: 11, color: AppColors.overlay)), + style: TextStyle(fontSize: 11, color: AppColors.overlay)), ); } @@ -329,14 +329,14 @@ class _FilesPanelState extends ConsumerState<_FilesPanel> { Expanded( child: Text(sftp.path, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( fontFamily: kMonoFont, fontSize: 11, color: AppColors.subtext)), ), InkWell( onTap: () => ref.read(sftpProvider.notifier).load(), - child: const Icon(Icons.refresh, + child: Icon(Icons.refresh, size: 13, color: AppColors.blue), ), ], @@ -344,7 +344,7 @@ class _FilesPanelState extends ConsumerState<_FilesPanel> { ), const SizedBox(height: 8), if (sftp.loading) - const Padding( + Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center( child: SizedBox( @@ -359,7 +359,7 @@ class _FilesPanelState extends ConsumerState<_FilesPanel> { Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text(l.t('right.loadFail', {'err': '${sftp.error}'}), - style: const TextStyle(fontSize: 11, color: AppColors.red)), + style: TextStyle(fontSize: 11, color: AppColors.red)), ) else ...[ // 顶部 .. 返回上级(非根目录时) @@ -386,7 +386,7 @@ class _FilesPanelState extends ConsumerState<_FilesPanel> { Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text(l.t('right.emptyDir'), - style: const TextStyle(fontSize: 11, color: AppColors.overlay)), + style: TextStyle(fontSize: 11, color: AppColors.overlay)), ), ], ], @@ -420,7 +420,7 @@ class _FilesPanelState extends ConsumerState<_FilesPanel> { ), if (meta != null) Text(meta, - style: const TextStyle( + style: TextStyle( fontSize: 10, color: AppColors.overlay)), ], ), @@ -463,7 +463,7 @@ class _MonitorPanelState extends ConsumerState<_MonitorPanel> { padding: const EdgeInsets.symmetric(vertical: 20), child: Text(l.t('right.monEmpty'), textAlign: TextAlign.center, - style: const TextStyle(fontSize: 11, color: AppColors.overlay)), + style: TextStyle(fontSize: 11, color: AppColors.overlay)), ); } @@ -488,7 +488,7 @@ class _MonitorPanelState extends ConsumerState<_MonitorPanel> { if (m.error != null) ...[ const SizedBox(height: 8), Text(l.t('right.sampleFail', {'err': '${m.error}'}), - style: const TextStyle(fontSize: 10, color: AppColors.red)), + style: TextStyle(fontSize: 10, color: AppColors.red)), ], ], ); @@ -502,7 +502,7 @@ class _MonitorPanelState extends ConsumerState<_MonitorPanel> { SizedBox( width: 48, child: Text(label, - style: const TextStyle( + style: TextStyle( fontSize: 11, color: AppColors.subtext))), const SizedBox(width: 9), Expanded( @@ -523,7 +523,7 @@ class _MonitorPanelState extends ConsumerState<_MonitorPanel> { child: Text(val, textAlign: TextAlign.right, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( fontSize: 10.5, color: AppColors.text))), ], ), @@ -540,13 +540,13 @@ class _MonitorPanelState extends ConsumerState<_MonitorPanel> { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, - style: const TextStyle( + style: TextStyle( fontFamily: kMonoFont, fontSize: 11, color: AppColors.subtext)), Text(val, style: - const TextStyle(fontSize: 10, color: AppColors.overlay)), + TextStyle(fontSize: 10, color: AppColors.overlay)), ], ), ); @@ -556,6 +556,6 @@ class _MonitorPanelState extends ConsumerState<_MonitorPanel> { Widget _panelTitle(String text) => Padding( padding: const EdgeInsets.only(bottom: 8), child: Text(text.toUpperCase(), - style: const TextStyle( + style: TextStyle( fontSize: 10.5, letterSpacing: 1, color: AppColors.overlay)), ); diff --git a/clients/app/lib/ui/security_dialog.dart b/clients/app/lib/ui/security_dialog.dart index 342aded..442bf0a 100644 --- a/clients/app/lib/ui/security_dialog.dart +++ b/clients/app/lib/ui/security_dialog.dart @@ -14,7 +14,7 @@ Future showSecurityDialog(BuildContext context) { backgroundColor: AppColors.mantle, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), - side: const BorderSide(color: AppColors.surface0), + side: BorderSide(color: AppColors.surface0), ), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560, maxHeight: 640), @@ -42,16 +42,16 @@ class _SecurityBody extends ConsumerWidget { // 标题 Row( children: [ - const Icon(Icons.shield_outlined, size: 16, color: AppColors.text), + Icon(Icons.shield_outlined, size: 16, color: AppColors.text), const SizedBox(width: 8), Text(l.t('sec.title'), - style: const TextStyle( + style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.text)), const SizedBox(width: 8), Text(l.t('sec.subtitle'), - style: const TextStyle(fontSize: 11, color: AppColors.overlay)), + style: TextStyle(fontSize: 11, color: AppColors.overlay)), ], ), const SizedBox(height: 12), @@ -75,7 +75,7 @@ class _SecurityBody extends ConsumerWidget { ), child: Text( '${l.t('sec.principle1')}${l.t('sec.principle2')}', - style: const TextStyle( + style: TextStyle( fontSize: 10.5, height: 1.5, color: AppColors.subtext)), ), const SizedBox(height: 12), @@ -99,7 +99,7 @@ class _SecurityBody extends ConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4), child: Text(l.t('sec.allowDesc'), - style: const TextStyle( + style: TextStyle( fontSize: 11, color: AppColors.overlay)), ), ], @@ -127,7 +127,7 @@ class _SecurityBody extends ConsumerWidget { const SizedBox(height: 2), Text(label, style: - const TextStyle(fontSize: 10, color: AppColors.overlay)), + TextStyle(fontSize: 10, color: AppColors.overlay)), ], ), ), @@ -140,7 +140,7 @@ class _SecurityBody extends ConsumerWidget { Container(width: 3, height: 12, color: color), const SizedBox(width: 7), Text(text, - style: const TextStyle( + style: TextStyle( fontSize: 11.5, fontWeight: FontWeight.w600, color: AppColors.text)), @@ -184,13 +184,13 @@ class _SecurityBody extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(r.pattern, - style: const TextStyle( + style: TextStyle( fontFamily: kMonoFont, fontSize: 11, color: AppColors.peach)), const SizedBox(height: 2), Text(r.desc, - style: const TextStyle( + style: TextStyle( fontSize: 10.5, color: AppColors.subtext)), ], ), diff --git a/clients/app/lib/ui/settings_center.dart b/clients/app/lib/ui/settings_center.dart index 8ef6627..b395a94 100644 --- a/clients/app/lib/ui/settings_center.dart +++ b/clients/app/lib/ui/settings_center.dart @@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../theme.dart'; import '../core/i18n.dart'; import '../core/config.dart'; +import '../core/palette.dart'; +import '../core/lock_store.dart'; import '../core/settings_store.dart'; import '../state/config_provider.dart'; import '../state/settings_provider.dart'; @@ -20,7 +22,7 @@ Future showSettingsCenter(BuildContext context) { insetPadding: const EdgeInsets.all(40), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), - side: const BorderSide(color: AppColors.surface0), + side: BorderSide(color: AppColors.surface0), ), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 860, maxHeight: 620), @@ -31,7 +33,7 @@ Future showSettingsCenter(BuildContext context) { } // 导航项标识 -enum _Nav { aiModel, common, terminal, theme, shortcuts } +enum _Nav { aiModel, common, terminal, theme, security, shortcuts } class _SettingsCenter extends ConsumerStatefulWidget { const _SettingsCenter(); @@ -51,7 +53,7 @@ class _SettingsCenterState extends ConsumerState<_SettingsCenter> { children: [ // 左栏导航 SizedBox(width: 220, child: _navBar(l)), - const VerticalDivider(width: 1, color: AppColors.surface0), + VerticalDivider(width: 1, color: AppColors.surface0), // 右栏内容 Expanded( child: Column( @@ -60,21 +62,21 @@ class _SettingsCenterState extends ConsumerState<_SettingsCenter> { // 顶部标题条 Container( padding: const EdgeInsets.fromLTRB(20, 16, 16, 16), - decoration: const BoxDecoration( + decoration: BoxDecoration( border: Border(bottom: BorderSide(color: AppColors.surface0)), ), child: Row( children: [ Text(l.t('settings.title'), - style: const TextStyle( + style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.text)), const Spacer(), InkWell( onTap: () => Navigator.pop(context), - child: const Icon(Icons.close, + child: Icon(Icons.close, size: 18, color: AppColors.subtext), ), ], @@ -87,6 +89,8 @@ class _SettingsCenterState extends ConsumerState<_SettingsCenter> { _Nav.aiModel => const _AiModelPage(), _Nav.common => const _CommonPage(), _Nav.terminal => const _TerminalPage(), + _Nav.theme => const _ThemePage(), + _Nav.security => const _SecurityPage(), _ => _placeholder(l), }, ), @@ -140,6 +144,8 @@ class _SettingsCenterState extends ConsumerState<_SettingsCenter> { l.t('settings.nav.terminal')), item(_Nav.theme, Icons.palette_outlined, l.t('settings.nav.theme')), + item(_Nav.security, Icons.lock_outline, + l.t('settings.nav.security')), item(_Nav.shortcuts, Icons.keyboard_outlined, l.t('settings.nav.shortcuts')), const Spacer(), @@ -150,10 +156,10 @@ class _SettingsCenterState extends ConsumerState<_SettingsCenter> { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(l.t('common.version'), - style: const TextStyle( + style: TextStyle( fontSize: 11, color: AppColors.overlay)), Text(kAppVersion, - style: const TextStyle( + style: TextStyle( fontSize: 11, color: AppColors.overlay)), ], ), @@ -167,7 +173,7 @@ class _SettingsCenterState extends ConsumerState<_SettingsCenter> { padding: const EdgeInsets.symmetric(vertical: 40), child: Text(l.t('settings.comingSoon'), textAlign: TextAlign.center, - style: const TextStyle(fontSize: 13, color: AppColors.overlay)), + style: TextStyle(fontSize: 13, color: AppColors.overlay)), ); } @@ -196,13 +202,13 @@ class _AiModelPageState extends ConsumerState<_AiModelPage> { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(l.t('settings.ai.title'), - style: const TextStyle( + style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.text)), const SizedBox(height: 4), Text(l.t('settings.ai.hint'), - style: const TextStyle(fontSize: 11.5, color: AppColors.overlay)), + style: TextStyle(fontSize: 11.5, color: AppColors.overlay)), const SizedBox(height: 16), // 供应商选择行(横向卡片) Wrap( @@ -246,13 +252,13 @@ class _AiModelPageState extends ConsumerState<_AiModelPage> { Expanded( child: Text(p.name, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( fontSize: 12.5, fontWeight: FontWeight.w600, color: AppColors.text)), ), if (active) - const Icon(Icons.check_circle, + Icon(Icons.check_circle, size: 14, color: AppColors.green), ], ), @@ -271,7 +277,7 @@ class _AiModelPageState extends ConsumerState<_AiModelPage> { Flexible( child: Text(p.model, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( fontSize: 10, color: AppColors.overlay)), ), ], @@ -350,7 +356,7 @@ class _ProviderFormState extends ConsumerState<_ProviderForm> { Row( children: [ Text(widget.provider.name, - style: const TextStyle( + style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.text)), @@ -364,7 +370,7 @@ class _ProviderFormState extends ConsumerState<_ProviderForm> { borderRadius: BorderRadius.circular(4), ), child: Text(l.t('settings.ai.active'), - style: const TextStyle( + style: TextStyle( fontSize: 9.5, fontWeight: FontWeight.w700, color: AppColors.green)), @@ -386,7 +392,7 @@ class _ProviderFormState extends ConsumerState<_ProviderForm> { alignment: Alignment.centerLeft, child: widget.isActive ? Text(l.t('settings.ai.active'), - style: const TextStyle( + style: TextStyle( fontSize: 12, color: AppColors.green)) : FilledButton( onPressed: widget.provider.configured @@ -421,7 +427,7 @@ class _CommonPage extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(l.t('settings.common.title'), - style: const TextStyle( + style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.text)), @@ -436,7 +442,7 @@ class _CommonPage extends ConsumerWidget { child: Row( children: [ Text(l.t('settings.common.language'), - style: const TextStyle(fontSize: 13, color: AppColors.text)), + style: TextStyle(fontSize: 13, color: AppColors.text)), const Spacer(), // 语言切换 _langTab(l.t('settings.common.langZh'), lang == AppLang.zh, @@ -485,7 +491,7 @@ class _TerminalPage extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(l.t('settings.term.title'), - style: const TextStyle( + style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.text)), @@ -504,11 +510,11 @@ class _TerminalPage extends ConsumerWidget { (v) { notifier.updateTerminal(selectToCopy: v, rightClickPaste: v); }), - const Divider(height: 1, color: AppColors.surface0), + Divider(height: 1, color: AppColors.surface0), // 光标闪烁 _switchRow(l.t('settings.term.cursorBlink'), s.cursorBlink, (v) => notifier.updateTerminal(cursorBlink: v)), - const Divider(height: 1, color: AppColors.surface0), + Divider(height: 1, color: AppColors.surface0), // 光标样式(三选) _rowWrap( l.t('settings.term.cursorStyle'), @@ -532,7 +538,7 @@ class _TerminalPage extends ConsumerWidget { ], ), ), - const Divider(height: 1, color: AppColors.surface0), + Divider(height: 1, color: AppColors.surface0), // 字号(± 步进) _rowWrap( l.t('settings.term.fontSize'), @@ -547,7 +553,7 @@ class _TerminalPage extends ConsumerWidget { width: 44, alignment: Alignment.center, child: Text(s.termFontSize.toStringAsFixed(0), - style: const TextStyle( + style: TextStyle( fontSize: 13, color: AppColors.text)), ), _stepBtn(Icons.add, () { @@ -572,7 +578,7 @@ class _TerminalPage extends ConsumerWidget { children: [ Expanded( child: Text(label, - style: const TextStyle(fontSize: 13, color: AppColors.text)), + style: TextStyle(fontSize: 13, color: AppColors.text)), ), Switch( value: value, @@ -591,7 +597,7 @@ class _TerminalPage extends ConsumerWidget { children: [ Expanded( child: Text(label, - style: const TextStyle(fontSize: 13, color: AppColors.text)), + style: TextStyle(fontSize: 13, color: AppColors.text)), ), trailing, ], @@ -637,7 +643,7 @@ class _TerminalPage extends ConsumerWidget { Widget _label(String text) => Padding( padding: const EdgeInsets.only(bottom: 4), child: Text(text, - style: const TextStyle(fontSize: 11, color: AppColors.subtext)), + style: TextStyle(fontSize: 11, color: AppColors.subtext)), ); Widget _input(TextEditingController c, @@ -646,7 +652,7 @@ Widget _input(TextEditingController c, controller: c, obscureText: obscure, onChanged: onChanged, - style: const TextStyle(fontSize: 13, color: AppColors.text), + style: TextStyle(fontSize: 13, color: AppColors.text), decoration: InputDecoration( isDense: true, filled: true, @@ -655,11 +661,274 @@ Widget _input(TextEditingController c, const EdgeInsets.symmetric(horizontal: 10, vertical: 9), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(6), - borderSide: const BorderSide(color: AppColors.surface0), + borderSide: BorderSide(color: AppColors.surface0), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(6), - borderSide: const BorderSide(color: AppColors.blue), + borderSide: BorderSide(color: AppColors.blue), + ), + ), + ); + +// ============ 外观(配色)页 ============ + +class _ThemePage extends ConsumerWidget { + const _ThemePage(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l = ref.watch(l10nProvider); + final currentId = ref.watch(settingsProvider).themeId; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _label(l.t('settings.theme.scheme')), + const SizedBox(height: 4), + Text(l.t('settings.theme.hint'), + style: TextStyle(fontSize: 11, color: AppColors.overlay)), + const SizedBox(height: 14), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final p in Palettes.all) + _paletteCard( + p, + selected: p.id == currentId, + onTap: () => ref.read(settingsProvider.notifier).setTheme(p.id), + ), + ], + ), + ], + ); + } + + // 单张配色预览卡:迷你界面预览 + 名称 + 选中标记 + Widget _paletteCard(AppPalette p, + {required bool selected, required VoidCallback onTap}) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Container( + width: 180, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.base, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: selected ? AppColors.blue : AppColors.surface0, + width: selected ? 2 : 1, + ), ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 迷你界面预览:用该方案自身的颜色渲染 + _miniPreview(p), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Text(p.name, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w600, + color: AppColors.text)), + ), + if (selected) + Icon(Icons.check_circle, size: 16, color: AppColors.blue), + ], + ), + ], + ), + ), + ); + } + + // 用配色自身颜色画一个三栏迷你界面缩略图 + Widget _miniPreview(AppPalette p) { + return Container( + height: 64, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: p.base, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: p.surface0), + ), + child: Row( + children: [ + // 侧栏 + Container(width: 30, color: p.mantle, child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _dot(p.blue), + const SizedBox(height: 4), + _dot(p.subtext), + const SizedBox(height: 4), + _dot(p.subtext), + ], + )), + // 主区:几条彩色文字行 + Expanded( + child: Padding( + padding: const EdgeInsets.all(6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _bar(p.green, 0.7), + const SizedBox(height: 4), + _bar(p.yellow, 0.5), + const SizedBox(height: 4), + _bar(p.text, 0.85), + const SizedBox(height: 4), + _bar(p.mauve, 0.4), + ], + ), + ), + ), + ], ), ); + } + + Widget _dot(Color c) => + Container(width: 8, height: 8, decoration: BoxDecoration(color: c, shape: BoxShape.circle)); + + Widget _bar(Color c, double widthFactor) => FractionallySizedBox( + widthFactor: widthFactor, + alignment: Alignment.centerLeft, + child: Container( + height: 5, + decoration: BoxDecoration( + color: c, borderRadius: BorderRadius.circular(3))), + ); +} + +// ============ 安全(主密码)页 ============ + +class _SecurityPage extends ConsumerStatefulWidget { + const _SecurityPage(); + + @override + ConsumerState<_SecurityPage> createState() => _SecurityPageState(); +} + +class _SecurityPageState extends ConsumerState<_SecurityPage> { + final _oldCtrl = TextEditingController(); + final _newCtrl = TextEditingController(); + final _confirmCtrl = TextEditingController(); + String? _msg; + bool _isError = false; + + @override + void dispose() { + _oldCtrl.dispose(); + _newCtrl.dispose(); + _confirmCtrl.dispose(); + super.dispose(); + } + + void _setMsg(String m, {bool error = false}) => + setState(() { + _msg = m; + _isError = error; + }); + + bool get _zh => ref.read(settingsProvider).lang == AppLang.zh; + + // 设置或修改主密码 + void _save() { + final hasOld = hasMasterPassword(); + // 已设密码:先验旧 + if (hasOld && !verifyMasterPassword(_oldCtrl.text)) { + _setMsg(_zh ? '当前密码错误' : 'Current password is wrong', error: true); + return; + } + final np = _newCtrl.text; + if (np.isEmpty) { + _setMsg(_zh ? '新密码不能为空' : 'New password cannot be empty', error: true); + return; + } + if (np != _confirmCtrl.text) { + _setMsg(_zh ? '两次输入不一致' : 'Passwords do not match', error: true); + return; + } + setMasterPassword(np); + _oldCtrl.clear(); + _newCtrl.clear(); + _confirmCtrl.clear(); + _setMsg(_zh ? '已保存,下次启动生效' : 'Saved. Takes effect next launch'); + } + + // 取消主密码 + void _clear() { + if (!verifyMasterPassword(_oldCtrl.text)) { + _setMsg(_zh ? '当前密码错误' : 'Current password is wrong', error: true); + return; + } + clearMasterPassword(); + _oldCtrl.clear(); + _newCtrl.clear(); + _confirmCtrl.clear(); + setState(() {}); + _setMsg(_zh ? '已取消主密码' : 'Master password removed'); + } + + @override + Widget build(BuildContext context) { + final zh = _zh; + final hasPwd = hasMasterPassword(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _label(zh ? '主密码' : 'Master Password'), + const SizedBox(height: 4), + Text( + hasPwd + ? (zh ? '已启用。启动应用时需输入主密码解锁。' : 'Enabled. Required to unlock on launch.') + : (zh ? '未设置。设置后启动需解锁,保护本地主机簿与密钥。' : 'Not set. Protects local hosts & keys.'), + style: TextStyle(fontSize: 11, color: AppColors.overlay)), + const SizedBox(height: 16), + if (hasPwd) ...[ + _label(zh ? '当前密码' : 'Current Password'), + _input(_oldCtrl, obscure: true), + const SizedBox(height: 12), + ], + _label(zh ? '新密码' : 'New Password'), + _input(_newCtrl, obscure: true), + const SizedBox(height: 12), + _label(zh ? '确认新密码' : 'Confirm New Password'), + _input(_confirmCtrl, obscure: true), + if (_msg != null) ...[ + const SizedBox(height: 10), + Text(_msg!, + style: TextStyle( + fontSize: 12, + color: _isError ? AppColors.red : AppColors.green)), + ], + const SizedBox(height: 16), + Row( + children: [ + FilledButton( + onPressed: _save, + style: FilledButton.styleFrom(backgroundColor: AppColors.blue), + child: Text(hasPwd ? (zh ? '修改' : 'Change') : (zh ? '设置' : 'Set'), + style: TextStyle(color: AppColors.crust)), + ), + if (hasPwd) ...[ + const SizedBox(width: 10), + OutlinedButton( + onPressed: _clear, + style: OutlinedButton.styleFrom( + side: BorderSide(color: AppColors.red)), + child: Text(zh ? '取消主密码' : 'Remove', + style: TextStyle(color: AppColors.red)), + ), + ], + ], + ), + ], + ); + } +} diff --git a/clients/app/lib/ui/sftp_view.dart b/clients/app/lib/ui/sftp_view.dart index 3ee8d8b..8700609 100644 --- a/clients/app/lib/ui/sftp_view.dart +++ b/clients/app/lib/ui/sftp_view.dart @@ -72,7 +72,7 @@ class SftpView extends ConsumerWidget { // 工具条:本地路径 | 上传/下载 | 远程路径 Widget _toolbar(L10n l) => Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.mantle, border: Border(bottom: BorderSide(color: AppColors.surface0)), ), @@ -112,7 +112,7 @@ class SftpView extends ConsumerWidget { const SizedBox(width: 8), Expanded( child: Text(path, - style: const TextStyle( + style: TextStyle( fontFamily: kMonoFont, fontSize: 11.5, color: AppColors.subtext)), @@ -133,7 +133,7 @@ class SftpView extends ConsumerWidget { Icon(icon, size: 13, color: AppColors.text), const SizedBox(width: 4), Text(label, - style: const TextStyle(fontSize: 11, color: AppColors.text)), + style: TextStyle(fontSize: 11, color: AppColors.text)), ], ), ); @@ -154,24 +154,24 @@ class SftpView extends ConsumerWidget { // 头 Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.mantle, border: Border(bottom: BorderSide(color: AppColors.surface0)), ), child: Row( children: [ - const Icon(Icons.folder_open_outlined, + Icon(Icons.folder_open_outlined, size: 14, color: AppColors.subtext), const SizedBox(width: 7), Text(head, - style: const TextStyle( + style: TextStyle( fontFamily: kMonoFont, fontSize: 11.5, color: AppColors.subtext)), if (host != null) ...[ const Spacer(), Text(host, - style: const TextStyle( + style: TextStyle( fontSize: 10, color: AppColors.green)), ], ], @@ -208,7 +208,7 @@ class SftpView extends ConsumerWidget { ), // 上传中显示进度图标 if (f.uploading) - const Padding( + Padding( padding: EdgeInsets.only(right: 4), child: Icon(Icons.upload, size: 12, color: AppColors.blue), ), @@ -226,14 +226,14 @@ class SftpView extends ConsumerWidget { // 底部传输状态条 Widget _status(L10n l) => Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.crust, border: Border(top: BorderSide(color: AppColors.surface0)), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Row( + Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.upload, size: 12, color: AppColors.blue), @@ -246,7 +246,7 @@ class SftpView extends ConsumerWidget { ], ), Text(l.t('sftp.hint'), - style: const TextStyle( + style: TextStyle( fontFamily: kMonoFont, fontSize: 10.5, color: AppColors.overlay)), diff --git a/clients/app/lib/ui/snippets_dialog.dart b/clients/app/lib/ui/snippets_dialog.dart index 057c795..2ac96d2 100644 --- a/clients/app/lib/ui/snippets_dialog.dart +++ b/clients/app/lib/ui/snippets_dialog.dart @@ -13,7 +13,7 @@ Future showSnippetsDialog(BuildContext context) { backgroundColor: AppColors.mantle, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), - side: const BorderSide(color: AppColors.surface0), + side: BorderSide(color: AppColors.surface0), ), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480, maxHeight: 560), @@ -67,17 +67,17 @@ class _SnippetsBodyState extends ConsumerState<_SnippetsBody> { // 标题行 Row( children: [ - const Icon(Icons.content_paste_outlined, + Icon(Icons.content_paste_outlined, size: 16, color: AppColors.text), const SizedBox(width: 8), Text(l.t('snip.title'), - style: const TextStyle( + style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.text)), const Spacer(), Text(l.t('snip.clickToFill'), - style: const TextStyle(fontSize: 11, color: AppColors.overlay)), + style: TextStyle(fontSize: 11, color: AppColors.overlay)), ], ), const SizedBox(height: 12), @@ -88,7 +88,7 @@ class _SnippetsBodyState extends ConsumerState<_SnippetsBody> { padding: const EdgeInsets.symmetric(vertical: 24), child: Text(l.t('snip.empty'), textAlign: TextAlign.center, - style: const TextStyle( + style: TextStyle( fontSize: 12, color: AppColors.overlay)), ) : ListView.separated( @@ -108,9 +108,9 @@ class _SnippetsBodyState extends ConsumerState<_SnippetsBody> { alignment: Alignment.centerLeft, child: TextButton.icon( onPressed: () => setState(() => _adding = true), - icon: const Icon(Icons.add, size: 16, color: AppColors.blue), + icon: Icon(Icons.add, size: 16, color: AppColors.blue), label: Text(l.t('snip.addNew'), - style: const TextStyle(fontSize: 12, color: AppColors.blue)), + style: TextStyle(fontSize: 12, color: AppColors.blue)), ), ), ], @@ -141,13 +141,13 @@ class _SnippetsBodyState extends ConsumerState<_SnippetsBody> { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(s.label, - style: const TextStyle( + style: TextStyle( fontSize: 12.5, color: AppColors.text)), const SizedBox(height: 2), Text(s.command, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( fontFamily: kMonoFont, fontSize: 11, color: AppColors.overlay)), @@ -157,7 +157,7 @@ class _SnippetsBodyState extends ConsumerState<_SnippetsBody> { ), ), IconButton( - icon: const Icon(Icons.close, size: 14, color: AppColors.overlay), + icon: Icon(Icons.close, size: 14, color: AppColors.overlay), splashRadius: 16, onPressed: () => ref.read(snippetProvider.notifier).removeAt(index), ), @@ -187,7 +187,7 @@ class _SnippetsBodyState extends ConsumerState<_SnippetsBody> { TextButton( onPressed: () => setState(() => _adding = false), child: Text(l.t('common.cancel'), - style: const TextStyle( + style: TextStyle( fontSize: 12, color: AppColors.subtext)), ), const SizedBox(width: 4), @@ -218,16 +218,16 @@ class _SnippetsBodyState extends ConsumerState<_SnippetsBody> { decoration: InputDecoration( isDense: true, hintText: hint, - hintStyle: const TextStyle(fontSize: 12, color: AppColors.overlay), + hintStyle: TextStyle(fontSize: 12, color: AppColors.overlay), contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), border: OutlineInputBorder( borderRadius: BorderRadius.circular(6), - borderSide: const BorderSide(color: AppColors.surface1), + borderSide: BorderSide(color: AppColors.surface1), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(6), - borderSide: const BorderSide(color: AppColors.surface1), + borderSide: BorderSide(color: AppColors.surface1), ), ), ); diff --git a/clients/app/lib/ui/status_bar.dart b/clients/app/lib/ui/status_bar.dart index 1b0ff37..3ff2202 100644 --- a/clients/app/lib/ui/status_bar.dart +++ b/clients/app/lib/ui/status_bar.dart @@ -26,13 +26,13 @@ class StatusBar extends ConsumerWidget { return Container( height: 26, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.crust, border: Border(top: BorderSide(color: AppColors.surface0)), ), padding: const EdgeInsets.symmetric(horizontal: 12), child: DefaultTextStyle( - style: const TextStyle( + style: TextStyle( fontFamily: kMonoFont, fontSize: 11, color: AppColors.subtext), child: Row( children: [ @@ -42,30 +42,30 @@ class StatusBar extends ConsumerWidget { // 门禁:ON + 阻止/待确认实时计数 _seg([ Text(l.t('status.guard'), - style: const TextStyle(color: AppColors.overlay)), - const Text('ON', style: TextStyle(color: AppColors.green)), - const Text(' · ', style: TextStyle(color: AppColors.overlay)), + style: TextStyle(color: AppColors.overlay)), + Text('ON', style: TextStyle(color: AppColors.green)), + Text(' · ', style: TextStyle(color: AppColors.overlay)), Text(l.t('status.blocked', {'n': '${guard.denyCount}'}), - style: const TextStyle(color: AppColors.red)), - const Text(' · ', style: TextStyle(color: AppColors.overlay)), + style: TextStyle(color: AppColors.red)), + Text(' · ', style: TextStyle(color: AppColors.overlay)), Text(l.t('status.pending', {'n': '${guard.askCount}'}), - style: const TextStyle(color: AppColors.yellow)), + style: TextStyle(color: AppColors.yellow)), ]), const Spacer(), // 模型 _seg([ Text(l.t('status.model'), - style: const TextStyle(color: AppColors.overlay)), + style: TextStyle(color: AppColors.overlay)), Text(llm.model.isEmpty ? l.t('status.notConfigured') : llm.model, - style: const TextStyle(color: AppColors.text)), + style: TextStyle(color: AppColors.text)), ]), const SizedBox(width: 16), // 上下文轮数 _seg([ Text(l.t('status.context'), - style: const TextStyle(color: AppColors.overlay)), + style: TextStyle(color: AppColors.overlay)), Text(l.t('status.rounds', {'n': '$rounds'}), - style: const TextStyle(color: AppColors.text)), + style: TextStyle(color: AppColors.text)), ]), ], ), diff --git a/clients/app/lib/ui/top_bar.dart b/clients/app/lib/ui/top_bar.dart index f40809c..b2c43af 100644 --- a/clients/app/lib/ui/top_bar.dart +++ b/clients/app/lib/ui/top_bar.dart @@ -15,7 +15,7 @@ class TopBar extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { return Container( height: 38, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.mantle, border: Border(bottom: BorderSide(color: AppColors.surface0)), ), @@ -24,7 +24,7 @@ class TopBar extends ConsumerWidget { children: [ // Logo Row( - children: const [ + children: [ Text('◈', style: TextStyle(color: AppColors.blue, fontSize: 14)), SizedBox(width: 5), Text('LowenSSH', @@ -56,19 +56,19 @@ class TopBar extends ConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2), child: Row( children: [ - const Icon(Icons.search, size: 15, color: AppColors.overlay), + Icon(Icons.search, size: 15, color: AppColors.overlay), const SizedBox(width: 6), Expanded( child: TextField( onChanged: (v) => ref.read(hostSearchProvider.notifier).update(v), - style: const TextStyle(color: AppColors.text, fontSize: 13), + style: TextStyle(color: AppColors.text, fontSize: 13), decoration: InputDecoration( isDense: true, border: InputBorder.none, contentPadding: const EdgeInsets.symmetric(vertical: 6), hintText: l.t('top.search'), - hintStyle: const TextStyle( + hintStyle: TextStyle( color: AppColors.overlay, fontSize: 13), ), ), From 00294f9e83417fd69004c2b31f21eb13418d1c3b Mon Sep 17 00:00:00 2001 From: xiaowen <0928du@gmail.com> Date: Sun, 28 Jun 2026 11:21:42 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20CI=EF=BC=9A=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=20lock=5Fscreen=20=E6=9C=AA=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E7=9A=84=20settings=5Fstore=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 的 flutter analyze 把 unused_import warning 当失败。 AppLang 来自 i18n.dart,settings_store 确实多余。 Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/app/lib/ui/lock_screen.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/clients/app/lib/ui/lock_screen.dart b/clients/app/lib/ui/lock_screen.dart index 731f3e1..1fe0ca2 100644 --- a/clients/app/lib/ui/lock_screen.dart +++ b/clients/app/lib/ui/lock_screen.dart @@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../theme.dart'; import '../core/i18n.dart'; import '../core/lock_store.dart'; -import '../core/settings_store.dart'; import '../state/settings_provider.dart'; /// 解锁界面 —— 已设主密码时,启动先过这一关,验证通过才进 AppShell。 From a9ec1911cc87cf3301ecaca16a61be16868a5eda Mon Sep 17 00:00:00 2001 From: xiaowen <0928du@gmail.com> Date: Sun, 28 Jun 2026 11:49:24 +0800 Subject: [PATCH 3/4] =?UTF-8?q?app:=20=E4=BF=AE=E5=A4=8D=E5=88=87=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E6=97=B6=E7=BB=88=E7=AB=AF/=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E4=B8=8D=E5=8F=98=E8=89=B2=20+=20=E6=96=B0=E5=A2=9E=E5=BD=A9?= =?UTF-8?q?=E8=89=B2=E4=B8=BB=E9=A2=98=20+=20=E6=A0=87=E7=AD=BE=E5=8F=8C?= =?UTF-8?q?=E8=AF=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 终端 _termTheme 由冻结全局改为 getter,随当前主题实时重算 - 终端硬编码黑/品红改走主题色(surface1/pink/mauve) - 新增 2 套彩色主题:霓虹夜(Tokyo Night风) / 樱桃亮(高对比亮色) - palette name 拆为 nameZh/nameEn,外观页卡片名随语言切换 - app_shell 标签(主机/终端/智能体/面板)改用 i18n,语言切换重建布局 Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/app/lib/core/palette.dart | 70 ++++++++++++++++++++++--- clients/app/lib/ui/app_shell.dart | 38 +++++++++----- clients/app/lib/ui/settings_center.dart | 6 ++- clients/app/lib/ui/terminal_pane.dart | 13 ++--- 4 files changed, 98 insertions(+), 29 deletions(-) diff --git a/clients/app/lib/core/palette.dart b/clients/app/lib/core/palette.dart index 3aa450c..fe2f910 100644 --- a/clients/app/lib/core/palette.dart +++ b/clients/app/lib/core/palette.dart @@ -4,7 +4,8 @@ import 'package:flutter/material.dart'; /// 新增配色只需 new 一个 AppPalette 实例,不必改调用点。 class AppPalette { final String id; // 持久化用的稳定标识 - final String name; // 显示名 + final String nameZh; // 显示名(中) + final String nameEn; // 显示名(英) final Brightness brightness; // 亮/暗,给 Flutter ThemeData 用 final Color base; // 主背景 @@ -29,7 +30,8 @@ class AppPalette { const AppPalette({ required this.id, - required this.name, + required this.nameZh, + required this.nameEn, required this.brightness, required this.base, required this.mantle, @@ -59,7 +61,7 @@ class Palettes { /// Catppuccin Mocha(深暗,原默认) static const mocha = AppPalette( id: 'mocha', - name: 'Mocha 深暗', + nameZh: 'Mocha 深暗', nameEn: 'Mocha Dark', brightness: Brightness.dark, base: Color(0xFF1E1E2E), mantle: Color(0xFF181825), @@ -85,7 +87,7 @@ class Palettes { /// Catppuccin Macchiato(暗,比 Mocha 略亮偏暖) static const macchiato = AppPalette( id: 'macchiato', - name: 'Macchiato 暖暗', + nameZh: 'Macchiato 暖暗', nameEn: 'Macchiato Warm', brightness: Brightness.dark, base: Color(0xFF24273A), mantle: Color(0xFF1E2030), @@ -111,7 +113,7 @@ class Palettes { /// Catppuccin Frappé(暗,更柔和的中间调) static const frappe = AppPalette( id: 'frappe', - name: 'Frappé 柔暗', + nameZh: 'Frappé 柔暗', nameEn: 'Frappé Soft', brightness: Brightness.dark, base: Color(0xFF303446), mantle: Color(0xFF292C3C), @@ -137,7 +139,7 @@ class Palettes { /// Catppuccin Latte(亮色) static const latte = AppPalette( id: 'latte', - name: 'Latte 亮色', + nameZh: 'Latte 亮色', nameEn: 'Latte Light', brightness: Brightness.light, base: Color(0xFFEFF1F5), mantle: Color(0xFFE6E9EF), @@ -160,8 +162,62 @@ class Palettes { pink: Color(0xFFEA76CB), ); + /// 霓虹夜(Tokyo Night 风,高饱和彩色,深蓝底 + 鲜亮强调色) + static const neon = AppPalette( + id: 'neon', + nameZh: '霓虹夜', + nameEn: 'Neon Night', + brightness: Brightness.dark, + base: Color(0xFF1A1B26), + mantle: Color(0xFF16161E), + crust: Color(0xFF0F0F17), + surface0: Color(0xFF2A2E45), + surface1: Color(0xFF3B4261), + surface2: Color(0xFF545C7E), + text: Color(0xFFC0CAF5), + subtext: Color(0xFF9AA5CE), + overlay: Color(0xFF565F89), + blue: Color(0xFF7AA2F7), + lavender: Color(0xFFBB9AF7), + sapphire: Color(0xFF2AC3DE), + green: Color(0xFF9ECE6A), + yellow: Color(0xFFE0AF68), + peach: Color(0xFFFF9E64), + red: Color(0xFFF7768E), + mauve: Color(0xFFBB9AF7), + teal: Color(0xFF73DACA), + pink: Color(0xFFFF75A0), + ); + + /// 樱桃亮(高对比亮色,白底 + 鲜艳强调色,彩色边框感强) + static const cherry = AppPalette( + id: 'cherry', + nameZh: '樱桃亮', + nameEn: 'Cherry Light', + brightness: Brightness.light, + base: Color(0xFFFFFFFF), + mantle: Color(0xFFF6F2F8), + crust: Color(0xFFEDE7F0), + surface0: Color(0xFFE0D4E7), + surface1: Color(0xFFCBB8D6), + surface2: Color(0xFFB199C2), + text: Color(0xFF2D1B33), + subtext: Color(0xFF5C476B), + overlay: Color(0xFF8A7397), + blue: Color(0xFF2563EB), + lavender: Color(0xFF7C3AED), + sapphire: Color(0xFF0891B2), + green: Color(0xFF16A34A), + yellow: Color(0xFFCA8A04), + peach: Color(0xFFEA580C), + red: Color(0xFFDC2626), + mauve: Color(0xFF9333EA), + teal: Color(0xFF0D9488), + pink: Color(0xFFDB2777), + ); + /// 全部内置方案,按显示顺序 - static const all = [mocha, macchiato, frappe, latte]; + static const all = [mocha, macchiato, frappe, latte, neon, cherry]; /// 按 id 取,找不到回退 Mocha static AppPalette byId(String? id) => diff --git a/clients/app/lib/ui/app_shell.dart b/clients/app/lib/ui/app_shell.dart index c363444..0157f79 100644 --- a/clients/app/lib/ui/app_shell.dart +++ b/clients/app/lib/ui/app_shell.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:docking/docking.dart'; import '../theme.dart'; +import '../core/i18n.dart'; +import '../state/settings_provider.dart'; import 'top_bar.dart'; import 'status_bar.dart'; import 'left_bar.dart'; @@ -13,25 +16,24 @@ import 'dock_theme.dart'; /// 应用主骨架 —— 顶栏 + 可停靠区(VS Code 式) + 状态栏 /// 所有面板(主机/终端/SFTP/智能体/侧面板)均为独立 DockingItem, /// 可自由拖拽、合并成 tab、分屏。顶栏下方整块交给 Docking,左栏因此自然顶起。 -class AppShell extends StatefulWidget { +class AppShell extends ConsumerStatefulWidget { const AppShell({super.key}); @override - State createState() => _AppShellState(); + ConsumerState createState() => _AppShellState(); } -class _AppShellState extends State { - late final DockingLayout _layout; +class _AppShellState extends ConsumerState { + DockingLayout? _layout; + AppLang? _builtLang; // 上次构建 layout 用的语言,变化则重建 - @override - void initState() { - super.initState(); - // 初始布局:主机 | (终端/SFTP tab 组 + 智能体) | 侧面板 - _layout = DockingLayout( + // 按当前语言构建布局。语言切换时重建(会重置拖拽布局,低频可接受)。 + DockingLayout _buildLayout(L10n l) { + return DockingLayout( root: DockingRow([ // 左:主机树 DockingItem( - name: '主机', + name: l.t('panel.hosts'), widget: const LeftBar(), weight: 0.17, closable: false, @@ -40,7 +42,7 @@ class _AppShellState extends State { // 中:终端与 SFTP 合并成 tab 组 DockingTabs([ DockingItem( - name: '终端 · web01', + name: '${l.t('panel.terminal')} · web01', widget: const TerminalPane(), keepAlive: true, ), @@ -52,14 +54,14 @@ class _AppShellState extends State { ], weight: 0.32), // 中右:智能体(独立面板,可拖动) DockingItem( - name: '智能体', + name: l.t('panel.agent'), widget: const AiPane(), weight: 0.30, keepAlive: true, ), // 右:安全/文件/监控侧面板 DockingItem( - name: '面板', + name: l.t('panel.side'), widget: const RightBar(), weight: 0.21, closable: false, @@ -71,12 +73,20 @@ class _AppShellState extends State { @override void dispose() { - _layout.dispose(); + _layout?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { + // 监听语言:变化时重建 layout(标签名跟随语言) + final lang = ref.watch(settingsProvider.select((s) => s.lang)); + final l = ref.watch(l10nProvider); + if (_layout == null || _builtLang != lang) { + _layout?.dispose(); + _layout = _buildLayout(l); + _builtLang = lang; + } return Scaffold( backgroundColor: AppColors.base, body: Column( diff --git a/clients/app/lib/ui/settings_center.dart b/clients/app/lib/ui/settings_center.dart index b395a94..3ed45b7 100644 --- a/clients/app/lib/ui/settings_center.dart +++ b/clients/app/lib/ui/settings_center.dart @@ -679,6 +679,7 @@ class _ThemePage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final l = ref.watch(l10nProvider); final currentId = ref.watch(settingsProvider).themeId; + final zh = ref.watch(settingsProvider).lang == AppLang.zh; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -694,6 +695,7 @@ class _ThemePage extends ConsumerWidget { for (final p in Palettes.all) _paletteCard( p, + zh: zh, selected: p.id == currentId, onTap: () => ref.read(settingsProvider.notifier).setTheme(p.id), ), @@ -705,7 +707,7 @@ class _ThemePage extends ConsumerWidget { // 单张配色预览卡:迷你界面预览 + 名称 + 选中标记 Widget _paletteCard(AppPalette p, - {required bool selected, required VoidCallback onTap}) { + {required bool zh, required bool selected, required VoidCallback onTap}) { return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(10), @@ -729,7 +731,7 @@ class _ThemePage extends ConsumerWidget { Row( children: [ Expanded( - child: Text(p.name, + child: Text(zh ? p.nameZh : p.nameEn, style: TextStyle( fontSize: 12.5, fontWeight: FontWeight.w600, diff --git a/clients/app/lib/ui/terminal_pane.dart b/clients/app/lib/ui/terminal_pane.dart index 8034d51..00bed0a 100644 --- a/clients/app/lib/ui/terminal_pane.dart +++ b/clients/app/lib/ui/terminal_pane.dart @@ -181,19 +181,20 @@ class _TerminalPaneState extends ConsumerState { } } -/// 终端配色(贴近 Catppuccin Mocha) -final TerminalTheme _termTheme = TerminalTheme( +/// 终端配色 —— getter(非 const 全局),随当前主题 AppColors 实时重算, +/// 切换配色后终端区域颜色立即跟随。 +TerminalTheme get _termTheme => TerminalTheme( cursor: AppColors.text, // 选区半透明,选中后文字仍可读(原来不透明的灰会盖住文字) selection: AppColors.surface2.withValues(alpha: .45), foreground: AppColors.text, background: AppColors.crust, - black: const Color(0xFF45475A), + black: AppColors.surface1, red: AppColors.red, green: AppColors.green, yellow: AppColors.yellow, blue: AppColors.blue, - magenta: Color(0xFFF5C2E7), + magenta: AppColors.pink, cyan: AppColors.sapphire, white: AppColors.text, brightBlack: AppColors.overlay, @@ -201,9 +202,9 @@ final TerminalTheme _termTheme = TerminalTheme( brightGreen: AppColors.green, brightYellow: AppColors.yellow, brightBlue: AppColors.blue, - brightMagenta: Color(0xFFF5C2E7), + brightMagenta: AppColors.mauve, brightCyan: AppColors.sapphire, - brightWhite: Colors.white, + brightWhite: AppColors.text, searchHitBackground: AppColors.yellow, searchHitBackgroundCurrent: AppColors.peach, searchHitForeground: AppColors.crust, From 788481f0db77b043862ed18152be76a832284196 Mon Sep 17 00:00:00 2001 From: xiaowen <0928du@gmail.com> Date: Sun, 28 Jun 2026 13:22:57 +0800 Subject: [PATCH 4/4] =?UTF-8?q?app:=20=E4=BF=AE=E5=A4=8D=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E5=90=8E=E6=97=A0=E6=B3=95=E6=89=BE=E5=9B=9E?= =?UTF-8?q?=20+=20=E9=98=B2=E6=8B=96=E6=8B=BD=E8=BF=87=E7=AA=84=E6=BA=A2?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 严重bug:面板(终端/智能体等)被关掉后无任何恢复入口,只能重启。 新增「重置布局」顶栏按钮,一键恢复所有面板到默认布局。 用 layoutResetProvider 信号解耦,AppShell 监听后重建 layout。 - 给 4 个面板设 minimalSize(200~240px),拖拽分隔条到下限即停, 从根上杜绝「拖到过窄→内容溢出」的 RIGHT OVERFLOWED 警告。 Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/app/lib/core/i18n.dart | 1 + clients/app/lib/state/layout_provider.dart | 14 ++++++++++++++ clients/app/lib/ui/app_shell.dart | 13 +++++++++++-- clients/app/lib/ui/top_bar.dart | 7 +++++++ 4 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 clients/app/lib/state/layout_provider.dart diff --git a/clients/app/lib/core/i18n.dart b/clients/app/lib/core/i18n.dart index 9f3a939..a8efd88 100644 --- a/clients/app/lib/core/i18n.dart +++ b/clients/app/lib/core/i18n.dart @@ -82,6 +82,7 @@ const Map> _dict = { 'top.connect': {AppLang.zh: '连接', AppLang.en: 'Connect'}, 'top.newHost': {AppLang.zh: '新建主机', AppLang.en: 'New Host'}, 'top.split': {AppLang.zh: '分屏', AppLang.en: 'Split'}, + 'top.resetLayout': {AppLang.zh: '重置布局', AppLang.en: 'Reset Layout'}, // ========== 面板标题 ========== 'panel.hosts': {AppLang.zh: '主机', AppLang.en: 'Hosts'}, diff --git a/clients/app/lib/state/layout_provider.dart b/clients/app/lib/state/layout_provider.dart new file mode 100644 index 0000000..5b14a31 --- /dev/null +++ b/clients/app/lib/state/layout_provider.dart @@ -0,0 +1,14 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 布局重置信号 —— 每次自增触发 AppShell 重建默认布局。 +/// 用途:用户关掉某个面板(终端/智能体等)后,可一键找回,避免面板永久消失。 +class LayoutResetNotifier extends Notifier { + @override + int build() => 0; + + /// 触发一次重置(恢复所有面板到默认布局) + void reset() => state++; +} + +final layoutResetProvider = + NotifierProvider(LayoutResetNotifier.new); diff --git a/clients/app/lib/ui/app_shell.dart b/clients/app/lib/ui/app_shell.dart index 0157f79..68c4d56 100644 --- a/clients/app/lib/ui/app_shell.dart +++ b/clients/app/lib/ui/app_shell.dart @@ -4,6 +4,7 @@ import 'package:docking/docking.dart'; import '../theme.dart'; import '../core/i18n.dart'; import '../state/settings_provider.dart'; +import '../state/layout_provider.dart'; import 'top_bar.dart'; import 'status_bar.dart'; import 'left_bar.dart'; @@ -26,6 +27,7 @@ class AppShell extends ConsumerStatefulWidget { class _AppShellState extends ConsumerState { DockingLayout? _layout; AppLang? _builtLang; // 上次构建 layout 用的语言,变化则重建 + int _builtResetTick = 0; // 上次构建时的重置计数,变化则重建 // 按当前语言构建布局。语言切换时重建(会重置拖拽布局,低频可接受)。 DockingLayout _buildLayout(L10n l) { @@ -36,6 +38,7 @@ class _AppShellState extends ConsumerState { name: l.t('panel.hosts'), widget: const LeftBar(), weight: 0.17, + minimalSize: 200, // 防拖到过窄导致内容溢出 closable: false, keepAlive: true, ), @@ -44,11 +47,13 @@ class _AppShellState extends ConsumerState { DockingItem( name: '${l.t('panel.terminal')} · web01', widget: const TerminalPane(), + minimalSize: 200, keepAlive: true, ), DockingItem( name: 'SFTP · web01', widget: const SftpView(), + minimalSize: 200, keepAlive: true, ), ], weight: 0.32), @@ -57,6 +62,7 @@ class _AppShellState extends ConsumerState { name: l.t('panel.agent'), widget: const AiPane(), weight: 0.30, + minimalSize: 240, // 智能体内含输入框+多按钮,留宽一点 keepAlive: true, ), // 右:安全/文件/监控侧面板 @@ -64,6 +70,7 @@ class _AppShellState extends ConsumerState { name: l.t('panel.side'), widget: const RightBar(), weight: 0.21, + minimalSize: 220, closable: false, keepAlive: true, ), @@ -79,13 +86,15 @@ class _AppShellState extends ConsumerState { @override Widget build(BuildContext context) { - // 监听语言:变化时重建 layout(标签名跟随语言) + // 监听语言 + 布局重置信号:任一变化都重建 layout final lang = ref.watch(settingsProvider.select((s) => s.lang)); + final resetTick = ref.watch(layoutResetProvider); final l = ref.watch(l10nProvider); - if (_layout == null || _builtLang != lang) { + if (_layout == null || _builtLang != lang || _builtResetTick != resetTick) { _layout?.dispose(); _layout = _buildLayout(l); _builtLang = lang; + _builtResetTick = resetTick; } return Scaffold( backgroundColor: AppColors.base, diff --git a/clients/app/lib/ui/top_bar.dart b/clients/app/lib/ui/top_bar.dart index b2c43af..7161fdf 100644 --- a/clients/app/lib/ui/top_bar.dart +++ b/clients/app/lib/ui/top_bar.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../theme.dart'; import '../state/search_provider.dart'; import '../state/settings_provider.dart'; +import '../state/layout_provider.dart'; import 'dialogs.dart'; import 'settings_center.dart'; @@ -93,6 +94,12 @@ class TopBar extends ConsumerWidget { const SizedBox(width: 6), _btn(icon: Icons.splitscreen_outlined, label: l.t('top.split')), const SizedBox(width: 6), + // 重置布局:找回被关掉的面板(终端/智能体等) + _btn( + icon: Icons.restart_alt, + label: l.t('top.resetLayout'), + onTap: () => ref.read(layoutResetProvider.notifier).reset()), + const SizedBox(width: 6), _btn( icon: Icons.settings_outlined, onTap: () => showSettingsCenter(context)),