feat: 主题设计更新

This commit is contained in:
chaoszhu
2026-07-25 18:23:23 +08:00
parent ba5e11d63e
commit 6dbccd615b
8 changed files with 276 additions and 13 deletions

86
AGENTS.md Normal file
View File

@@ -0,0 +1,86 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project Overview
EasyNode is a Linux server management panel with WebSSH, WebSFTP, RDP, Docker, AI chat, and batch operations. It's a monorepo with three modules:
- **`server/`** — Koa.js backend (Node.js, CommonJS), REST API + Socket.IO WebSockets
- **`web/`** — Vue 3 + Vite SPA frontend
- **`native/`** — Flutter mobile app (Android/iOS/macOS/Linux, HarmonyOS in progress)
## Common Commands
### Development
```bash
yarn dev # Run web + server concurrently
yarn workspace server run local # Server only (nodemon, EXEC_ENV=local)
yarn workspace web run dev # Web only (Vite, port 18090)
cd native && flutter run # Native app on connected device
```
### Build
```bash
yarn workspace web run build # Vite production build
cd native && flutter build apk --release --split-per-abi # Android APK
```
### Lint
```bash
yarn workspace server run lint # Server ESLint
yarn workspace web run lint # Web ESLint
cd native && flutter analyze # Dart static analysis
```
### Test
```bash
yarn workspace server run test # All server tests
yarn workspace server run test:api # REST API auth tests only
yarn workspace server run test:ws # WebSocket tests only
cd native && flutter test # Flutter unit tests
```
## Architecture
### Server
- **Koa 2** with middleware chain: IP filter → compression → history fallback → static files → response handler → body parser → access logging → auth → router
- **API prefix**: `/api/v1`, HTTP port 8082 (configurable via `HTTP_PORT`)
- **Database**: NeDB (embedded document DB), files in `server/app/db/`. Each domain has its own `.db` file. DB classes use singleton pattern via `server/app/utils/db-class.js`
- **WebSocket namespaces** (Socket.IO): `/terminal`, `/sftp-v2`, `/docker`, `/onekey`, `/server-status`, `/file-transfer` — all require auth via `verifyWsAuthSync`
- **Auth flow**: RSA-2048 keypair generated on first boot → client encrypts password with public key → server decrypts → JWT issued (AES-encrypted before sending) → session cookie stored in `session.db`
- **Global `logger`** object (log4js) available everywhere — no need to import
### Web
- **Vue 3** Composition API + **Pinia** store + **Vue Router 4** (history mode)
- **Element Plus** components auto-imported via `unplugin-vue-components`
- **Path aliases**: `@``src/`, `@views`, `@utils`, `@store`
- **Vite proxy**: `/api/v1` and `/sftp-cache``http://localhost:8082`
- Terminal: xterm.js (`@xterm/xterm`), RDP: `guacamole-common-js`, AI: `ant-design-x-vue`
### Native
- **Flutter** (SDK ^3.11.0) with **Riverpod** state management
- **Direct SSH**: connects to servers via `dartssh2` natively — does NOT go through the server's WebSocket. Fetches AES-GCM encrypted credentials from `POST /api/v1/native/ssh-connection`, decrypts locally
- **Feature-based layout**: `lib/features/` (`auth/`, `servers/`, `terminal/`, `shell/`, `docker/`, `scripts/`, `settings/`), state in `lib/state/`
- **Storage**: `AppStorage` (SharedPreferences) for preferences, `SecureAppStorage` (flutter_secure_storage) for tokens/passwords
- **i18n**: `lib/l10n/strings_en.dart` and `strings_zh.dart` with `AppLocalizations` — dotted key notation (`'sftp.newFile'`)
- **Theme**: Material 3, `AppColorTheme` extension accessed via `context.colors`
## Code Conventions
### Server & Web (JavaScript)
- CommonJS in server, ES modules in web — no TypeScript
- Single quotes, no semicolons, 2-space indent (`eslint.config.cjs` in each)
- Equality: `eqeqeq` enforced
### Native (Dart)
- `flutter_lints` analysis rules
- snake_case filenames, PascalCase classes
- Riverpod providers in `lib/state/`, features in `lib/features/`
## Key Patterns
- **Web ↔ Server**: Axios for REST (token in header, `withCredentials` for cookies), Socket.IO for real-time ops (terminal I/O, SFTP, Docker)
- **Native ↔ Server**: Dio + cookie manager for REST API calls; SSH/SFTP operations happen directly on-device via `dartssh2`
- **Encryption interop**: Server uses `node-rsa` + `crypto-js`; native uses `pointycastle` + `basic_utils` — must stay compatible (RSA PKCS1, AES-GCM)
- **DB singleton**: All NeDB collections accessed via class instances from `db-class.js` — never instantiate DB directly

View File

@@ -18,7 +18,7 @@ import 'features/shell/main_shell_page.dart';
import 'l10n/app_localizations.dart';
import 'state/auth_notifier.dart';
import 'state/auth_state.dart';
import 'core/ui/app_color_theme.dart';
import 'state/color_theme_notifier.dart';
import 'state/locale_notifier.dart';
import 'state/storage_providers.dart';
import 'state/theme_mode_notifier.dart';
@@ -257,6 +257,10 @@ class _AppRootState extends ConsumerState<_AppRoot> {
);
}
final palette = ref.watch(colorThemeProvider);
final lightColors = palette.colorsFor(Brightness.light);
final darkColors = palette.colorsFor(Brightness.dark);
return MaterialApp(
title: 'EasyNode',
debugShowCheckedModeBanner: false,
@@ -265,30 +269,33 @@ class _AppRootState extends ConsumerState<_AppRoot> {
themeMode: ref.watch(themeModeProvider),
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.amber,
extensions: const [AppColorTheme.light],
appBarTheme: const AppBarTheme(
colorScheme: ColorScheme.fromSeed(seedColor: palette.seed),
extensions: [lightColors],
appBarTheme: AppBarTheme(
centerTitle: false,
titleSpacing: 4,
titleTextStyle: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: Color(0xFF2A2418),
color: lightColors.text,
),
),
),
darkTheme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorSchemeSeed: Colors.amber,
extensions: const [AppColorTheme.dark],
appBarTheme: const AppBarTheme(
colorScheme: ColorScheme.fromSeed(
seedColor: palette.seed,
brightness: Brightness.dark,
),
extensions: [darkColors],
appBarTheme: AppBarTheme(
centerTitle: false,
titleSpacing: 4,
titleTextStyle: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: Color(0xFFE8E0D4),
color: darkColors.text,
),
),
),

View File

@@ -97,10 +97,15 @@ class AppStorage {
// ── App theme ──
static const _keyThemeMode = 'app.themeMode';
static const _keyColorTheme = 'app.colorTheme';
String get themeMode => _prefs.getString(_keyThemeMode) ?? 'system';
Future<void> setThemeMode(String v) => _prefs.setString(_keyThemeMode, v);
/// The default is the neutral Light theme; `amber` keeps the original palette.
String get colorTheme => _prefs.getString(_keyColorTheme) ?? 'light';
Future<void> setColorTheme(String v) => _prefs.setString(_keyColorTheme, v);
// ── SFTP create suggestions cache ──
static const _keySftpFileCache = 'sftp.createFileCache';
@@ -119,7 +124,13 @@ class AppStorage {
// ── Tab order ──
static const _keyTabOrder = 'app.tabOrder';
static const defaultTabOrder = ['servers', 'sftp', 'docker', 'scripts', 'settings'];
static const defaultTabOrder = [
'servers',
'sftp',
'docker',
'scripts',
'settings',
];
List<String> get tabOrder {
final stored = _prefs.getStringList(_keyTabOrder);

View File

@@ -41,7 +41,51 @@ class AppColorTheme extends ThemeExtension<AppColorTheme> {
final Color dangerBorder;
final Color fontOnPrimary;
static const light = AppColorTheme(
/// The default neutral light palette.
static const defaultLight = AppColorTheme(
canvas: Color(0xFFF7F9FC),
card: Color(0xFFFFFFFF),
chip: Color(0xFFF0F4F9),
banner: Color(0xFFEAF2FD),
primary: Color(0xFF2B6FCB),
text: Color(0xFF182235),
muted: Color(0xFF596A82),
softMuted: Color(0xFF73849B),
border: Color(0xFFDDE6F0),
strongBorder: Color(0xFFBFCEE0),
accent: Color(0xFF3B82F6),
accentSoft: Color(0xFFEAF2FD),
success: Color(0xFF1E825A),
warning: Color(0xFFA96208),
danger: Color(0xFFC23F42),
dangerSoft: Color(0xFFFFF0F0),
dangerBorder: Color(0xFFF2C7C8),
fontOnPrimary: Color(0xFFFFFFFF),
);
static const defaultDark = AppColorTheme(
canvas: Color(0xFF111A29),
card: Color(0xFF192538),
chip: Color(0xFF223149),
banner: Color(0xFF1B3961),
primary: Color(0xFF91C2FF),
text: Color(0xFFF2F6FC),
muted: Color(0xFFB4C2D6),
softMuted: Color(0xFF8B9AB0),
border: Color(0xFF33455F),
strongBorder: Color(0xFF4A5F7C),
accent: Color(0xFF68A6FF),
accentSoft: Color(0xFF1B3961),
success: Color(0xFF49C58D),
warning: Color(0xFFF2BD4A),
danger: Color(0xFFF27A7C),
dangerSoft: Color(0xFF40242D),
dangerBorder: Color(0xFF6E3B47),
fontOnPrimary: Color(0xFF0E1726),
);
/// The original warm amber palette.
static const amberLight = AppColorTheme(
canvas: Color(0xFFF7EFE0),
card: Color(0xFFFBF5E6),
chip: Color(0xFFF4ECD7),
@@ -62,7 +106,7 @@ class AppColorTheme extends ThemeExtension<AppColorTheme> {
fontOnPrimary: Color(0xFFF7EFE0),
);
static const dark = AppColorTheme(
static const amberDark = AppColorTheme(
canvas: Color(0xFF121212),
card: Color(0xFF1E1E1E),
chip: Color(0xFF2A2A2A),

View File

@@ -12,6 +12,7 @@ import '../../state/tab_order_notifier.dart';
import '../../state/credential_list_notifier.dart';
import '../../state/host_list_notifier.dart';
import '../../state/locale_notifier.dart';
import '../../state/color_theme_notifier.dart';
import '../../state/theme_mode_notifier.dart';
import '../../state/package_info_provider.dart';
import '../../state/plus_discount_notifier.dart';
@@ -186,11 +187,50 @@ class SettingsTab extends ConsumerWidget {
void _showThemePicker(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
final current = ref.read(themeModeProvider);
final currentPalette = ref.read(colorThemeProvider);
showDialog<void>(
context: context,
builder: (ctx) => SimpleDialog(
title: Text(l.tr('settings.theme.title')),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 4),
child: Text(
l.tr('settings.theme.colorTheme'),
style: Theme.of(ctx).textTheme.labelLarge,
),
),
for (final entry in [
(
AppThemePalette.light,
l.tr('settings.theme.lightTheme'),
Icons.light_mode,
),
(
AppThemePalette.amber,
l.tr('settings.theme.amberTheme'),
Icons.wb_sunny_outlined,
),
])
ListTile(
leading: Icon(entry.$3),
title: Text(entry.$2),
trailing: entry.$1 == currentPalette
? Icon(Icons.check, color: context.colors.primary)
: null,
onTap: () {
ref.read(colorThemeProvider.notifier).setPalette(entry.$1);
Navigator.of(ctx).pop();
},
),
const Divider(),
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 4),
child: Text(
l.tr('settings.theme.mode'),
style: Theme.of(ctx).textTheme.labelLarge,
),
),
for (final entry in [
(
ThemeMode.system,
@@ -224,6 +264,21 @@ class SettingsTab extends ConsumerWidget {
};
}
static String _themeSubtitle(
AppLocalizations l,
AppThemePalette palette,
ThemeMode mode,
) {
final paletteLabel = switch (palette) {
AppThemePalette.light => l.tr('settings.theme.lightTheme'),
AppThemePalette.amber => l.tr('settings.theme.amberTheme'),
};
return l.trf('settings.theme.summary', [
paletteLabel,
_themeModeLabel(l, mode),
]);
}
void _showTabOrderDialog(BuildContext context, WidgetRef ref) {
showDialog<void>(
context: context,
@@ -415,8 +470,9 @@ class SettingsTab extends ConsumerWidget {
SettingsRow(
icon: Icons.palette_outlined,
title: l.tr('settings.theme.title'),
subtitle: _themeModeLabel(
subtitle: _themeSubtitle(
l,
ref.watch(colorThemeProvider),
ref.watch(themeModeProvider),
),
onTap: () => _showThemePicker(context, ref),

View File

@@ -311,6 +311,11 @@ const Map<String, String> stringsEn = {
'settings.proxy.subtitle': 'HTTP / SOCKS5 outbound proxies',
'settings.theme.title': 'Appearance',
'settings.theme.colorTheme': 'Color theme',
'settings.theme.mode': 'Display mode',
'settings.theme.lightTheme': 'Default theme',
'settings.theme.amberTheme': 'Amber theme',
'settings.theme.summary': '{0} · {1}',
'settings.theme.system': 'Follow System',
'settings.theme.light': 'Light',
'settings.theme.dark': 'Dark',

View File

@@ -297,6 +297,11 @@ const Map<String, String> stringsZh = {
'settings.proxy.subtitle': 'HTTP / SOCKS5 出站代理',
'settings.theme.title': '主题外观',
'settings.theme.colorTheme': '配色主题',
'settings.theme.mode': '显示模式',
'settings.theme.lightTheme': '默认主题',
'settings.theme.amberTheme': '琥珀色主题',
'settings.theme.summary': '{0} · {1}',
'settings.theme.system': '跟随系统',
'settings.theme.light': '亮色',
'settings.theme.dark': '暗黑',

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/storage/app_storage.dart';
import '../core/ui/app_color_theme.dart';
import 'storage_providers.dart';
enum AppThemePalette {
light,
amber;
AppColorTheme colorsFor(Brightness brightness) {
return switch ((this, brightness)) {
(AppThemePalette.light, Brightness.light) => AppColorTheme.defaultLight,
(AppThemePalette.light, Brightness.dark) => AppColorTheme.defaultDark,
(AppThemePalette.amber, Brightness.light) => AppColorTheme.amberLight,
(AppThemePalette.amber, Brightness.dark) => AppColorTheme.amberDark,
};
}
Color get seed => switch (this) {
AppThemePalette.light => const Color(0xFF2B6FCB),
AppThemePalette.amber => Colors.amber,
};
}
class ColorThemeNotifier extends StateNotifier<AppThemePalette> {
ColorThemeNotifier(this._storage) : super(_fromString(_storage.colorTheme));
final AppStorage _storage;
static AppThemePalette _fromString(String value) {
return switch (value) {
'amber' => AppThemePalette.amber,
_ => AppThemePalette.light,
};
}
Future<void> setPalette(AppThemePalette palette) async {
if (state == palette) return;
await _storage.setColorTheme(palette.name);
state = palette;
}
}
final colorThemeProvider =
StateNotifierProvider<ColorThemeNotifier, AppThemePalette>((ref) {
return ColorThemeNotifier(ref.watch(appStorageProvider));
});