feat(phase1): architecture skeleton
- Electron 33 main process with frameless window, IPC, win-state persistence - Preload with contextBridge typed API (window, theme, dialog) - Vue 3 + Pinia + Element Plus + Vue Router renderer - Dark/light dual theme via CSS custom properties - TitleBar with window controls + theme toggle - StatusBar placeholder - Design spec document
This commit is contained in:
@@ -16,3 +16,6 @@ yarn-error.log*
|
||||
|
||||
# Legacy codebase — archived Vue 2 version, not part of v2 build
|
||||
legacy/
|
||||
|
||||
# Brainstorming artifacts
|
||||
.superpowers/
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
# JRedisDesktop v2 — Design Spec
|
||||
|
||||
**Date:** 2026-07-04
|
||||
**Status:** Approved
|
||||
|
||||
## 1. Project Identity
|
||||
|
||||
Fork of [AnotherRedisDesktopManager](https://github.com/qishibo/AnotherRedisDesktopManager). Full rewrite of the legacy Vue 2 + Webpack 4 + Electron 12 codebase (archived in `legacy/`) to Electron 33 + Vue 3 + Vite 6 + TypeScript 5.
|
||||
|
||||
## 2. Scope & Phasing
|
||||
|
||||
### Phase 1: Architecture Skeleton (Week 1-2)
|
||||
- Electron main process with contextIsolation + preload + contextBridge
|
||||
- Vue 3 SPA with Vue Router + Element Plus + Pinia
|
||||
- electron-vite three-target build pipeline (main/preload/renderer)
|
||||
- Dark/light dual theme system via CSS variables
|
||||
- Window management (min/max/close, win-state persistence)
|
||||
|
||||
### Phase 2: Connection Management MVP (Week 2-3)
|
||||
- Standalone Redis connections (host:port + password/ACL)
|
||||
- TLS support (CA/cert/key file reading via main process)
|
||||
- Connection list CRUD with drag-to-reorder
|
||||
- Secure credential storage (electron-store + safeStorage encryption)
|
||||
- SSH tunnel support (single node)
|
||||
|
||||
### Phase 3: Key Browser MVP (Week 3-4)
|
||||
- Key list with SCAN-based pagination + tree view (key separator)
|
||||
- String viewer/editor (plain text, JSON formatting)
|
||||
- Hash viewer/editor (field table, pagination, add/edit/delete)
|
||||
- Server INFO status dashboard
|
||||
- Database switching
|
||||
|
||||
### Phase 4: Advanced Features (Iterative)
|
||||
- List/Set/Zset/Stream editors
|
||||
- Format viewers (Msgpack, Protobuf, Pickle, Brotli, Gzip, etc.)
|
||||
- Redis CLI terminal
|
||||
- Cluster/Sentinel connections
|
||||
- Slow log, memory analysis, batch delete
|
||||
- i18n (13 languages from legacy)
|
||||
- Monaco JSON editor
|
||||
- Custom shell formatters
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### Process Model
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Main Process │
|
||||
│ ├── Window management │
|
||||
│ ├── RedisService (ioredis + SSH + TLS) │
|
||||
│ ├── electron-store (persistence) │
|
||||
│ ├── safeStorage (credential encryption) │
|
||||
│ └── IPC handlers (typed channels) │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Preload (contextBridge) │
|
||||
│ └── window.electronAPI (typed IPC methods) │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Renderer (Vue 3 SPA) │
|
||||
│ ├── Pinia stores (connection, key, app) │
|
||||
│ ├── Vue Router │
|
||||
│ ├── Element Plus │
|
||||
│ └── Composition API components │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Architectural Decision: RedisService in Main Process
|
||||
ioredis depends on Node `net`/`tls` modules and cannot run in a contextIsolation renderer. All Redis operations go through typed IPC:
|
||||
|
||||
```
|
||||
Renderer (Pinia action) → IPC invoke → Main (RedisService) → ioredis → Redis
|
||||
```
|
||||
|
||||
IPC latency (~1ms) is negligible vs Redis network latency (1-100ms). High-frequency operations (SCAN) use batch IPC to minimize round trips.
|
||||
|
||||
### Renderer Component Tree (MVP)
|
||||
```
|
||||
App.vue
|
||||
├── TitleBar.vue (window controls, connection badge)
|
||||
├── Sidebar.vue
|
||||
│ ├── ConnectionSearch.vue (filter connections)
|
||||
│ ├── ConnectionList.vue (card list, drag sort)
|
||||
│ │ └── ConnectionCard.vue
|
||||
│ └── NewConnectionBtn.vue
|
||||
├── MainArea.vue (tab host)
|
||||
│ ├── StatusView.vue (INFO dashboard with stat cards)
|
||||
│ ├── KeyBrowser.vue (key list + tree + detail)
|
||||
│ │ ├── KeyList.vue (SCAN pagination, tree toggle)
|
||||
│ │ └── KeyDetail.vue (type dispatcher)
|
||||
│ │ ├── StringEditor.vue
|
||||
│ │ └── HashEditor.vue
|
||||
│ └── CliView.vue (Phase 4)
|
||||
└── StatusBar.vue (connection status, latency)
|
||||
```
|
||||
|
||||
### Pinia Stores
|
||||
| Store | Responsibility |
|
||||
|---|---|
|
||||
| `useConnectionStore` | Connections CRUD, active connection, RedisService proxy |
|
||||
| `useKeyStore` | Key list, tree view, selected key, key data cache |
|
||||
| `useAppStore` | Theme (dark/light), settings, i18n locale, UI state |
|
||||
|
||||
### IPC Channel Design
|
||||
| Channel | Type | Direction | Purpose |
|
||||
|---|---|---|---|
|
||||
| `redis:connect` | invoke | renderer→main | Create Redis connection, return client ID |
|
||||
| `redis:disconnect` | invoke | renderer→main | Close connection by ID |
|
||||
| `redis:execute` | invoke | renderer→main | Execute Redis command, return result |
|
||||
| `redis:event` | on | main→renderer | Connection events (ready, error, close) |
|
||||
| `storage:get` | invoke | renderer→main | Read from electron-store |
|
||||
| `storage:set` | invoke | renderer→main | Write to electron-store |
|
||||
| `credential:encrypt` | invoke | renderer→main | Encrypt via safeStorage |
|
||||
| `credential:decrypt` | invoke | renderer→main | Decrypt via safeStorage |
|
||||
| `dialog:openFile` | invoke | renderer→main | Open file picker, return file content |
|
||||
| `window:minimize` | send | renderer→main | Minimize window |
|
||||
| `window:maximize` | send | renderer→main | Toggle maximize |
|
||||
| `window:close` | send | renderer→main | Close window |
|
||||
| `theme:set` | send | renderer→main | Set nativeTheme source |
|
||||
| `theme:os-updated` | on | main→renderer | OS theme change notification |
|
||||
|
||||
## 4. UI Design
|
||||
|
||||
### Visual Style
|
||||
- **Dark theme:** Deep blue-black gradient background, perspective cards with rotateY/X micro-angles, ambient light overlays, glow dot status indicators, multi-tier shadows for depth hierarchy
|
||||
- **Light theme:** Clean white background, soft shadows, purple-blue accent, light frosted glass effects
|
||||
- **Dual theme:** CSS custom properties (`--bg`, `--text`, `--border`, `--accent`, `--shadow`) switch at root level. Follows OS preference by default, manual toggle persisted.
|
||||
|
||||
### Layout
|
||||
- Left sidebar (~260px): connection cards with search + new connection button
|
||||
- Right main area: stat cards row + tab bar + content panel
|
||||
- Bottom status bar: connection status + latency + app version
|
||||
- Resizable sidebar (drag handle)
|
||||
|
||||
## 5. Data Persistence
|
||||
|
||||
### Strategy: Mixed
|
||||
- **electron-store** (main process): Non-sensitive data — connection list, settings, UI preferences
|
||||
- **safeStorage** (main process): Sensitive data — passwords, SSH private keys, TLS certs
|
||||
|
||||
### Data Shape
|
||||
```typescript
|
||||
// electron-store schema
|
||||
interface AppStorage {
|
||||
connections: Connection[];
|
||||
settings: AppSettings;
|
||||
windowState: { x: number; y: number; width: number; height: number; maximized: boolean };
|
||||
}
|
||||
|
||||
interface Connection {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
auth: string; // encrypted via safeStorage
|
||||
username: string; // ACL username
|
||||
tls: boolean;
|
||||
tlsCaPath: string;
|
||||
tlsCertPath: string;
|
||||
tlsKeyPath: string;
|
||||
sshEnabled: boolean;
|
||||
sshHost: string;
|
||||
sshPort: number;
|
||||
sshUsername: string;
|
||||
sshPassword: string; // encrypted via safeStorage
|
||||
sshPrivateKeyPath: string;
|
||||
cluster: boolean;
|
||||
sentinelMasterName: string;
|
||||
separator: string; // key tree separator, default ":"
|
||||
order: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface AppSettings {
|
||||
theme: 'system' | 'dark' | 'light';
|
||||
locale: string;
|
||||
fontSize: number;
|
||||
scanCount: number; // keys per SCAN page
|
||||
// ... more settings as needed
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Security Model
|
||||
|
||||
- **contextIsolation: true** — renderer has zero Node.js/Electron API access
|
||||
- **nodeIntegration: false** — no Node globals in renderer
|
||||
- **contextBridge** — only typed API surface exposed via `window.electronAPI`
|
||||
- **safeStorage** — OS-level encryption for credentials (DPAPI on Windows, Keychain on macOS, libsecret on Linux)
|
||||
- **IPC validation** — all IPC handlers validate input before executing
|
||||
- **No remote module** — all main-process features accessed via IPC
|
||||
|
||||
## 7. Compatibility Mitigations
|
||||
|
||||
| Legacy Pattern | v2 Approach |
|
||||
|---|---|
|
||||
| `redisClient.js` in renderer | RedisService in main, typed IPC proxy |
|
||||
| `fs.readFileSync` for TLS | `dialog:openFile` IPC → main reads file |
|
||||
| `remote.app` sandbox bookmarks | Main process handles file access |
|
||||
| `vue.$bus` / `vue.$message` | mitt EventEmitter + ElMessage composable |
|
||||
| `Vue.prototype.$storage` | `useConnectionStore()` composable |
|
||||
| `Vue.prototype.$util` | Pure functions in `utils/` |
|
||||
| Node `zlib` for decompression | Web `DecompressionStream` + WASM fallback |
|
||||
| `child_process.exec` formatters | IPC → main process spawn (Phase 4) |
|
||||
| `window.localStorage` | electron-store IPC |
|
||||
| Monaco + webpack plugin | ESM Monaco + vite plugin (Phase 4) |
|
||||
|
||||
## 8. Build & Dev
|
||||
|
||||
```bash
|
||||
npm run dev # electron-vite dev (HMR, renderer on :5173)
|
||||
npm run build # production build → out/
|
||||
npm start # preview built app
|
||||
npm run build:unpack # build + electron-builder --dir
|
||||
npm run build:win # build + electron-builder --win
|
||||
npm run build:mac # build + electron-builder --mac
|
||||
npm run build:linux # build + electron-builder --linux
|
||||
```
|
||||
|
||||
## 9. Dependencies
|
||||
|
||||
### Runtime
|
||||
- `ioredis` ^5.7.0 — Redis client (main process)
|
||||
- `tunnel-ssh` ^5.x — SSH tunneling (main process)
|
||||
- `electron-store` ^10.x — persistence (main process)
|
||||
|
||||
### Dev / Renderer
|
||||
- `electron` ^33.4.0
|
||||
- `electron-vite` ^3.0.0
|
||||
- `vue` ^3.5.0
|
||||
- `vue-router` ^4.5.0
|
||||
- `pinia` ^2.x
|
||||
- `element-plus` ^2.9.0
|
||||
- `typescript` ^5.7.0
|
||||
- `vite` ^6.0.0
|
||||
- `electron-builder` ^25.1.0
|
||||
Generated
+7561
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -18,7 +18,8 @@
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx,.vue --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"ioredis": "^5.7.0"
|
||||
"ioredis": "^5.7.0",
|
||||
"pinia": "^3.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// src/main/index.ts
|
||||
import { app, BrowserWindow, nativeTheme } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { restoreAndTrack } from './win-state'
|
||||
import { registerIpcHandlers } from './ipc-handlers'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
function createWindow(): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
title: 'JRedisDesktop',
|
||||
backgroundColor: nativeTheme.shouldUseDarkColors ? '#0f0f1a' : '#f8fafc',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
},
|
||||
frame: false,
|
||||
titleBarStyle: 'hidden',
|
||||
})
|
||||
|
||||
restoreAndTrack(mainWindow)
|
||||
|
||||
if (process.env.ELECTRON_RENDERER_URL) {
|
||||
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => { mainWindow = null })
|
||||
|
||||
nativeTheme.on('updated', () => {
|
||||
mainWindow?.webContents.send('theme:os-updated', nativeTheme.shouldUseDarkColors)
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerIpcHandlers()
|
||||
createWindow()
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => { app.quit() })
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
// src/main/ipc-handlers.ts
|
||||
import { ipcMain, BrowserWindow, nativeTheme, dialog } from 'electron'
|
||||
|
||||
export function registerIpcHandlers(): void {
|
||||
ipcMain.on('window:minimize', () => {
|
||||
BrowserWindow.getFocusedWindow()?.minimize()
|
||||
})
|
||||
|
||||
ipcMain.on('window:maximize', () => {
|
||||
const win = BrowserWindow.getFocusedWindow()
|
||||
if (win) win.isMaximized() ? win.unmaximize() : win.maximize()
|
||||
})
|
||||
|
||||
ipcMain.on('window:close', () => {
|
||||
BrowserWindow.getFocusedWindow()?.close()
|
||||
})
|
||||
|
||||
ipcMain.handle('theme:get', () => nativeTheme.shouldUseDarkColors)
|
||||
|
||||
ipcMain.on('theme:set', (_event, theme: 'system' | 'dark' | 'light') => {
|
||||
nativeTheme.themeSource = theme
|
||||
})
|
||||
|
||||
ipcMain.handle('dialog:openFile', async (_event, options: Electron.OpenDialogOptions) => {
|
||||
const win = BrowserWindow.getFocusedWindow()
|
||||
if (!win) return null
|
||||
const result = await dialog.showOpenDialog(win, options)
|
||||
if (result.canceled || result.filePaths.length === 0) return null
|
||||
const fs = await import('fs')
|
||||
try {
|
||||
const content = fs.readFileSync(result.filePaths[0], 'utf-8')
|
||||
return { path: result.filePaths[0], content }
|
||||
} catch { return null }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// src/main/win-state.ts
|
||||
import { app, screen, BrowserWindow } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { readFileSync, writeFile } from 'fs'
|
||||
|
||||
interface WinState {
|
||||
x: number | null
|
||||
y: number | null
|
||||
width: number
|
||||
height: number
|
||||
maximized: boolean
|
||||
}
|
||||
|
||||
const STATE_FILE = join(app.getPath('userData'), 'jrdm-win-state.json')
|
||||
const MIN_WIDTH = 900
|
||||
const MIN_HEIGHT = 600
|
||||
const DEFAULT_WIDTH = 1200
|
||||
const DEFAULT_HEIGHT = 800
|
||||
|
||||
function parseJson(str: string): WinState | null {
|
||||
try { return JSON.parse(str) }
|
||||
catch { return null }
|
||||
}
|
||||
|
||||
function getLastState(): WinState {
|
||||
try {
|
||||
const raw = readFileSync(STATE_FILE, 'utf-8')
|
||||
const state = parseJson(raw)
|
||||
if (state) {
|
||||
const display = screen.getPrimaryDisplay().workArea
|
||||
if (state.x != null && (state.x < 0 || state.x > display.width - 100)) {
|
||||
state.x = null; state.y = null
|
||||
}
|
||||
if (state.y != null && (state.y < 0 || state.y > display.height - 100)) {
|
||||
state.x = null; state.y = null
|
||||
}
|
||||
state.width = Math.max(state.width || DEFAULT_WIDTH, MIN_WIDTH)
|
||||
state.height = Math.max(state.height || DEFAULT_HEIGHT, MIN_HEIGHT)
|
||||
return state
|
||||
}
|
||||
} catch { /* file doesn't exist yet */ }
|
||||
return { x: null, y: null, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, maximized: false }
|
||||
}
|
||||
|
||||
function saveState(win: BrowserWindow): void {
|
||||
const bounds = win.getBounds()
|
||||
const state = { ...bounds, maximized: win.isMaximized() }
|
||||
writeFile(STATE_FILE, JSON.stringify(state), 'utf-8', () => {})
|
||||
}
|
||||
|
||||
export function restoreAndTrack(win: BrowserWindow): void {
|
||||
const lastState = getLastState()
|
||||
if (lastState.x != null && lastState.y != null) {
|
||||
win.setBounds({ x: lastState.x, y: lastState.y, width: lastState.width, height: lastState.height })
|
||||
}
|
||||
if (lastState.maximized) {
|
||||
win.maximize()
|
||||
}
|
||||
win.on('close', () => saveState(win))
|
||||
}
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
// src/preload/index.d.ts
|
||||
export interface ElectronAPI {
|
||||
window: {
|
||||
minimize: () => void
|
||||
maximize: () => void
|
||||
close: () => void
|
||||
}
|
||||
theme: {
|
||||
get: () => Promise<boolean>
|
||||
set: (theme: 'system' | 'dark' | 'light') => void
|
||||
onOsUpdated: (callback: (isDark: boolean) => void) => void
|
||||
}
|
||||
dialog: {
|
||||
openFile: (options: { title?: string; filters?: { name: string; extensions: string[] }[] }) =>
|
||||
Promise<{ path: string; content: string } | null>
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// src/preload/index.ts
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
const electronAPI = {
|
||||
window: {
|
||||
minimize: () => ipcRenderer.send('window:minimize'),
|
||||
maximize: () => ipcRenderer.send('window:maximize'),
|
||||
close: () => ipcRenderer.send('window:close'),
|
||||
},
|
||||
theme: {
|
||||
get: (): Promise<boolean> => ipcRenderer.invoke('theme:get'),
|
||||
set: (theme: 'system' | 'dark' | 'light') => ipcRenderer.send('theme:set', theme),
|
||||
onOsUpdated: (callback: (isDark: boolean) => void) => {
|
||||
ipcRenderer.on('theme:os-updated', (_event, isDark: boolean) => callback(isDark))
|
||||
},
|
||||
},
|
||||
dialog: {
|
||||
openFile: (options: { title?: string; filters?: { name: string; extensions: string[] }[] }) =>
|
||||
ipcRenderer.invoke('dialog:openFile', options),
|
||||
},
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', electronAPI)
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>JRedisDesktop</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
// src/renderer/src/App.vue
|
||||
import TitleBar from './components/TitleBar.vue'
|
||||
import StatusBar from './components/StatusBar.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<TitleBar />
|
||||
<main class="app-main">
|
||||
<div class="placeholder-content">
|
||||
<div class="placeholder-icon">R</div>
|
||||
<h2>JRedisDesktop</h2>
|
||||
<p>A modern Redis desktop manager</p>
|
||||
</div>
|
||||
</main>
|
||||
<StatusBar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.placeholder-content {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
margin: 0 auto 20px;
|
||||
box-shadow: 0 8px 32px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.placeholder-content h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.placeholder-content p {
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
// src/renderer/src/components/StatusBar.vue
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="statusbar">
|
||||
<span class="statusbar-left">No connection</span>
|
||||
<span class="statusbar-right">JRedisDesktop v2.0</span>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 26px;
|
||||
padding: 0 14px;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
// src/renderer/src/components/TitleBar.vue
|
||||
import { useAppStore } from '@renderer/stores/app'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
function minimize() { window.electronAPI?.window.minimize() }
|
||||
function maximize() { window.electronAPI?.window.maximize() }
|
||||
function close() { window.electronAPI?.window.close() }
|
||||
function toggleTheme() {
|
||||
appStore.setTheme(appStore.isDark ? 'light' : 'dark')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="titlebar">
|
||||
<div class="titlebar-drag">
|
||||
<span class="titlebar-logo">R</span>
|
||||
<span class="titlebar-title">JRedisDesktop</span>
|
||||
</div>
|
||||
<div class="titlebar-actions">
|
||||
<button class="titlebar-btn" title="Toggle theme" @click="toggleTheme">
|
||||
{{ appStore.isDark ? '☀' : '☾' }}
|
||||
</button>
|
||||
<button class="titlebar-btn" title="Minimize" @click="minimize">─</button>
|
||||
<button class="titlebar-btn" title="Maximize" @click="maximize">☐</button>
|
||||
<button class="titlebar-btn titlebar-btn-close" title="Close" @click="close">✕</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 38px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.titlebar-drag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.titlebar-logo {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.titlebar-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.titlebar-actions {
|
||||
display: flex;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.titlebar-btn {
|
||||
width: 42px;
|
||||
height: 38px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.titlebar-btn:hover {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.titlebar-btn-close:hover {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<object, object, unknown>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// src/renderer/src/main.ts
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/global.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { size: 'small' })
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,16 @@
|
||||
// src/renderer/src/router/index.ts
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import App from '@renderer/App.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: App,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,36 @@
|
||||
// src/renderer/src/stores/app.ts
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export type ThemeMode = 'system' | 'dark' | 'light'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const theme = ref<ThemeMode>('system')
|
||||
const isDark = ref(true)
|
||||
|
||||
function applyTheme(mode: ThemeMode, osDark?: boolean) {
|
||||
theme.value = mode
|
||||
const dark = mode === 'system' ? (osDark ?? true) : mode === 'dark'
|
||||
isDark.value = dark
|
||||
document.documentElement.classList.toggle('light', !dark)
|
||||
}
|
||||
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.theme.onOsUpdated((osDark: boolean) => {
|
||||
if (theme.value === 'system') {
|
||||
applyTheme('system', osDark)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
window.electronAPI?.theme.get().then((osDark: boolean) => {
|
||||
applyTheme(theme.value, osDark)
|
||||
})
|
||||
|
||||
function setTheme(mode: ThemeMode) {
|
||||
applyTheme(mode)
|
||||
window.electronAPI?.theme.set(mode)
|
||||
}
|
||||
|
||||
return { theme, isDark, setTheme }
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
/* src/renderer/src/styles/global.css */
|
||||
@import './variables.css';
|
||||
|
||||
*, *::before, *::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
|
||||
@@ -0,0 +1,42 @@
|
||||
/* src/renderer/src/styles/variables.css */
|
||||
:root {
|
||||
--bg-primary: #0f0f1a;
|
||||
--bg-secondary: #1a1a2e;
|
||||
--bg-card: rgba(255, 255, 255, 0.03);
|
||||
--bg-card-hover: rgba(255, 255, 255, 0.06);
|
||||
--bg-card-active: rgba(99, 102, 241, 0.08);
|
||||
--border-color: rgba(255, 255, 255, 0.06);
|
||||
--border-accent: rgba(99, 102, 241, 0.2);
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--accent: #6366f1;
|
||||
--accent-light: #a5b4fc;
|
||||
--success: #22c55e;
|
||||
--warning: #eab308;
|
||||
--danger: #ef4444;
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
--shadow-glow: 0 0 14px rgba(99, 102, 241, 0.2);
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
}
|
||||
|
||||
:root.light {
|
||||
--bg-primary: #f8fafc;
|
||||
--bg-secondary: #f1f5f9;
|
||||
--bg-card: #ffffff;
|
||||
--bg-card-hover: #f1f5f9;
|
||||
--bg-card-active: rgba(99, 102, 241, 0.06);
|
||||
--border-color: rgba(0, 0, 0, 0.06);
|
||||
--border-accent: rgba(99, 102, 241, 0.15);
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--text-muted: #94a3b8;
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
--shadow-glow: 0 0 12px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
+1
-1
@@ -10,5 +10,5 @@
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/renderer/**/*", "src/renderer/src/**/*.d.ts"]
|
||||
"include": ["src/renderer/**/*", "src/renderer/src/**/*.d.ts", "src/preload/index.d.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user