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:
2026-07-04 17:21:00 +08:00
parent 3e06def637
commit 7e5d61c099
20 changed files with 8338 additions and 2 deletions
+51
View File
@@ -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()
})
+35
View File
@@ -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 }
})
}
+60
View File
@@ -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))
}
+23
View File
@@ -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
}
}
+23
View File
@@ -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)
+12
View File
@@ -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>
+68
View File
@@ -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>
+26
View File
@@ -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>
+99
View File
@@ -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>
+7
View File
@@ -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
}
+14
View File
@@ -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')
+16
View File
@@ -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
+36
View File
@@ -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 }
})
+26
View File
@@ -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; }
+42
View File
@@ -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);
}