feat(phase2): connection management

- RedisService in main process (ioredis wrapper, execute, scan, hash ops)
- electron-store for connection/settings persistence
- safeStorage credential encrypt/decrypt
- IPC handlers for redis, storage, credential channels
- Preload API typed with all new channels
- useConnectionStore (Pinia) with connect/disconnect/save/delete
- ConnectionCard with 3D hover effect + status indicator
- ConnectionList with search filter
- NewConnectionDialog (create/edit connection form)
- Sidebar component
- StatusBar with live connection status + pulse animation
- App.vue updated with Sidebar layout
This commit is contained in:
2026-07-04 17:45:19 +08:00
parent 7e5d61c099
commit 4f9398c65d
16 changed files with 1387 additions and 24 deletions
+69 -8
View File
@@ -1,19 +1,38 @@
<script setup lang="ts">
// src/renderer/src/App.vue
import { onMounted } from 'vue'
import TitleBar from './components/TitleBar.vue'
import Sidebar from './components/Sidebar.vue'
import StatusBar from './components/StatusBar.vue'
import { useConnectionStore } from '@renderer/stores/connection'
const connStore = useConnectionStore()
onMounted(() => {
connStore.loadConnections()
})
</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>
<div class="app-body">
<Sidebar />
<main class="app-main">
<div v-if="!connStore.isConnected" class="placeholder-content">
<div class="placeholder-icon">R</div>
<h2>JRedisDesktop</h2>
<p>A modern Redis desktop manager</p>
<p class="placeholder-hint">Select or create a connection to get started</p>
</div>
<div v-else class="connected-content">
<div class="info-card">
<h3>{{ connStore.activeConnection?.name }}</h3>
<pre class="info-text">{{ connStore.serverInfo }}</pre>
</div>
</div>
</main>
</div>
<StatusBar />
</div>
</template>
@@ -26,13 +45,19 @@ import StatusBar from './components/StatusBar.vue'
width: 100%;
}
.app-body {
flex: 1;
display: flex;
overflow: hidden;
}
.app-main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
overflow: hidden;
overflow: auto;
}
.placeholder-content {
@@ -65,4 +90,40 @@ import StatusBar from './components/StatusBar.vue'
.placeholder-content p {
font-size: 13px;
}
.placeholder-hint {
margin-top: 8px;
color: var(--text-muted);
font-size: 12px !important;
}
.connected-content {
width: 100%;
height: 100%;
padding: 20px;
overflow: auto;
}
.info-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 20px;
box-shadow: var(--shadow-sm);
}
.info-card h3 {
font-size: 16px;
font-weight: 600;
color: var(--accent-light);
margin-bottom: 12px;
}
.info-text {
font-size: 12px;
color: var(--text-secondary);
white-space: pre-wrap;
font-family: 'Cascadia Code', 'Fira Code', monospace;
line-height: 1.6;
}
</style>
@@ -0,0 +1,138 @@
<script setup lang="ts">
// src/renderer/src/components/ConnectionCard.vue
import { computed } from 'vue'
import type { ConnectionConfig } from '@renderer/stores/connection'
import { useConnectionStore } from '@renderer/stores/connection'
const props = defineProps<{ connection: ConnectionConfig }>()
const emit = defineEmits<{ edit: [conn: ConnectionConfig]; delete: [id: string] }>()
const connStore = useConnectionStore()
const isActive = computed(() => connStore.activeId === props.connection.id)
const statusColor = computed(() => {
if (isActive.value) return 'var(--success)'
return 'var(--text-muted)'
})
function handleClick() {
if (isActive.value) {
connStore.disconnect()
} else {
connStore.connect(props.connection)
}
}
</script>
<template>
<div
class="connection-card"
:class="{ active: isActive, connecting: connStore.connecting && connStore.activeId === connection.id }"
@click="handleClick"
:style="{ '--status-color': statusColor }"
>
<div class="card-status" />
<div class="card-body">
<div class="card-name">{{ connection.name }}</div>
<div class="card-host">{{ connection.host }}:{{ connection.port }}</div>
</div>
<div class="card-actions" @click.stop>
<button class="card-btn" title="Edit" @click="emit('edit', connection)">&#x270E;</button>
<button class="card-btn card-btn-danger" title="Delete" @click="emit('delete', connection.id)">&#x2715;</button>
</div>
</div>
</template>
<style scoped>
.connection-card {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s;
box-shadow: var(--shadow-sm);
}
.connection-card:hover {
background: var(--bg-card-hover);
transform: perspective(600px) rotateY(-1deg);
}
.connection-card.active {
background: var(--bg-card-active);
border-color: var(--border-accent);
box-shadow: var(--shadow-glow);
}
.connection-card.connecting {
opacity: 0.7;
pointer-events: none;
}
.card-status {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--status-color);
box-shadow: 0 0 8px var(--status-color);
flex-shrink: 0;
}
.card-body {
flex: 1;
min-width: 0;
}
.card-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-host {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
.card-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.15s;
}
.connection-card:hover .card-actions {
opacity: 1;
}
.card-btn {
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--text-muted);
border-radius: 6px;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.card-btn:hover {
background: var(--bg-card-hover);
color: var(--text-primary);
}
.card-btn-danger:hover {
background: rgba(239, 68, 68, 0.15);
color: var(--danger);
}
</style>
@@ -0,0 +1,133 @@
<script setup lang="ts">
// src/renderer/src/components/ConnectionList.vue
import { ref, computed } from 'vue'
import { ElMessageBox } from 'element-plus'
import ConnectionCard from './ConnectionCard.vue'
import { useConnectionStore } from '@renderer/stores/connection'
import type { ConnectionConfig } from '@renderer/stores/connection'
const connStore = useConnectionStore()
const searchQuery = ref('')
const emit = defineEmits<{ edit: [conn: ConnectionConfig | null] }>()
const filteredConnections = computed(() => {
const q = searchQuery.value.toLowerCase()
return connStore.connections
.filter(c => c.name.toLowerCase().includes(q) || c.host.includes(q))
.sort((a, b) => a.order - b.order)
})
async function handleDelete(id: string) {
try {
await ElMessageBox.confirm('Delete this connection?', 'Confirm', {
type: 'warning',
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
})
await connStore.deleteConnection(id)
} catch { /* cancelled */ }
}
function handleEdit(conn: ConnectionConfig) {
emit('edit', conn)
}
function handleNew() {
emit('edit', null)
}
</script>
<template>
<div class="connection-list">
<div class="list-search">
<input
v-model="searchQuery"
class="search-input"
type="text"
placeholder="Search connections..."
/>
</div>
<div class="list-items">
<ConnectionCard
v-for="conn in filteredConnections"
:key="conn.id"
:connection="conn"
@edit="handleEdit"
@delete="handleDelete"
/>
<div v-if="filteredConnections.length === 0" class="list-empty">
<p v-if="searchQuery">No connections match</p>
<p v-else>No connections yet</p>
</div>
</div>
<button class="list-add-btn" @click="handleNew">
+ New Connection
</button>
</div>
</template>
<style scoped>
.connection-list {
display: flex;
flex-direction: column;
height: 100%;
}
.list-search {
padding: 14px;
}
.search-input {
width: 100%;
padding: 10px 14px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.search-input::placeholder {
color: var(--text-muted);
}
.search-input:focus {
border-color: var(--border-accent);
}
.list-items {
flex: 1;
overflow-y: auto;
padding: 0 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.list-empty {
text-align: center;
color: var(--text-muted);
font-size: 13px;
padding: 40px 0;
}
.list-add-btn {
margin: 14px;
padding: 10px;
background: var(--bg-card);
border: 1px dashed var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.list-add-btn:hover {
border-color: var(--border-accent);
color: var(--accent-light);
background: var(--bg-card-hover);
}
</style>
@@ -0,0 +1,137 @@
<script setup lang="ts">
// src/renderer/src/components/NewConnectionDialog.vue
import { reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useConnectionStore } from '@renderer/stores/connection'
import type { ConnectionConfig } from '@renderer/stores/connection'
const props = defineProps<{ visible: boolean; connection: ConnectionConfig | null }>()
const emit = defineEmits<{ 'update:visible': [v: boolean] }>()
const connStore = useConnectionStore()
const form = reactive({
name: '',
host: '127.0.0.1',
port: 6379,
auth: '',
username: '',
tls: false,
separator: ':',
})
const saving = ref(false)
const isEdit = ref(false)
watch(() => props.visible, (v) => {
if (v) {
if (props.connection) {
isEdit.value = true
Object.assign(form, {
name: props.connection.name,
host: props.connection.host,
port: props.connection.port,
auth: '',
username: props.connection.username,
tls: props.connection.tls,
separator: props.connection.separator,
})
} else {
isEdit.value = false
Object.assign(form, {
name: '', host: '127.0.0.1', port: 6379,
auth: '', username: '', tls: false, separator: ':',
})
}
}
})
function close() {
emit('update:visible', false)
}
async function handleSave() {
if (!form.name.trim() || !form.host.trim()) {
ElMessage.warning('Name and host are required')
return
}
saving.value = true
try {
const conn: ConnectionConfig = {
id: props.connection?.id ?? `conn_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
name: form.name.trim(),
host: form.host.trim(),
port: Number(form.port) || 6379,
auth: form.auth,
username: form.username.trim(),
tls: form.tls,
tlsCaPath: '',
tlsCertPath: '',
tlsKeyPath: '',
sshEnabled: false,
sshHost: '',
sshPort: 22,
sshUsername: '',
sshPassword: '',
sshPrivateKeyPath: '',
cluster: false,
sentinelMasterName: '',
separator: form.separator || ':',
order: props.connection?.order ?? connStore.connections.length,
createdAt: props.connection?.createdAt ?? Date.now(),
}
await connStore.saveConnection(conn)
ElMessage.success(isEdit.value ? 'Connection updated' : 'Connection created')
close()
} catch (err: any) {
ElMessage.error(err.message || 'Failed to save connection')
} finally {
saving.value = false
}
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="isEdit ? 'Edit Connection' : 'New Connection'"
width="480px"
:close-on-click-modal="false"
@update:model-value="close"
>
<el-form label-position="top" size="small">
<el-form-item label="Name" required>
<el-input v-model="form.name" placeholder="My Redis" />
</el-form-item>
<el-row :gutter="12">
<el-col :span="16">
<el-form-item label="Host" required>
<el-input v-model="form.host" placeholder="127.0.0.1" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Port">
<el-input-number v-model="form.port" :min="1" :max="65535" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="Password">
<el-input v-model="form.auth" type="password" show-password placeholder="Redis password" />
</el-form-item>
<el-form-item label="Username (Redis 6+ ACL)">
<el-input v-model="form.username" placeholder="default" />
</el-form-item>
<el-form-item label="Key Separator">
<el-input v-model="form.separator" placeholder=":" />
</el-form-item>
<el-form-item>
<el-checkbox v-model="form.tls">Enable TLS/SSL</el-checkbox>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">Cancel</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">
{{ isEdit ? 'Save' : 'Create' }}
</el-button>
</template>
</el-dialog>
</template>
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
// src/renderer/src/components/Sidebar.vue
import { ref } from 'vue'
import ConnectionList from './ConnectionList.vue'
import NewConnectionDialog from './NewConnectionDialog.vue'
import { useConnectionStore } from '@renderer/stores/connection'
import type { ConnectionConfig } from '@renderer/stores/connection'
const connStore = useConnectionStore()
const dialogVisible = ref(false)
const editingConnection = ref<ConnectionConfig | null>(null)
function handleEdit(conn: ConnectionConfig | null) {
editingConnection.value = conn
dialogVisible.value = true
}
</script>
<template>
<aside class="sidebar">
<ConnectionList @edit="handleEdit" />
<NewConnectionDialog
v-model:visible="dialogVisible"
:connection="editingConnection"
/>
</aside>
</template>
<style scoped>
.sidebar {
width: 260px;
flex-shrink: 0;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow: hidden;
}
</style>
+58 -1
View File
@@ -1,10 +1,33 @@
<script setup lang="ts">
// src/renderer/src/components/StatusBar.vue
import { computed } from 'vue'
import { useConnectionStore } from '@renderer/stores/connection'
const connStore = useConnectionStore()
const statusText = computed(() => {
if (connStore.connecting) return 'Connecting...'
if (connStore.error) return `Error: ${connStore.error}`
if (connStore.activeConnection) {
return `${connStore.activeConnection.host}:${connStore.activeConnection.port}`
}
return 'No connection'
})
const statusClass = computed(() => {
if (connStore.connecting) return 'status-connecting'
if (connStore.error) return 'status-error'
if (connStore.isConnected) return 'status-connected'
return 'status-disconnected'
})
</script>
<template>
<footer class="statusbar">
<span class="statusbar-left">No connection</span>
<span class="statusbar-left">
<span class="status-dot" :class="statusClass" />
{{ statusText }}
</span>
<span class="statusbar-right">JRedisDesktop v2.0</span>
</footer>
</template>
@@ -23,4 +46,38 @@
flex-shrink: 0;
user-select: none;
}
.statusbar-left {
display: flex;
align-items: center;
gap: 6px;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--text-muted);
}
.status-dot.status-connected {
background: var(--success);
box-shadow: 0 0 6px var(--success);
}
.status-dot.status-connecting {
background: var(--warning);
box-shadow: 0 0 6px var(--warning);
animation: pulse 1s infinite;
}
.status-dot.status-error {
background: var(--danger);
box-shadow: 0 0 6px var(--danger);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>
+104
View File
@@ -0,0 +1,104 @@
// src/renderer/src/stores/connection.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface ConnectionConfig {
id: string
name: string
host: string
port: number
auth: string
username: string
tls: boolean
tlsCaPath: string
tlsCertPath: string
tlsKeyPath: string
sshEnabled: boolean
sshHost: string
sshPort: number
sshUsername: string
sshPassword: string
sshPrivateKeyPath: string
cluster: boolean
sentinelMasterName: string
separator: string
order: number
createdAt: number
color?: string
}
export const useConnectionStore = defineStore('connection', () => {
const connections = ref<ConnectionConfig[]>([])
const activeId = ref<string | null>(null)
const connecting = ref(false)
const error = ref<string | null>(null)
const serverInfo = ref<string | null>(null)
const activeConnection = computed(() =>
connections.value.find((c: ConnectionConfig) => c.id === activeId.value) ?? null
)
const isConnected = computed(() => activeId.value !== null)
async function loadConnections() {
connections.value = await window.electronAPI.storage.getConnections()
}
async function saveConnection(conn: ConnectionConfig) {
if (conn.auth && !conn.auth.startsWith('enc:')) {
conn.auth = 'enc:' + await window.electronAPI.credential.encrypt(conn.auth)
}
connections.value = await window.electronAPI.storage.saveConnection(conn)
}
async function deleteConnection(id: string) {
if (activeId.value === id) await disconnect()
connections.value = await window.electronAPI.storage.deleteConnection(id)
}
async function reorderConnections(ids: string[]) {
connections.value = await window.electronAPI.storage.reorderConnections(ids)
}
async function connect(conn: ConnectionConfig) {
connecting.value = true
error.value = null
try {
let auth = conn.auth
if (auth.startsWith('enc:')) {
auth = await window.electronAPI.credential.decrypt(auth.slice(4))
}
const result = await window.electronAPI.redis.connect(conn.id, {
host: conn.host,
port: conn.port,
password: auth || undefined,
username: conn.username || undefined,
tls: conn.tls,
})
if (result.success) {
activeId.value = conn.id
serverInfo.value = await window.electronAPI.redis.getInfo(conn.id)
} else {
error.value = result.error || 'Connection failed'
}
} catch (err: any) {
error.value = err.message
} finally {
connecting.value = false
}
}
async function disconnect() {
if (activeId.value) {
await window.electronAPI.redis.disconnect(activeId.value)
activeId.value = null
serverInfo.value = null
}
}
return {
connections, activeId, connecting, error, serverInfo,
activeConnection, isConnected,
loadConnections, saveConnection, deleteConnection, reorderConnections,
connect, disconnect,
}
})