refactor: restructure project layout

- Move main process to electron/main/
- Move preload to electron/preload/
- Add shared types in electron/shared/
- Split redis-service into modular redis/ directory
- Flatten renderer to src/ (remove src/renderer/ nesting)
- Update electron.vite.config.ts paths
- Update tsconfig paths
- Output to electron-dist/
This commit is contained in:
2026-07-04 21:12:50 +08:00
parent bb885c6d6d
commit bdcf4fe299
59 changed files with 654 additions and 624 deletions
-12
View File
@@ -1,12 +0,0 @@
<!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>
-41
View File
@@ -1,41 +0,0 @@
<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 MainArea from './components/MainArea.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 />
<div class="app-body">
<Sidebar />
<MainArea />
</div>
<StatusBar />
</div>
</template>
<style scoped>
.app-shell {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
.app-body {
flex: 1;
display: flex;
overflow: hidden;
}
</style>
-177
View File
@@ -1,177 +0,0 @@
<script setup lang="ts">
// src/renderer/src/components/CliView.vue
import { ref, nextTick, watch } from 'vue'
import { useConnectionStore } from '@renderer/stores/connection'
const connStore = useConnectionStore()
const input = ref('')
const history = ref<{ cmd: string; result: string; error?: boolean }[]>([])
const historyIndex = ref(-1)
const outputRef = ref<HTMLDivElement>()
async function execute() {
if (!connStore.activeId || !input.value.trim()) return
const cmd = input.value.trim()
input.value = ''
historyIndex.value = -1
const parts = cmd.split(/\s+/)
const command = parts[0].toUpperCase()
const args = parts.slice(1)
try {
const result = await window.electronAPI.redis.execute(connStore.activeId, command, args)
let formatted = ''
if (result === null) {
formatted = '(nil)'
} else if (typeof result === 'number') {
formatted = `(integer) ${result}`
} else if (Array.isArray(result)) {
if (result.length === 0) {
formatted = '(empty array)'
} else {
formatted = result.map((item: any, i: number) => {
if (item === null) return `${i + 1}) (nil)`
if (typeof item === 'number') return `${i + 1}) (integer) ${item}`
return `${i + 1}) "${item}"`
}).join('\n')
}
} else if (typeof result === 'object') {
formatted = JSON.stringify(result, null, 2)
} else {
formatted = String(result)
}
history.value.push({ cmd, result: formatted })
} catch (err: any) {
history.value.push({ cmd, result: `ERROR: ${err.message}`, error: true })
}
await nextTick()
if (outputRef.value) {
outputRef.value.scrollTop = outputRef.value.scrollHeight
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault()
execute()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
if (historyIndex.value < history.value.length - 1) {
historyIndex.value++
input.value = history.value[history.value.length - 1 - historyIndex.value].cmd
}
} else if (e.key === 'ArrowDown') {
e.preventDefault()
if (historyIndex.value > 0) {
historyIndex.value--
input.value = history.value[history.value.length - 1 - historyIndex.value].cmd
} else {
historyIndex.value = -1
input.value = ''
}
}
}
</script>
<template>
<div class="cli-view">
<div class="cli-output" ref="outputRef">
<div v-for="(item, index) in history" :key="index" class="cli-entry">
<div class="cli-cmd">
<span class="prompt">&gt;</span>
<span>{{ item.cmd }}</span>
</div>
<pre class="cli-result" :class="{ error: item.error }">{{ item.result }}</pre>
</div>
<div v-if="history.length === 0" class="cli-welcome">
Redis CLI - type commands below
</div>
</div>
<div class="cli-input-bar">
<span class="prompt">&gt;</span>
<input
v-model="input"
class="cli-input"
placeholder="Enter Redis command..."
@keydown="onKeydown"
autofocus
/>
</div>
</div>
</template>
<style scoped>
.cli-view {
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg-primary);
}
.cli-output {
flex: 1;
overflow-y: auto;
padding: 12px;
font-family: 'Cascadia Code', 'Fira Code', monospace;
font-size: 13px;
}
.cli-welcome {
color: var(--text-muted);
padding: 20px 0;
}
.cli-entry {
margin-bottom: 12px;
}
.cli-cmd {
color: var(--accent-light);
}
.prompt {
color: var(--color-green);
margin-right: 6px;
}
.cli-result {
color: var(--text-secondary);
margin: 4px 0 0 18px;
white-space: pre-wrap;
word-break: break-all;
font-size: 12px;
}
.cli-result.error {
color: var(--color-red);
}
.cli-input-bar {
padding: 10px 12px;
border-top: 1px solid var(--border-color);
display: flex;
align-items: center;
flex-shrink: 0;
}
.cli-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-primary);
font-family: 'Cascadia Code', 'Fira Code', monospace;
font-size: 13px;
margin-left: 6px;
}
.cli-input::placeholder {
color: var(--text-muted);
}
</style>
@@ -1,138 +0,0 @@
<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>
@@ -1,187 +0,0 @@
<script setup lang="ts">
// src/renderer/src/components/ConnectionList.vue
import { ref, computed } from 'vue'
import { ElMessageBox, ElMessage } 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)
}
async function handleExport() {
try {
const data = await connStore.exportConnections()
const blob = new Blob([data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `jredis-connections-${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('Connections exported')
} catch (err: any) {
ElMessage.error(err.message)
}
}
async function handleImport() {
try {
const result = await window.electronAPI.dialog.openFile({
title: 'Import Connections',
filters: [{ name: 'JSON', extensions: ['json'] }],
})
if (result) {
const count = await connStore.importConnections(result.content)
ElMessage.success(`Imported ${count} connection(s)`)
await connStore.loadConnections()
}
} catch (err: any) {
ElMessage.error(err.message)
}
}
</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>
<div class="list-footer">
<button class="list-add-btn" @click="handleNew">
+ New Connection
</button>
<div class="list-actions">
<el-button size="small" text @click="handleImport">
<el-icon><Upload /></el-icon> Import
</el-button>
<el-button size="small" text @click="handleExport">
<el-icon><Download /></el-icon> Export
</el-button>
</div>
</div>
</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-footer {
padding: 14px;
border-top: 1px solid var(--border-color);
}
.list-add-btn {
width: 100%;
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;
margin-bottom: 8px;
}
.list-add-btn:hover {
border-color: var(--border-accent);
color: var(--accent-light);
background: var(--bg-card-hover);
}
.list-actions {
display: flex;
justify-content: center;
gap: 8px;
}
</style>
@@ -1,62 +0,0 @@
<script setup lang="ts">
// src/renderer/src/components/DbSelector.vue
import { ref, watch } from 'vue'
import { useConnectionStore } from '@renderer/stores/connection'
import { useKeyStore } from '@renderer/stores/key'
const connStore = useConnectionStore()
const keyStore = useKeyStore()
const selectedDb = ref(0)
const dbs = Array.from({ length: 16 }, (_, i) => i)
watch(
() => connStore.isConnected,
(connected) => {
if (connected) {
selectedDb.value = 0
keyStore.currentDb = 0
}
}
)
async function switchDb(db: number) {
if (!connStore.activeId) return
selectedDb.value = db
await keyStore.selectDb(connStore.activeId, db)
}
</script>
<template>
<div class="db-selector" v-if="connStore.isConnected">
<span class="db-label">DB</span>
<el-select
:model-value="selectedDb"
size="small"
@change="switchDb"
style="width: 70px"
>
<el-option
v-for="db in dbs"
:key="db"
:label="db"
:value="db"
/>
</el-select>
</div>
</template>
<style scoped>
.db-selector {
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
}
.db-label {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
}
</style>
-169
View File
@@ -1,169 +0,0 @@
<script setup lang="ts">
// src/renderer/src/components/HashEditor.vue
import { ref, watch } from 'vue'
import { useKeyStore } from '@renderer/stores/key'
import { useConnectionStore } from '@renderer/stores/connection'
import { ElMessage, ElMessageBox } from 'element-plus'
const keyStore = useKeyStore()
const connStore = useConnectionStore()
interface HashField {
field: string
value: string
}
const fields = ref<HashField[]>([])
const loading = ref(false)
const showAddDialog = ref(false)
const newFieldName = ref('')
const newFieldValue = ref('')
watch(
() => keyStore.keyData,
(newData) => {
if (Array.isArray(newData)) {
fields.value = newData.map(([field, value]) => ({ field, value }))
}
},
{ immediate: true }
)
async function refresh() {
if (!connStore.activeId || !keyStore.selectedKey) return
loading.value = true
try {
const result = await window.electronAPI.redis.hashScan(connStore.activeId, keyStore.selectedKey, '0', 100)
keyStore.keyData = result.fields
} finally {
loading.value = false
}
}
async function saveField(item: HashField) {
if (!connStore.activeId || !keyStore.selectedKey) return
try {
await window.electronAPI.redis.hashSet(connStore.activeId, keyStore.selectedKey, item.field, item.value)
ElMessage.success('Saved')
} catch (err: any) {
ElMessage.error(err.message)
}
}
async function deleteField(item: HashField) {
if (!connStore.activeId || !keyStore.selectedKey) return
try {
await ElMessageBox.confirm(`Delete field "${item.field}"?`, 'Confirm')
await window.electronAPI.redis.hashDel(connStore.activeId, keyStore.selectedKey, item.field)
fields.value = fields.value.filter(f => f.field !== item.field)
ElMessage.success('Deleted')
} catch (err: any) {
if (err !== 'cancel') ElMessage.error(err.message)
}
}
async function addField() {
if (!connStore.activeId || !keyStore.selectedKey || !newFieldName.value) return
try {
await window.electronAPI.redis.hashSet(connStore.activeId, keyStore.selectedKey, newFieldName.value, newFieldValue.value)
fields.value.push({ field: newFieldName.value, value: newFieldValue.value })
showAddDialog.value = false
newFieldName.value = ''
newFieldValue.value = ''
ElMessage.success('Added')
} catch (err: any) {
ElMessage.error(err.message)
}
}
</script>
<template>
<div class="hash-editor">
<div class="editor-toolbar">
<span class="type-badge">HASH</span>
<el-button size="small" type="primary" @click="showAddDialog = true">Add Field</el-button>
<el-button size="small" @click="refresh" :loading="loading">Refresh</el-button>
</div>
<div class="hash-table-wrap">
<el-table :data="fields" size="small" class="hash-table" max-height="400">
<el-table-column prop="field" label="Field" min-width="150">
<template #default="{ row }">
<el-input v-model="row.field" size="small" class="hash-input" />
</template>
</el-table-column>
<el-table-column prop="value" label="Value" min-width="250">
<template #default="{ row }">
<el-input v-model="row.value" size="small" class="hash-input" />
</template>
</el-table-column>
<el-table-column width="120">
<template #default="{ row }">
<el-button size="small" type="primary" text @click="saveField(row)">Save</el-button>
<el-button size="small" type="danger" text @click="deleteField(row)">Del</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-dialog v-model="showAddDialog" title="Add Hash Field" width="400px">
<el-form label-position="top">
<el-form-item label="Field">
<el-input v-model="newFieldName" />
</el-form-item>
<el-form-item label="Value">
<el-input v-model="newFieldValue" type="textarea" :rows="4" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddDialog = false">Cancel</el-button>
<el-button type="primary" @click="addField">Add</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.hash-editor {
height: 100%;
display: flex;
flex-direction: column;
}
.editor-toolbar {
padding: 12px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.type-badge {
font-size: 10px;
font-weight: 700;
color: var(--accent-light);
background: var(--accent-bg);
padding: 2px 8px;
border-radius: 4px;
letter-spacing: 0.05em;
}
.hash-table-wrap {
flex: 1;
padding: 12px;
overflow: auto;
}
.hash-table {
width: 100%;
}
.hash-input :deep(.el-input__wrapper) {
background: var(--bg-primary);
border-color: var(--border-color);
box-shadow: none;
font-family: 'Cascadia Code', 'Fira Code', monospace;
font-size: 12px;
}
</style>
@@ -1,52 +0,0 @@
<script setup lang="ts">
// src/renderer/src/components/KeyBrowser.vue
import { watch } from 'vue'
import { useKeyStore } from '@renderer/stores/key'
import { useConnectionStore } from '@renderer/stores/connection'
import KeyList from './KeyList.vue'
import KeyDetail from './KeyDetail.vue'
const keyStore = useKeyStore()
const connStore = useConnectionStore()
watch(
() => connStore.activeId,
async (newId) => {
if (newId) {
keyStore.selectedKey = null
keyStore.keyData = null
await keyStore.scanKeys(newId, '0', true)
}
},
{ immediate: true }
)
</script>
<template>
<div class="key-browser">
<div class="key-sidebar">
<KeyList />
</div>
<div class="key-main">
<KeyDetail />
</div>
</div>
</template>
<style scoped>
.key-browser {
display: flex;
height: 100%;
}
.key-sidebar {
width: 260px;
flex-shrink: 0;
border-right: 1px solid var(--border-color);
}
.key-main {
flex: 1;
overflow: hidden;
}
</style>
-281
View File
@@ -1,281 +0,0 @@
<script setup lang="ts">
// src/renderer/src/components/KeyDetail.vue
import { computed, ref } from 'vue'
import { useKeyStore } from '@renderer/stores/key'
import { useConnectionStore } from '@renderer/stores/connection'
import { ElMessage, ElMessageBox } from 'element-plus'
import StringEditor from './StringEditor.vue'
import HashEditor from './HashEditor.vue'
import ListEditor from './ListEditor.vue'
import SetEditor from './SetEditor.vue'
import ZsetEditor from './ZsetEditor.vue'
import StreamEditor from './StreamEditor.vue'
const keyStore = useKeyStore()
const connStore = useConnectionStore()
const showTtlDialog = ref(false)
const ttlValue = ref(0)
const showRenameDialog = ref(false)
const newName = ref('')
const typeColor = computed(() => {
const colors: Record<string, string> = {
string: 'var(--color-green)',
hash: 'var(--color-yellow)',
list: 'var(--color-cyan)',
set: 'var(--color-purple)',
zset: 'var(--color-red)',
stream: 'var(--color-orange)',
}
return colors[keyStore.selectedType || ''] || 'var(--text-muted)'
})
const ttlDisplay = computed(() => {
if (keyStore.selectedTTL < 0) return 'No expiry'
if (keyStore.selectedTTL === 0) return 'Expired'
const sec = keyStore.selectedTTL
if (sec < 60) return `${sec}s`
if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`
})
function openTtlDialog() {
ttlValue.value = keyStore.selectedTTL < 0 ? 0 : keyStore.selectedTTL
showTtlDialog.value = true
}
async function saveTtl() {
if (!connStore.activeId || !keyStore.selectedKey) return
try {
await window.electronAPI.redis.setTTL(connStore.activeId, keyStore.selectedKey, ttlValue.value)
keyStore.selectedTTL = ttlValue.value
showTtlDialog.value = false
ElMessage.success('TTL updated')
} catch (err: any) {
ElMessage.error(err.message)
}
}
function openRenameDialog() {
newName.value = keyStore.selectedKey || ''
showRenameDialog.value = true
}
async function saveRename() {
if (!connStore.activeId || !keyStore.selectedKey || !newName.value) return
if (newName.value === keyStore.selectedKey) {
showRenameDialog.value = false
return
}
try {
const exists = await window.electronAPI.redis.existsKey(connStore.activeId, newName.value)
if (exists) {
ElMessage.error('Key already exists')
return
}
await window.electronAPI.redis.renameKey(connStore.activeId, keyStore.selectedKey, newName.value)
const oldKey = keyStore.selectedKey
keyStore.selectedKey = newName.value
showRenameDialog.value = false
ElMessage.success('Renamed')
// Update in key list
const idx = keyStore.keys.findIndex(k => k.name === oldKey)
if (idx >= 0) keyStore.keys[idx].name = newName.value
} catch (err: any) {
ElMessage.error(err.message)
}
}
async function copyKeyName() {
if (!keyStore.selectedKey) return
try {
await navigator.clipboard.writeText(keyStore.selectedKey)
ElMessage.success('Copied')
} catch {
ElMessage.error('Copy failed')
}
}
async function deleteKey() {
if (!connStore.activeId || !keyStore.selectedKey) return
try {
await ElMessageBox.confirm(`Delete key "${keyStore.selectedKey}"?`, 'Confirm')
await window.electronAPI.redis.deleteKey(connStore.activeId, keyStore.selectedKey)
ElMessage.success('Deleted')
keyStore.selectedKey = null
keyStore.keyData = null
} catch (err: any) {
if (err !== 'cancel') ElMessage.error(err.message)
}
}
async function refreshKey() {
if (!connStore.activeId || !keyStore.selectedKey) return
await keyStore.loadKeyData(connStore.activeId, keyStore.selectedKey)
}
</script>
<template>
<div class="key-detail" v-if="keyStore.selectedKey">
<div class="detail-header">
<div class="key-info">
<el-icon class="key-icon" :style="{ color: typeColor }"><Document /></el-icon>
<span class="key-name">{{ keyStore.selectedKey }}</span>
<el-button size="small" text class="copy-btn" @click="copyKeyName">
<el-icon><CopyDocument /></el-icon>
</el-button>
<span class="type-badge" :style="{ color: typeColor, borderColor: typeColor }">
{{ keyStore.selectedType?.toUpperCase() }}
</span>
<el-button size="small" text class="ttl-btn" @click="openTtlDialog">
{{ ttlDisplay }}
</el-button>
</div>
<div class="key-actions">
<el-button size="small" @click="openRenameDialog">Rename</el-button>
<el-button size="small" @click="refreshKey">Refresh</el-button>
<el-button size="small" type="danger" @click="deleteKey">Delete</el-button>
</div>
</div>
<div class="detail-content">
<StringEditor v-if="keyStore.selectedType === 'string'" />
<HashEditor v-else-if="keyStore.selectedType === 'hash'" />
<ListEditor v-else-if="keyStore.selectedType === 'list'" />
<SetEditor v-else-if="keyStore.selectedType === 'set'" />
<ZsetEditor v-else-if="keyStore.selectedType === 'zset'" />
<StreamEditor v-else-if="keyStore.selectedType === 'stream'" />
<div v-else class="unsupported">
<el-icon><Warning /></el-icon>
<p>Type "{{ keyStore.selectedType }}" viewer not yet implemented</p>
</div>
</div>
<!-- TTL Dialog -->
<el-dialog v-model="showTtlDialog" title="Set TTL" width="320px">
<el-form label-position="top">
<el-form-item label="Seconds (-1 = no expiry, 0 = expire now)">
<el-input-number v-model="ttlValue" :min="-1" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showTtlDialog = false">Cancel</el-button>
<el-button type="primary" @click="saveTtl">Save</el-button>
</template>
</el-dialog>
<!-- Rename Dialog -->
<el-dialog v-model="showRenameDialog" title="Rename Key" width="400px">
<el-form-item label="New name">
<el-input v-model="newName" />
</el-form-item>
<template #footer>
<el-button @click="showRenameDialog = false">Cancel</el-button>
<el-button type="primary" @click="saveRename">Rename</el-button>
</template>
</el-dialog>
</div>
<div v-else class="no-selection">
<el-icon size="48" color="var(--text-muted)"><Document /></el-icon>
<p>Select a key to view details</p>
</div>
</template>
<style scoped>
.key-detail {
height: 100%;
display: flex;
flex-direction: column;
}
.detail-header {
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
background: var(--bg-secondary);
}
.key-info {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.key-icon {
flex-shrink: 0;
font-size: 18px;
}
.key-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 300px;
}
.copy-btn {
padding: 2px 4px;
}
.type-badge {
font-size: 10px;
font-weight: 700;
border: 1px solid;
padding: 1px 6px;
border-radius: 4px;
letter-spacing: 0.05em;
}
.ttl-btn {
font-size: 11px;
color: var(--text-muted);
}
.key-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.detail-content {
flex: 1;
overflow: hidden;
}
.no-selection {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.no-selection p {
margin-top: 12px;
font-size: 13px;
}
.unsupported {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.unsupported p {
margin-top: 12px;
font-size: 13px;
}
</style>
-339
View File
@@ -1,339 +0,0 @@
<script setup lang="ts">
// src/renderer/src/components/KeyList.vue
import { ref, watch } from 'vue'
import { useKeyStore } from '@renderer/stores/key'
import { useConnectionStore } from '@renderer/stores/connection'
import { ElMessage, ElMessageBox } from 'element-plus'
const keyStore = useKeyStore()
const connStore = useConnectionStore()
const filterText = ref('')
const patternInput = ref('*')
const showTree = ref(false)
const multiSelect = ref(false)
const selectedKeys = ref<Set<string>>(new Set())
function applyPattern() {
keyStore.pattern = patternInput.value || '*'
if (connStore.activeId) {
keyStore.scanKeys(connStore.activeId, '0', true)
}
}
async function loadMore() {
if (!connStore.activeId) return
await keyStore.scanKeys(connStore.activeId, keyStore.scanCursor)
}
async function refreshKeys() {
if (!connStore.activeId) return
selectedKeys.value.clear()
keyStore.pattern = patternInput.value || '*'
await keyStore.scanKeys(connStore.activeId, '0', true)
}
async function selectKey(keyName: string) {
if (!connStore.activeId) return
if (multiSelect.value) {
if (selectedKeys.value.has(keyName)) {
selectedKeys.value.delete(keyName)
} else {
selectedKeys.value.add(keyName)
}
return
}
keyStore.selectedKey = keyName
await keyStore.loadKeyData(connStore.activeId, keyName)
}
async function batchDelete() {
if (!connStore.activeId || selectedKeys.value.size === 0) return
const count = selectedKeys.value.size
try {
await ElMessageBox.confirm(`Delete ${count} key(s)?`, 'Confirm')
const deleted = await window.electronAPI.redis.batchDelete(connStore.activeId, Array.from(selectedKeys.value))
ElMessage.success(`Deleted ${deleted} key(s)`)
selectedKeys.value.clear()
await refreshKeys()
} catch (err: any) {
if (err !== 'cancel') ElMessage.error(err.message)
}
}
function toggleMultiSelect() {
multiSelect.value = !multiSelect.value
if (!multiSelect.value) {
selectedKeys.value.clear()
}
}
function deselectAll() {
selectedKeys.value.clear()
keyStore.selectedKey = null
keyStore.keyData = null
}
const filteredKeys = ref<string[]>([])
watch(
() => keyStore.keys,
(newKeys) => {
filteredKeys.value = filterText.value
? newKeys.filter(k => k.name.includes(filterText.value)).map(k => k.name)
: newKeys.map(k => k.name)
},
{ deep: true, immediate: true }
)
watch(filterText, (val) => {
filteredKeys.value = val
? keyStore.keys.filter(k => k.name.includes(val)).map(k => k.name)
: keyStore.keys.map(k => k.name)
})
defineExpose({ deselectAll })
</script>
<template>
<div class="key-list">
<div class="key-pattern-row">
<el-input
v-model="patternInput"
size="small"
placeholder="Key pattern (e.g. user:*)"
class="pattern-input"
@keyup.enter="applyPattern"
>
<template #prepend>
<span class="pattern-label">MATCH</span>
</template>
</el-input>
<el-button size="small" type="primary" @click="applyPattern">Go</el-button>
</div>
<div class="key-toolbar">
<el-input
v-model="filterText"
placeholder="Local filter..."
size="small"
clearable
class="filter-input"
/>
<el-button-group class="key-actions">
<el-button size="small" @click="refreshKeys" :disabled="!connStore.activeId">
<el-icon><Refresh /></el-icon>
</el-button>
<el-button
size="small"
:type="showTree ? 'primary' : 'default'"
@click="showTree = !showTree"
>
<el-icon><List /></el-icon>
</el-button>
<el-button
size="small"
:type="multiSelect ? 'danger' : 'default'"
@click="toggleMultiSelect"
>
<el-icon><Check /></el-icon>
</el-button>
</el-button-group>
</div>
<div v-if="multiSelect && selectedKeys.size > 0" class="batch-bar">
<span class="batch-count">{{ selectedKeys.size }} selected</span>
<el-button size="small" type="danger" @click="batchDelete">
Delete Selected
</el-button>
</div>
<div class="key-content">
<div v-if="!showTree" class="key-flat-list">
<div
v-for="keyName in filteredKeys"
:key="keyName"
class="key-item"
:class="{
active: keyStore.selectedKey === keyName && !multiSelect,
selected: selectedKeys.has(keyName),
}"
@click="selectKey(keyName)"
>
<input
v-if="multiSelect"
type="checkbox"
class="key-checkbox"
:checked="selectedKeys.has(keyName)"
@click.stop
/>
<el-icon class="key-icon"><Document /></el-icon>
<span class="key-name">{{ keyName }}</span>
</div>
</div>
<div v-else class="key-tree">
<div
v-for="keyName in filteredKeys"
:key="keyName"
class="key-item"
:class="{
active: keyStore.selectedKey === keyName && !multiSelect,
selected: selectedKeys.has(keyName),
}"
@click="selectKey(keyName)"
>
<input
v-if="multiSelect"
type="checkbox"
class="key-checkbox"
:checked="selectedKeys.has(keyName)"
@click.stop
/>
<el-icon class="key-icon"><Document /></el-icon>
<span class="key-name">{{ keyName }}</span>
</div>
</div>
</div>
<div class="key-footer">
<span class="key-count">{{ keyStore.keys.length }} keys</span>
<el-button
v-if="keyStore.hasMore"
size="small"
type="primary"
text
@click="loadMore"
:loading="keyStore.loading"
>
Load more...
</el-button>
</div>
</div>
</template>
<style scoped>
.key-list {
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg-secondary);
}
.key-pattern-row {
padding: 10px 12px 6px;
display: flex;
gap: 6px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.pattern-input {
flex: 1;
}
.pattern-label {
font-size: 10px;
font-weight: 700;
color: var(--accent-light);
}
.key-toolbar {
padding: 6px 12px 10px;
display: flex;
gap: 8px;
flex-shrink: 0;
}
.filter-input {
flex: 1;
}
.key-actions {
flex-shrink: 0;
}
.batch-bar {
padding: 8px 12px;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--accent-bg);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.batch-count {
font-size: 12px;
font-weight: 600;
color: var(--accent-light);
}
.key-content {
flex: 1;
overflow-y: auto;
}
.key-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
font-size: 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-subtle);
transition: background 0.15s, color 0.15s;
}
.key-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.key-item.active {
background: var(--accent-bg);
color: var(--accent-light);
}
.key-item.selected {
background: rgba(245, 108, 108, 0.08);
color: var(--color-red);
}
.key-checkbox {
flex-shrink: 0;
width: 14px;
height: 14px;
accent-color: var(--accent-light);
}
.key-icon {
flex-shrink: 0;
color: var(--text-muted);
}
.key-item.active .key-icon {
color: var(--accent-light);
}
.key-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'Cascadia Code', 'Fira Code', monospace;
}
.key-footer {
padding: 8px 12px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.key-count {
font-size: 11px;
color: var(--text-muted);
}
</style>
-239
View File
@@ -1,239 +0,0 @@
<script setup lang="ts">
// src/renderer/src/components/ListEditor.vue
import { ref, watch } from 'vue'
import { useKeyStore } from '@renderer/stores/key'
import { useConnectionStore } from '@renderer/stores/connection'
import { ElMessage, ElMessageBox } from 'element-plus'
const keyStore = useKeyStore()
const connStore = useConnectionStore()
const items = ref<string[]>([])
const loading = ref(false)
const pageStart = ref(0)
const pageSize = 50
const totalCount = ref(0)
const showAddDialog = ref(false)
const newItemValue = ref('')
const editingIndex = ref<number | null>(null)
const editingValue = ref('')
async function loadList() {
if (!connStore.activeId || !keyStore.selectedKey) return
loading.value = true
try {
const [len, data] = await Promise.all([
window.electronAPI.redis.listLength(connStore.activeId, keyStore.selectedKey),
window.electronAPI.redis.listRange(connStore.activeId, keyStore.selectedKey, pageStart.value, pageStart.value + pageSize - 1),
])
totalCount.value = len
items.value = data
} finally {
loading.value = false
}
}
watch(
() => keyStore.selectedKey,
() => { if (keyStore.selectedType === 'list') { pageStart.value = 0; loadList() } },
{ immediate: true }
)
async function addItem() {
if (!connStore.activeId || !keyStore.selectedKey || !newItemValue.value) return
try {
await window.electronAPI.redis.listPush(connStore.activeId, keyStore.selectedKey, [newItemValue.value])
showAddDialog.value = false
newItemValue.value = ''
await loadList()
ElMessage.success('Added')
} catch (err: any) {
ElMessage.error(err.message)
}
}
function startEdit(index: number) {
editingIndex.value = index
editingValue.value = items.value[index]
}
async function saveEdit() {
if (!connStore.activeId || !keyStore.selectedKey || editingIndex.value === null) return
try {
await window.electronAPI.redis.listSet(connStore.activeId, keyStore.selectedKey, editingIndex.value, editingValue.value)
items.value[editingIndex.value] = editingValue.value
editingIndex.value = null
ElMessage.success('Saved')
} catch (err: any) {
ElMessage.error(err.message)
}
}
async function removeItem(item: string) {
if (!connStore.activeId || !keyStore.selectedKey) return
try {
await ElMessageBox.confirm(`Remove this element?`, 'Confirm')
await window.electronAPI.redis.listRemove(connStore.activeId, keyStore.selectedKey, 1, item)
await loadList()
ElMessage.success('Removed')
} catch (err: any) {
if (err !== 'cancel') ElMessage.error(err.message)
}
}
function prevPage() {
if (pageStart.value === 0) return
pageStart.value = Math.max(0, pageStart.value - pageSize)
loadList()
}
function nextPage() {
if (pageStart.value + pageSize >= totalCount.value) return
pageStart.value += pageSize
loadList()
}
</script>
<template>
<div class="list-editor">
<div class="editor-toolbar">
<span class="type-badge">LIST</span>
<span class="item-count">{{ totalCount }} elements</span>
<el-button size="small" type="primary" @click="showAddDialog = true">Push</el-button>
<el-button size="small" @click="loadList" :loading="loading">Refresh</el-button>
</div>
<div class="list-content">
<div v-for="(item, index) in items" :key="index" class="list-item">
<span class="item-index">{{ pageStart + index }}</span>
<template v-if="editingIndex === index">
<el-input v-model="editingValue" size="small" class="item-input" />
<el-button size="small" type="primary" text @click="saveEdit">Save</el-button>
<el-button size="small" @click="editingIndex = null">Cancel</el-button>
</template>
<template v-else>
<span class="item-value">{{ item }}</span>
<div class="item-actions">
<el-button size="small" text @click="startEdit(index)">Edit</el-button>
<el-button size="small" type="danger" text @click="removeItem(item)">Del</el-button>
</div>
</template>
</div>
<div v-if="items.length === 0 && !loading" class="empty">Empty list</div>
</div>
<div class="list-pagination" v-if="totalCount > pageSize">
<el-button size="small" :disabled="pageStart === 0" @click="prevPage">Prev</el-button>
<span class="page-info">{{ pageStart + 1 }}-{{ Math.min(pageStart + pageSize, totalCount) }} / {{ totalCount }}</span>
<el-button size="small" :disabled="pageStart + pageSize >= totalCount" @click="nextPage">Next</el-button>
</div>
<el-dialog v-model="showAddDialog" title="Push Element" width="400px">
<el-form-item label="Value">
<el-input v-model="newItemValue" type="textarea" :rows="4" />
</el-form-item>
<template #footer>
<el-button @click="showAddDialog = false">Cancel</el-button>
<el-button type="primary" @click="addItem">Push</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.list-editor {
height: 100%;
display: flex;
flex-direction: column;
}
.editor-toolbar {
padding: 12px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.type-badge {
font-size: 10px;
font-weight: 700;
color: var(--accent-light);
background: var(--accent-bg);
padding: 2px 8px;
border-radius: 4px;
letter-spacing: 0.05em;
}
.item-count {
font-size: 11px;
color: var(--text-muted);
}
.list-content {
flex: 1;
overflow-y: auto;
}
.list-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-subtle);
}
.list-item:hover {
background: var(--bg-hover);
}
.item-index {
font-size: 11px;
color: var(--text-muted);
min-width: 30px;
font-family: 'Cascadia Code', monospace;
}
.item-value {
flex: 1;
font-size: 12px;
font-family: 'Cascadia Code', monospace;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.item-input {
flex: 1;
}
.empty {
padding: 40px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
.list-pagination {
padding: 8px 12px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.page-info {
font-size: 12px;
color: var(--text-secondary);
}
</style>
-165
View File
@@ -1,165 +0,0 @@
<script setup lang="ts">
// src/renderer/src/components/MainArea.vue
import { ref, watch } from 'vue'
import { useConnectionStore } from '@renderer/stores/connection'
import StatusView from './StatusView.vue'
import KeyBrowser from './KeyBrowser.vue'
import CliView from './CliView.vue'
import SlowLogView from './SlowLogView.vue'
import SettingsView from './SettingsView.vue'
const connStore = useConnectionStore()
type Tab = 'status' | 'keys' | 'cli' | 'slowlog' | 'settings'
const activeTab = ref<Tab>('status')
watch(
() => connStore.isConnected,
(connected) => {
if (connected) {
activeTab.value = 'status'
}
}
)
</script>
<template>
<div class="main-area" v-if="connStore.isConnected">
<div class="tab-bar">
<button
class="tab-item"
:class="{ active: activeTab === 'status' }"
@click="activeTab = 'status'"
>
<el-icon><Monitor /></el-icon>
Status
</button>
<button
class="tab-item"
:class="{ active: activeTab === 'keys' }"
@click="activeTab = 'keys'"
>
<el-icon><Document /></el-icon>
Keys
</button>
<button
class="tab-item"
:class="{ active: activeTab === 'cli' }"
@click="activeTab = 'cli'"
>
<el-icon><Monitor /></el-icon>
CLI
</button>
<button
class="tab-item"
:class="{ active: activeTab === 'slowlog' }"
@click="activeTab = 'slowlog'"
>
<el-icon><Clock /></el-icon>
Slow Log
</button>
<button
class="tab-item"
:class="{ active: activeTab === 'settings' }"
@click="activeTab = 'settings'"
>
<el-icon><Setting /></el-icon>
Settings
</button>
</div>
<div class="tab-content">
<StatusView v-if="activeTab === 'status'" />
<KeyBrowser v-else-if="activeTab === 'keys'" />
<CliView v-else-if="activeTab === 'cli'" />
<SlowLogView v-else-if="activeTab === 'slowlog'" />
<SettingsView v-else-if="activeTab === 'settings'" />
</div>
</div>
<div v-else class="no-connection">
<div class="placeholder-icon">JRD</div>
<h2>JRedisDesktop</h2>
<p>Select or create a connection to get started</p>
</div>
</template>
<style scoped>
.main-area {
height: 100%;
display: flex;
flex-direction: column;
}
.tab-bar {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
flex-shrink: 0;
}
.tab-item {
padding: 10px 16px;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: color 0.15s, border-color 0.15s;
}
.tab-item:hover {
color: var(--text-primary);
}
.tab-item.active {
color: var(--accent-light);
border-bottom-color: var(--accent-light);
}
.tab-content {
flex: 1;
overflow: hidden;
}
.no-connection {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.placeholder-icon {
width: 80px;
height: 64px;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
font-weight: 800;
color: #fff;
margin-bottom: 20px;
box-shadow: 0 8px 32px rgba(99, 102, 241, 0.2);
letter-spacing: 1px;
}
.no-connection h2 {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 6px;
}
.no-connection p {
font-size: 13px;
}
</style>
@@ -1,180 +0,0 @@
<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 testing = 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 handleTest() {
if (!form.host.trim()) {
ElMessage.warning('Host is required')
return
}
testing.value = true
try {
const result = await connStore.testConnection({
host: form.host.trim(),
port: Number(form.port) || 6379,
auth: form.auth || undefined,
username: form.username || undefined,
tls: form.tls,
})
if (result.success) {
ElMessage.success('Connection successful')
} else {
ElMessage.error(result.error || 'Connection failed')
}
} finally {
testing.value = 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>
<div class="dialog-footer">
<el-button @click="handleTest" :loading="testing">Test Connection</el-button>
<div class="footer-right">
<el-button @click="close">Cancel</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">
{{ isEdit ? 'Save' : 'Create' }}
</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<style scoped>
.dialog-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.footer-right {
display: flex;
gap: 8px;
}
</style>
-162
View File
@@ -1,162 +0,0 @@
<script setup lang="ts">
// src/renderer/src/components/SetEditor.vue
import { ref, watch } from 'vue'
import { useKeyStore } from '@renderer/stores/key'
import { useConnectionStore } from '@renderer/stores/connection'
import { ElMessage, ElMessageBox } from 'element-plus'
const keyStore = useKeyStore()
const connStore = useConnectionStore()
const members = ref<string[]>([])
const loading = ref(false)
const filterText = ref('')
const showAddDialog = ref(false)
const newMember = ref('')
async function loadMembers() {
if (!connStore.activeId || !keyStore.selectedKey) return
loading.value = true
try {
members.value = await window.electronAPI.redis.setMembers(connStore.activeId, keyStore.selectedKey)
} finally {
loading.value = false
}
}
watch(
() => keyStore.selectedKey,
() => { if (keyStore.selectedType === 'set') loadMembers() },
{ immediate: true }
)
async function addMember() {
if (!connStore.activeId || !keyStore.selectedKey || !newMember.value) return
try {
const added = await window.electronAPI.redis.setAdd(connStore.activeId, keyStore.selectedKey, [newMember.value])
if (added > 0) {
members.value.push(newMember.value)
}
showAddDialog.value = false
newMember.value = ''
ElMessage.success('Added')
} catch (err: any) {
ElMessage.error(err.message)
}
}
async function removeMember(member: string) {
if (!connStore.activeId || !keyStore.selectedKey) return
try {
await ElMessageBox.confirm(`Remove member "${member}"?`, 'Confirm')
await window.electronAPI.redis.setRemove(connStore.activeId, keyStore.selectedKey, [member])
members.value = members.value.filter(m => m !== member)
ElMessage.success('Removed')
} catch (err: any) {
if (err !== 'cancel') ElMessage.error(err.message)
}
}
const filteredMembers = ref<string[]>([])
watch([members, filterText], () => {
filteredMembers.value = filterText.value
? members.value.filter(m => m.includes(filterText.value))
: members.value
}, { immediate: true })
</script>
<template>
<div class="set-editor">
<div class="editor-toolbar">
<span class="type-badge">SET</span>
<span class="item-count">{{ members.length }} members</span>
<el-input v-model="filterText" placeholder="Filter..." size="small" clearable style="width: 150px" />
<el-button size="small" type="primary" @click="showAddDialog = true">Add</el-button>
<el-button size="small" @click="loadMembers" :loading="loading">Refresh</el-button>
</div>
<div class="set-content">
<div v-for="member in filteredMembers" :key="member" class="set-item">
<span class="item-value">{{ member }}</span>
<el-button size="small" type="danger" text @click="removeMember(member)">Del</el-button>
</div>
<div v-if="filteredMembers.length === 0 && !loading" class="empty">Empty set</div>
</div>
<el-dialog v-model="showAddDialog" title="Add Member" width="400px">
<el-form-item label="Member">
<el-input v-model="newMember" />
</el-form-item>
<template #footer>
<el-button @click="showAddDialog = false">Cancel</el-button>
<el-button type="primary" @click="addMember">Add</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.set-editor {
height: 100%;
display: flex;
flex-direction: column;
}
.editor-toolbar {
padding: 12px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.type-badge {
font-size: 10px;
font-weight: 700;
color: var(--accent-light);
background: var(--accent-bg);
padding: 2px 8px;
border-radius: 4px;
letter-spacing: 0.05em;
}
.item-count {
font-size: 11px;
color: var(--text-muted);
}
.set-content {
flex: 1;
overflow-y: auto;
}
.set-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--border-subtle);
}
.set-item:hover {
background: var(--bg-hover);
}
.item-value {
font-size: 12px;
font-family: 'Cascadia Code', monospace;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.empty {
padding: 40px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
</style>
@@ -1,92 +0,0 @@
<script setup lang="ts">
// src/renderer/src/components/SettingsView.vue
import { useAppStore } from '@renderer/stores/app'
import { useI18n } from '@renderer/i18n'
import { ref } from 'vue'
const appStore = useAppStore()
const { t, locale } = useI18n()
const themes = [
{ value: 'dark', labelKey: 'settings.themeDark' },
{ value: 'light', labelKey: 'settings.themeLight' },
{ value: 'system', labelKey: 'settings.themeSystem' },
]
const locales = [
{ value: 'en', label: 'English' },
{ value: 'zh-CN', label: '中文' },
]
</script>
<template>
<div class="settings-view">
<h2 class="settings-title">{{ t('settings.title') }}</h2>
<div class="settings-group">
<label class="settings-label">{{ t('settings.theme') }}</label>
<el-radio-group :model-value="appStore.theme" @change="appStore.setTheme($event)">
<el-radio-button v-for="item in themes" :key="item.value" :value="item.value">
{{ t(item.labelKey) }}
</el-radio-button>
</el-radio-group>
</div>
<div class="settings-group">
<label class="settings-label">{{ t('settings.language') }}</label>
<el-select :model-value="locale" @change="locale = $event" size="small" style="width: 140px">
<el-option v-for="l in locales" :key="l.value" :label="l.label" :value="l.value" />
</el-select>
</div>
<div class="settings-group">
<label class="settings-label">{{ t('settings.scanCount') }}</label>
<el-input-number
:model-value="appStore.scanCount"
@change="appStore.setScanCount($event || 100)"
:min="10"
:max="10000"
:step="10"
size="small"
/>
</div>
<div class="settings-group">
<label class="settings-label">{{ t('settings.fontSize') }}</label>
<el-input-number
:model-value="appStore.fontSize"
@change="appStore.setFontSize($event || 13)"
:min="10"
:max="20"
size="small"
/>
</div>
</div>
</template>
<style scoped>
.settings-view {
padding: 24px;
max-width: 500px;
}
.settings-title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 24px;
}
.settings-group {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
}
.settings-label {
font-size: 13px;
color: var(--text-secondary);
min-width: 140px;
}
</style>
-79
View File
@@ -1,79 +0,0 @@
<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 { useKeyStore } from '@renderer/stores/key'
import type { ConnectionConfig } from '@renderer/stores/connection'
import { useKeyboard } from '@renderer/composables/useKeyboard'
import { ElMessage, ElMessageBox } from 'element-plus'
const connStore = useConnectionStore()
const keyStore = useKeyStore()
const dialogVisible = ref(false)
const editingConnection = ref<ConnectionConfig | null>(null)
function handleEdit(conn: ConnectionConfig | null) {
editingConnection.value = conn
dialogVisible.value = true
}
function handleNew() {
editingConnection.value = null
dialogVisible.value = true
}
async function deleteSelectedKey() {
if (!connStore.activeId || !keyStore.selectedKey) return
try {
await ElMessageBox.confirm(`Delete key "${keyStore.selectedKey}"?`, 'Confirm')
await window.electronAPI.redis.deleteKey(connStore.activeId, keyStore.selectedKey)
ElMessage.success('Deleted')
keyStore.selectedKey = null
keyStore.keyData = null
await keyStore.scanKeys(connStore.activeId, '0', true)
} catch { /* cancelled */ }
}
function deselectKey() {
keyStore.selectedKey = null
keyStore.keyData = null
}
function refreshAll() {
if (connStore.activeId) {
keyStore.scanKeys(connStore.activeId, '0', true)
}
}
useKeyboard({
'ctrl+n': handleNew,
'ctrl+r': refreshAll,
'f5': refreshAll,
'delete': deleteSelectedKey,
'escape': deselectKey,
})
</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>
-103
View File
@@ -1,103 +0,0 @@
<script setup lang="ts">
// src/renderer/src/components/SlowLogView.vue
import { ref } from 'vue'
import { useConnectionStore } from '@renderer/stores/connection'
const connStore = useConnectionStore()
interface SlowLogEntry {
id: number
timestamp: number
duration: number
command: string
}
const entries = ref<SlowLogEntry[]>([])
const loading = ref(false)
async function refresh() {
if (!connStore.activeId) return
loading.value = true
try {
const raw = await window.electronAPI.redis.slowLogGet(connStore.activeId, 50)
entries.value = raw.map((item: any) => ({
id: item[0],
timestamp: item[1],
duration: item[2],
command: Array.isArray(item[3]) ? item[3].join(' ') : String(item[3]),
}))
} finally {
loading.value = false
}
}
function formatDuration(us: number) {
if (us < 1000) return `${us}μs`
if (us < 1000000) return `${(us / 1000).toFixed(1)}ms`
return `${(us / 1000000).toFixed(2)}s`
}
function formatTime(ts: number) {
return new Date(ts * 1000).toLocaleString()
}
defineExpose({ refresh })
</script>
<template>
<div class="slow-log">
<div class="toolbar">
<span class="title">Slow Log</span>
<el-button size="small" @click="refresh" :loading="loading">Refresh</el-button>
</div>
<div class="content">
<el-table :data="entries" size="small" max-height="400" empty-text="No slow log entries">
<el-table-column label="Time" width="180">
<template #default="{ row }">{{ formatTime(row.timestamp) }}</template>
</el-table-column>
<el-table-column label="Duration" width="100">
<template #default="{ row }">{{ formatDuration(row.duration) }}</template>
</el-table-column>
<el-table-column label="Command" min-width="300">
<template #default="{ row }">
<span class="cmd-text">{{ row.command }}</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<style scoped>
.slow-log {
height: 100%;
display: flex;
flex-direction: column;
}
.toolbar {
padding: 12px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.content {
flex: 1;
padding: 12px;
overflow: auto;
}
.cmd-text {
font-family: 'Cascadia Code', monospace;
font-size: 12px;
}
</style>
-100
View File
@@ -1,100 +0,0 @@
<script setup lang="ts">
// src/renderer/src/components/StatusBar.vue
import { computed } from 'vue'
import { useConnectionStore } from '@renderer/stores/connection'
import DbSelector from './DbSelector.vue'
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 && connStore.healthOk) return 'status-connected'
if (connStore.isConnected && !connStore.healthOk) return 'status-unstable'
return 'status-disconnected'
})
</script>
<template>
<footer class="statusbar">
<span class="statusbar-left">
<span class="status-dot" :class="statusClass" />
{{ statusText }}
</span>
<span class="statusbar-center">
<DbSelector v-if="connStore.isConnected" />
</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;
}
.statusbar-left {
display: flex;
align-items: center;
gap: 6px;
}
.statusbar-center {
flex: 1;
display: flex;
justify-content: center;
}
.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);
}
.status-dot.status-unstable {
background: var(--warning);
box-shadow: 0 0 6px var(--warning);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>
-182
View File
@@ -1,182 +0,0 @@
<script setup lang="ts">
// src/renderer/src/components/StatusView.vue
import { ref, onMounted } from 'vue'
import { useConnectionStore } from '@renderer/stores/connection'
const connStore = useConnectionStore()
const info = ref('')
const dbSize = ref(0)
interface InfoSection {
title: string
items: { key: string; value: string }[]
}
const sections = ref<InfoSection[]>([])
function parseInfo(raw: string): InfoSection[] {
const result: InfoSection[] = []
let current: InfoSection | null = null
for (const line of raw.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) {
if (trimmed.startsWith('#')) {
if (current) result.push(current)
current = { title: trimmed.slice(2), items: [] }
}
continue
}
const colonIdx = trimmed.indexOf(':')
if (colonIdx > 0 && current) {
current.items.push({
key: trimmed.slice(0, colonIdx),
value: trimmed.slice(colonIdx + 1),
})
}
}
if (current) result.push(current)
return result
}
async function refresh() {
if (!connStore.activeId) return
info.value = await connStore.getInfo()
sections.value = parseInfo(info.value)
dbSize.value = await window.electronAPI.redis.dbSize(connStore.activeId)
}
onMounted(refresh)
defineExpose({ refresh })
</script>
<template>
<div class="status-view" v-if="connStore.activeConnection">
<div class="status-header">
<h2>{{ connStore.activeConnection.name }}</h2>
<el-button size="small" @click="refresh">Refresh</el-button>
</div>
<div class="info-grid">
<div class="info-card">
<div class="info-label">Database</div>
<div class="info-value">{{ connStore.activeConnection.host }}:{{ connStore.activeConnection.port }}</div>
</div>
<div class="info-card">
<div class="info-label">Keys</div>
<div class="info-value">{{ dbSize.toLocaleString() }}</div>
</div>
</div>
<div class="info-sections">
<div v-for="section in sections" :key="section.title" class="info-section">
<h3 class="section-title">{{ section.title }}</h3>
<div class="section-items">
<div v-for="item in section.items" :key="item.key" class="info-row">
<span class="info-key">{{ item.key }}</span>
<span class="info-val">{{ item.value }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.status-view {
padding: 20px;
height: 100%;
overflow: auto;
}
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.status-header h2 {
font-size: 18px;
font-weight: 700;
color: var(--accent-light);
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.info-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 16px;
box-shadow: var(--shadow-sm);
}
.info-label {
font-size: 11px;
text-transform: uppercase;
color: var(--text-muted);
letter-spacing: 0.05em;
margin-bottom: 4px;
}
.info-value {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.info-sections {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.info-section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
overflow: hidden;
}
.section-title {
font-size: 12px;
font-weight: 700;
color: var(--accent-light);
padding: 10px 14px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
}
.section-items {
padding: 4px 0;
max-height: 200px;
overflow-y: auto;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 4px 14px;
font-size: 12px;
}
.info-row:hover {
background: var(--bg-hover);
}
.info-key {
color: var(--text-secondary);
}
.info-val {
color: var(--text-primary);
font-family: 'Cascadia Code', 'Fira Code', monospace;
}
</style>
@@ -1,280 +0,0 @@
<script setup lang="ts">
// src/renderer/src/components/StreamEditor.vue
import { ref, watch } from 'vue'
import { useKeyStore } from '@renderer/stores/key'
import { useConnectionStore } from '@renderer/stores/connection'
import { ElMessage, ElMessageBox } from 'element-plus'
const keyStore = useKeyStore()
const connStore = useConnectionStore()
interface StreamEntry {
id: string
fields: Record<string, string>
}
const entries = ref<StreamEntry[]>([])
const loading = ref(false)
const showAddDialog = ref(false)
const newEntryId = ref('*')
const newFields = ref<{ key: string; value: string }[]>([{ key: '', value: '' }])
const trimming = ref(false)
const trimMaxLen = ref(1000)
async function loadEntries() {
if (!connStore.activeId || !keyStore.selectedKey) return
loading.value = true
try {
const data = await window.electronAPI.redis.streamRange(
connStore.activeId, keyStore.selectedKey, '-', '+', 100
)
entries.value = data.map((item: any) => ({
id: item[0],
fields: parseFields(item[1]),
}))
} finally {
loading.value = false
}
}
function parseFields(raw: any[]): Record<string, string> {
const result: Record<string, string> = {}
for (let i = 0; i < raw.length; i += 2) {
result[raw[i]] = raw[i + 1]
}
return result
}
watch(
() => keyStore.selectedKey,
() => { if (keyStore.selectedType === 'stream') loadEntries() },
{ immediate: true }
)
function addField() {
newFields.value.push({ key: '', value: '' })
}
function removeField(index: number) {
if (newFields.value.length > 1) {
newFields.value.splice(index, 1)
}
}
async function addEntry() {
if (!connStore.activeId || !keyStore.selectedKey) return
const fieldValues: string[] = []
for (const f of newFields.value) {
if (f.key && f.value) {
fieldValues.push(f.key, f.value)
}
}
if (fieldValues.length === 0) {
ElMessage.warning('Add at least one field')
return
}
try {
const id = await window.electronAPI.redis.streamAdd(
connStore.activeId, keyStore.selectedKey, newEntryId.value, fieldValues
)
entries.value.push({
id,
fields: Object.fromEntries(newFields.value.filter(f => f.key).map(f => [f.key, f.value])),
})
showAddDialog.value = false
newFields.value = [{ key: '', value: '' }]
ElMessage.success('Added')
} catch (err: any) {
ElMessage.error(err.message)
}
}
async function deleteEntry(entry: StreamEntry) {
if (!connStore.activeId || !keyStore.selectedKey) return
try {
await ElMessageBox.confirm(`Delete entry "${entry.id}"?`, 'Confirm')
await window.electronAPI.redis.streamDel(connStore.activeId, keyStore.selectedKey, [entry.id])
entries.value = entries.value.filter(e => e.id !== entry.id)
ElMessage.success('Deleted')
} catch (err: any) {
if (err !== 'cancel') ElMessage.error(err.message)
}
}
async function trimStream() {
if (!connStore.activeId || !keyStore.selectedKey) return
try {
await ElMessageBox.confirm(`Trim stream to max ${trimMaxLen.value} entries?`, 'Confirm')
trimming.value = true
const removed = await window.electronAPI.redis.streamTrim(
connStore.activeId, keyStore.selectedKey, trimMaxLen.value
)
ElMessage.success(`Removed ${removed} entries`)
await loadEntries()
} catch (err: any) {
if (err !== 'cancel') ElMessage.error(err.message)
} finally {
trimming.value = false
}
}
</script>
<template>
<div class="stream-editor">
<div class="editor-toolbar">
<span class="type-badge">STREAM</span>
<span class="item-count">{{ entries.length }} entries</span>
<el-button size="small" type="primary" @click="showAddDialog = true">Add</el-button>
<el-popconfirm title="Trim stream?">
<template #reference>
<el-button size="small" :loading="trimming">Trim</el-button>
</template>
</el-popconfirm>
<el-button size="small" @click="loadEntries" :loading="loading">Refresh</el-button>
</div>
<div class="stream-content">
<div v-for="entry in entries" :key="entry.id" class="stream-entry">
<div class="entry-header">
<span class="entry-id">{{ entry.id }}</span>
<el-button size="small" type="danger" text @click="deleteEntry(entry)">Del</el-button>
</div>
<div class="entry-fields">
<div v-for="(val, key) in entry.fields" :key="key" class="field-row">
<span class="field-key">{{ key }}</span>
<span class="field-value">{{ val }}</span>
</div>
</div>
</div>
<div v-if="entries.length === 0 && !loading" class="empty">Empty stream</div>
</div>
<el-dialog v-model="showAddDialog" title="Add Stream Entry" width="500px">
<el-form label-position="top">
<el-form-item label="ID (use * for auto)">
<el-input v-model="newEntryId" />
</el-form-item>
<el-form-item label="Fields">
<div v-for="(_, index) in newFields" :key="index" class="field-input-row">
<el-input v-model="newFields[index].key" placeholder="Field" size="small" />
<el-input v-model="newFields[index].value" placeholder="Value" size="small" />
<el-button size="small" type="danger" text @click="removeField(index)"
:disabled="newFields.length === 1">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<el-button size="small" type="primary" text @click="addField">+ Add field</el-button>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddDialog = false">Cancel</el-button>
<el-button type="primary" @click="addEntry">Add</el-button>
</template>
</el-dialog>
<el-dialog v-model="trimming" title="Trim Stream" width="300px">
<el-form-item label="Max length">
<el-input-number v-model="trimMaxLen" :min="1" />
</el-form-item>
</el-dialog>
</div>
</template>
<style scoped>
.stream-editor {
height: 100%;
display: flex;
flex-direction: column;
}
.editor-toolbar {
padding: 12px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.type-badge {
font-size: 10px;
font-weight: 700;
color: var(--accent-light);
background: var(--accent-bg);
padding: 2px 8px;
border-radius: 4px;
letter-spacing: 0.05em;
}
.item-count {
font-size: 11px;
color: var(--text-muted);
}
.stream-content {
flex: 1;
overflow-y: auto;
}
.stream-entry {
border-bottom: 1px solid var(--border-subtle);
padding: 10px 12px;
}
.stream-entry:hover {
background: var(--bg-hover);
}
.entry-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.entry-id {
font-size: 11px;
font-family: 'Cascadia Code', monospace;
color: var(--accent-light);
font-weight: 600;
}
.entry-fields {
display: grid;
gap: 4px;
}
.field-row {
display: flex;
gap: 12px;
font-size: 12px;
}
.field-key {
color: var(--color-yellow);
font-family: 'Cascadia Code', monospace;
min-width: 100px;
}
.field-value {
color: var(--text-primary);
font-family: 'Cascadia Code', monospace;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.field-input-row {
display: flex;
gap: 8px;
margin-bottom: 6px;
}
.empty {
padding: 40px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
</style>
@@ -1,116 +0,0 @@
<script setup lang="ts">
// src/renderer/src/components/StringEditor.vue
import { ref, watch } from 'vue'
import { useKeyStore } from '@renderer/stores/key'
import { useConnectionStore } from '@renderer/stores/connection'
import { ElMessage } from 'element-plus'
const keyStore = useKeyStore()
const connStore = useConnectionStore()
const editedValue = ref('')
const isJson = ref(false)
const isEditing = ref(false)
function formatValue(val: string | null) {
if (val === null) return ''
try {
const parsed = JSON.parse(val)
isJson.value = true
return JSON.stringify(parsed, null, 2)
} catch {
isJson.value = false
return val
}
}
watch(
() => keyStore.keyData,
(newVal) => {
if (typeof newVal === 'string') {
editedValue.value = formatValue(newVal)
isEditing.value = false
}
},
{ immediate: true }
)
async function save() {
if (!connStore.activeId || !keyStore.selectedKey) return
try {
await window.electronAPI.redis.setString(connStore.activeId, keyStore.selectedKey, editedValue.value)
keyStore.keyData = editedValue.value
isEditing.value = false
ElMessage.success('Saved')
} catch (err: any) {
ElMessage.error(err.message)
}
}
</script>
<template>
<div class="string-editor">
<div class="editor-toolbar">
<span class="type-badge">STRING</span>
<el-button v-if="!isEditing" size="small" type="primary" @click="isEditing = true">Edit</el-button>
<template v-else>
<el-button size="small" type="primary" @click="save">Save</el-button>
<el-button size="small" @click="isEditing = false; editedValue = formatValue(keyStore.keyData as string)">Cancel</el-button>
</template>
</div>
<div class="editor-content">
<el-input
v-model="editedValue"
type="textarea"
:rows="12"
:disabled="!isEditing"
:class="{ json: isJson }"
spellcheck="false"
class="editor-textarea"
/>
</div>
</div>
</template>
<style scoped>
.string-editor {
height: 100%;
display: flex;
flex-direction: column;
}
.editor-toolbar {
padding: 12px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.type-badge {
font-size: 10px;
font-weight: 700;
color: var(--accent-light);
background: var(--accent-bg);
padding: 2px 8px;
border-radius: 4px;
letter-spacing: 0.05em;
}
.editor-content {
flex: 1;
padding: 12px;
overflow: auto;
}
.editor-textarea :deep(.el-textarea__inner) {
background: var(--bg-primary);
border-color: var(--border-color);
color: var(--text-primary);
font-family: 'Cascadia Code', 'Fira Code', monospace;
font-size: 13px;
line-height: 1.6;
resize: none;
}
</style>
-100
View File
@@ -1,100 +0,0 @@
<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">JRD</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: 36px;
height: 24px;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 800;
color: #fff;
letter-spacing: 0.5px;
}
.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>
-186
View File
@@ -1,186 +0,0 @@
<script setup lang="ts">
// src/renderer/src/components/ZsetEditor.vue
import { ref, watch } from 'vue'
import { useKeyStore } from '@renderer/stores/key'
import { useConnectionStore } from '@renderer/stores/connection'
import { ElMessage, ElMessageBox } from 'element-plus'
const keyStore = useKeyStore()
const connStore = useConnectionStore()
interface ZsetItem {
member: string
score: number
}
const items = ref<ZsetItem[]>([])
const loading = ref(false)
const filterText = ref('')
const showAddDialog = ref(false)
const newMember = ref('')
const newScore = ref(0)
async function loadMembers() {
if (!connStore.activeId || !keyStore.selectedKey) return
loading.value = true
try {
const data = await window.electronAPI.redis.zsetRange(connStore.activeId, keyStore.selectedKey, 0, -1, true)
const result: ZsetItem[] = []
for (let i = 0; i < data.length; i += 2) {
result.push({ member: data[i], score: Number(data[i + 1]) })
}
items.value = result
} finally {
loading.value = false
}
}
watch(
() => keyStore.selectedKey,
() => { if (keyStore.selectedType === 'zset') loadMembers() },
{ immediate: true }
)
async function addMember() {
if (!connStore.activeId || !keyStore.selectedKey || !newMember.value) return
try {
await window.electronAPI.redis.zsetAdd(connStore.activeId, keyStore.selectedKey, newScore.value, newMember.value)
items.value.push({ member: newMember.value, score: newScore.value })
items.value.sort((a, b) => a.score - b.score)
showAddDialog.value = false
newMember.value = ''
newScore.value = 0
ElMessage.success('Added')
} catch (err: any) {
ElMessage.error(err.message)
}
}
async function removeMember(member: string) {
if (!connStore.activeId || !keyStore.selectedKey) return
try {
await ElMessageBox.confirm(`Remove member "${member}"?`, 'Confirm')
await window.electronAPI.redis.zsetRemove(connStore.activeId, keyStore.selectedKey, [member])
items.value = items.value.filter(i => i.member !== member)
ElMessage.success('Removed')
} catch (err: any) {
if (err !== 'cancel') ElMessage.error(err.message)
}
}
const filteredItems = ref<ZsetItem[]>([])
watch([items, filterText], () => {
filteredItems.value = filterText.value
? items.value.filter(i => i.member.includes(filterText.value))
: items.value
}, { immediate: true })
</script>
<template>
<div class="zset-editor">
<div class="editor-toolbar">
<span class="type-badge">ZSET</span>
<span class="item-count">{{ items.length }} members</span>
<el-input v-model="filterText" placeholder="Filter..." size="small" clearable style="width: 150px" />
<el-button size="small" type="primary" @click="showAddDialog = true">Add</el-button>
<el-button size="small" @click="loadMembers" :loading="loading">Refresh</el-button>
</div>
<div class="zset-content">
<div v-for="item in filteredItems" :key="item.member" class="zset-item">
<span class="item-score">{{ item.score }}</span>
<span class="item-member">{{ item.member }}</span>
<el-button size="small" type="danger" text @click="removeMember(item.member)">Del</el-button>
</div>
<div v-if="filteredItems.length === 0 && !loading" class="empty">Empty sorted set</div>
</div>
<el-dialog v-model="showAddDialog" title="Add Member" width="400px">
<el-form label-position="top">
<el-form-item label="Member">
<el-input v-model="newMember" />
</el-form-item>
<el-form-item label="Score">
<el-input-number v-model="newScore" :precision="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddDialog = false">Cancel</el-button>
<el-button type="primary" @click="addMember">Add</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.zset-editor {
height: 100%;
display: flex;
flex-direction: column;
}
.editor-toolbar {
padding: 12px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.type-badge {
font-size: 10px;
font-weight: 700;
color: var(--accent-light);
background: var(--accent-bg);
padding: 2px 8px;
border-radius: 4px;
letter-spacing: 0.05em;
}
.item-count {
font-size: 11px;
color: var(--text-muted);
}
.zset-content {
flex: 1;
overflow-y: auto;
}
.zset-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-subtle);
}
.zset-item:hover {
background: var(--bg-hover);
}
.item-score {
font-size: 12px;
font-family: 'Cascadia Code', monospace;
color: var(--accent-light);
min-width: 60px;
}
.item-member {
flex: 1;
font-size: 12px;
font-family: 'Cascadia Code', monospace;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty {
padding: 40px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
</style>
@@ -1,25 +0,0 @@
// src/renderer/src/composables/useKeyboard.ts
import { onMounted, onUnmounted } from 'vue'
interface ShortcutMap {
[key: string]: () => void
}
export function useKeyboard(shortcuts: ShortcutMap) {
function handler(e: KeyboardEvent) {
const key: string[] = []
if (e.ctrlKey || e.metaKey) key.push('ctrl')
if (e.shiftKey) key.push('shift')
if (e.altKey) key.push('alt')
key.push(e.key.toLowerCase())
const combo = key.join('+')
if (shortcuts[combo]) {
e.preventDefault()
shortcuts[combo]()
}
}
onMounted(() => window.addEventListener('keydown', handler))
onUnmounted(() => window.removeEventListener('keydown', handler))
}
-7
View File
@@ -1,7 +0,0 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
-52
View File
@@ -1,52 +0,0 @@
// src/renderer/src/i18n/index.ts
import { ref, computed } from 'vue'
import en from './locales/en'
import zhCN from './locales/zh-CN'
type Messages = typeof en
type NestedKeyOf<T> = {
[K in keyof T & string]: T[K] extends object
? `${K}.${NestedKeyOf<T[K]>}`
: K
}[keyof T & string]
const messages: Record<string, Messages> = {
en,
'zh-CN': zhCN,
}
const currentLocale = ref<string>(
localStorage.getItem('locale') || 'zh-CN'
)
export function useI18n() {
const locale = computed({
get: () => currentLocale.value,
set: (val: string) => {
currentLocale.value = val
localStorage.setItem('locale', val)
},
})
function t(path: string, params?: Record<string, string | number>): string {
const keys = path.split('.')
let result: any = messages[currentLocale.value] || messages.en
for (const key of keys) {
result = result?.[key]
}
if (typeof result !== 'string') return path
if (params) {
return Object.entries(params).reduce(
(str, [k, v]) => str.replace(`{${k}}`, String(v)),
result
)
}
return result
}
return { t, locale, currentLocale }
}
-123
View File
@@ -1,123 +0,0 @@
// src/renderer/src/i18n/locales/en.ts
export default {
common: {
ok: 'OK',
cancel: 'Cancel',
save: 'Save',
delete: 'Delete',
edit: 'Edit',
add: 'Add',
refresh: 'Refresh',
search: 'Search',
confirm: 'Confirm',
success: 'Success',
error: 'Error',
loading: 'Loading...',
noData: 'No data',
},
app: {
title: 'JRedisDesktop',
subtitle: 'A modern Redis desktop manager',
placeholder: 'Select or create a connection to get started',
},
connection: {
title: 'Connections',
new: 'New Connection',
edit: 'Edit Connection',
name: 'Name',
host: 'Host',
port: 'Port',
password: 'Password',
username: 'Username',
tls: 'Enable TLS',
ssh: 'SSH Tunnel',
connect: 'Connect',
disconnect: 'Disconnect',
deleteConfirm: 'Delete this connection?',
testSuccess: 'Connected successfully',
testFailed: 'Connection failed',
connected: 'Connected',
disconnected: 'Disconnected',
},
key: {
title: 'Keys',
filter: 'Filter keys...',
loadMore: 'Load more...',
count: '{n} keys',
deleteConfirm: 'Delete key "{key}"?',
deleted: 'Key deleted',
rename: 'Rename',
renameTitle: 'Rename Key',
newName: 'New name',
renamed: 'Key renamed',
copyName: 'Copy key name',
copied: 'Copied',
ttl: 'TTL',
ttlTitle: 'Set TTL',
ttlHint: '-1 = no expiry, 0 = expire now',
ttlUpdated: 'TTL updated',
exists: 'Key already exists',
batchDelete: 'Delete Selected',
batchConfirm: 'Delete {n} key(s)?',
batchDeleted: 'Deleted {n} key(s)',
selected: '{n} selected',
},
types: {
string: 'STRING',
hash: 'HASH',
list: 'LIST',
set: 'SET',
zset: 'ZSET',
stream: 'STREAM',
},
editor: {
save: 'Save',
cancel: 'Cancel',
edit: 'Edit',
saved: 'Saved',
push: 'Push',
addField: 'Add Field',
addMember: 'Add Member',
filter: 'Filter...',
refresh: 'Refresh',
elements: '{n} elements',
members: '{n} members',
empty: 'Empty',
field: 'Field',
value: 'Value',
score: 'Score',
remove: 'Remove',
removed: 'Removed',
added: 'Added',
},
cli: {
title: 'CLI',
welcome: 'Redis CLI - type commands below',
placeholder: 'Enter Redis command...',
},
slowlog: {
title: 'Slow Log',
time: 'Time',
duration: 'Duration',
command: 'Command',
},
status: {
title: 'Status',
database: 'Database',
keys: 'Keys',
refresh: 'Refresh',
},
db: {
label: 'DB',
},
settings: {
title: 'Settings',
theme: 'Theme',
themeDark: 'Dark',
themeLight: 'Light',
themeSystem: 'System',
language: 'Language',
scanCount: 'Keys per SCAN',
fontSize: 'Font Size',
},
}
-123
View File
@@ -1,123 +0,0 @@
// src/renderer/src/i18n/locales/zh-CN.ts
export default {
common: {
ok: '确定',
cancel: '取消',
save: '保存',
delete: '删除',
edit: '编辑',
add: '添加',
refresh: '刷新',
search: '搜索',
confirm: '确认',
success: '成功',
error: '错误',
loading: '加载中...',
noData: '暂无数据',
},
app: {
title: 'JRedisDesktop',
subtitle: '现代 Redis 桌面管理器',
placeholder: '选择或创建连接开始使用',
},
connection: {
title: '连接',
new: '新建连接',
edit: '编辑连接',
name: '名称',
host: '主机',
port: '端口',
password: '密码',
username: '用户名',
tls: '启用 TLS',
ssh: 'SSH 隧道',
connect: '连接',
disconnect: '断开',
deleteConfirm: '删除此连接?',
testSuccess: '连接成功',
testFailed: '连接失败',
connected: '已连接',
disconnected: '未连接',
},
key: {
title: '键',
filter: '过滤键...',
loadMore: '加载更多...',
count: '{n} 个键',
deleteConfirm: '删除键 "{key}"?',
deleted: '键已删除',
rename: '重命名',
renameTitle: '重命名键',
newName: '新名称',
renamed: '键已重命名',
copyName: '复制键名',
copied: '已复制',
ttl: 'TTL',
ttlTitle: '设置 TTL',
ttlHint: '-1 = 永不过期, 0 = 立即过期',
ttlUpdated: 'TTL 已更新',
exists: '键已存在',
batchDelete: '删除选中',
batchConfirm: '删除 {n} 个键?',
batchDeleted: '已删除 {n} 个键',
selected: '已选 {n} 项',
},
types: {
string: '字符串',
hash: '哈希',
list: '列表',
set: '集合',
zset: '有序集合',
stream: '流',
},
editor: {
save: '保存',
cancel: '取消',
edit: '编辑',
saved: '已保存',
push: '推入',
addField: '添加字段',
addMember: '添加成员',
filter: '过滤...',
refresh: '刷新',
elements: '{n} 个元素',
members: '{n} 个成员',
empty: '空',
field: '字段',
value: '值',
score: '分数',
remove: '移除',
removed: '已移除',
added: '已添加',
},
cli: {
title: 'CLI',
welcome: 'Redis CLI - 在下方输入命令',
placeholder: '输入 Redis 命令...',
},
slowlog: {
title: '慢日志',
time: '时间',
duration: '耗时',
command: '命令',
},
status: {
title: '状态',
database: '数据库',
keys: '键数',
refresh: '刷新',
},
db: {
label: '数据库',
},
settings: {
title: '设置',
theme: '主题',
themeDark: '深色',
themeLight: '浅色',
themeSystem: '跟随系统',
language: '语言',
scanCount: 'SCAN 每次数量',
fontSize: '字体大小',
},
}
-20
View File
@@ -1,20 +0,0 @@
// 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 * as ElementPlusIconsVue from '@element-plus/icons-vue'
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' })
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')
-16
View File
@@ -1,16 +0,0 @@
// 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
-49
View File
@@ -1,49 +0,0 @@
// 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>((localStorage.getItem('theme') as ThemeMode) || 'system')
const isDark = ref(true)
const fontSize = ref<number>(Number(localStorage.getItem('fontSize')) || 13)
const scanCount = ref<number>(Number(localStorage.getItem('scanCount')) || 100)
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)
localStorage.setItem('theme', mode)
window.electronAPI?.theme.set(mode)
}
function setFontSize(size: number) {
fontSize.value = size
localStorage.setItem('fontSize', String(size))
}
function setScanCount(count: number) {
scanCount.value = count
localStorage.setItem('scanCount', String(count))
}
return { theme, isDark, fontSize, scanCount, setTheme, setFontSize, setScanCount }
})
-182
View File
@@ -1,182 +0,0 @@
// 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 healthOk = ref(true)
let healthTimer: ReturnType<typeof setInterval> | 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)
startHealthCheck(conn.id)
} else {
error.value = result.error || 'Connection failed'
}
} catch (err: any) {
error.value = err.message
} finally {
connecting.value = false
}
}
async function disconnect() {
stopHealthCheck()
if (activeId.value) {
await window.electronAPI.redis.disconnect(activeId.value)
activeId.value = null
serverInfo.value = null
}
}
function startHealthCheck(connId: string) {
stopHealthCheck()
healthOk.value = true
healthTimer = setInterval(async () => {
try {
const ok = await window.electronAPI.redis.isConnected(connId)
healthOk.value = ok
if (!ok && activeId.value === connId) {
// Connection lost
error.value = 'Connection lost'
}
} catch {
healthOk.value = false
}
}, 5000)
}
function stopHealthCheck() {
if (healthTimer) {
clearInterval(healthTimer)
healthTimer = null
}
}
async function testConnection(opts: {
host: string; port: number; auth?: string; username?: string; tls?: boolean
}): Promise<{ success: boolean; error?: string }> {
if (!window.electronAPI) {
return { success: false, error: 'Electron API not ready' }
}
const testId = `test_${Date.now()}`
try {
const result = await window.electronAPI.redis.connect(testId, {
host: opts.host,
port: opts.port,
password: opts.auth || undefined,
username: opts.username || undefined,
tls: opts.tls,
})
if (result.success) {
await window.electronAPI.redis.disconnect(testId)
return { success: true }
}
return { success: false, error: result.error }
} catch (err: any) {
return { success: false, error: err.message }
}
}
async function exportConnections(): Promise<string> {
const data = JSON.stringify(connections.value, null, 2)
return data
}
async function importConnections(jsonStr: string): Promise<number> {
try {
const imported = JSON.parse(jsonStr) as ConnectionConfig[]
if (!Array.isArray(imported)) throw new Error('Invalid format')
let count = 0
for (const conn of imported) {
if (conn.id && conn.name && conn.host) {
const exists = connections.value.find((c: ConnectionConfig) => c.id === conn.id)
if (!exists) {
await saveConnection(conn)
count++
}
}
}
return count
} catch (err: any) {
throw new Error('Import failed: ' + err.message)
}
}
return {
connections, activeId, connecting, error, serverInfo, healthOk,
activeConnection, isConnected,
loadConnections, saveConnection, deleteConnection, reorderConnections,
connect, disconnect, testConnection, exportConnections, importConnections,
}
})
-186
View File
@@ -1,186 +0,0 @@
// src/renderer/src/stores/key.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface KeyItem {
name: string
type: string
ttl: number
level: number
children?: KeyItem[]
isLeaf: boolean
}
interface ScanResult {
cursor: string
keys: string[]
}
export const useKeyStore = defineStore('key', () => {
const keys = ref<KeyItem[]>([])
const treeKeys = ref<KeyItem[]>([])
const selectedKey = ref<string | null>(null)
const selectedType = ref<string | null>(null)
const selectedTTL = ref<number>(-1)
const keyData = ref<any>(null)
const loading = ref(false)
const scanCursor = ref('0')
const hasMore = ref(false)
const pattern = ref('*')
const useTree = ref(false)
const currentDb = ref(0)
function buildTree(flatKeys: string[], separator: string): KeyItem[] {
const root: KeyItem[] = []
const map = new Map<string, KeyItem>()
for (const key of flatKeys) {
const parts = key.split(separator)
let currentLevel = root
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
const isLast = i === parts.length - 1
const path = parts.slice(0, i + 1).join(separator)
if (map.has(path)) {
const existing = map.get(path)!
if (isLast) {
existing.name = key
existing.isLeaf = true
existing.type = ''
existing.ttl = -1
}
currentLevel = existing.children || []
} else {
const node: KeyItem = {
name: isLast ? key : part,
type: isLast ? '' : 'branch',
ttl: -1,
level: i,
children: isLast ? undefined : [],
isLeaf: isLast,
}
map.set(path, node)
currentLevel.push(node)
if (!isLast) currentLevel = node.children!
}
}
}
return root
}
async function scanKeys(connId: string, cursor: string = '0', reset: boolean = false) {
if (reset) {
scanCursor.value = '0'
keys.value = []
hasMore.value = false
}
loading.value = true
try {
const result: ScanResult = await window.electronAPI.redis.scanKeys(
connId, cursor, pattern.value, 100
)
const newKeys = result.keys.map(k => ({
name: k,
type: '',
ttl: -1,
level: 0,
isLeaf: true,
}))
if (reset) {
keys.value = newKeys
} else {
keys.value.push(...newKeys)
}
scanCursor.value = result.cursor
hasMore.value = result.cursor !== '0'
if (useTree.value) {
const conn = (await window.electronAPI.storage.getConnections())
.find((c: any) => c.id === connId)
const sep = conn?.separator || ':'
treeKeys.value = buildTree(keys.value.map(k => k.name), sep)
}
} finally {
loading.value = false
}
}
async function loadKeyTypeAndTTL(connId: string, key: string) {
const [type, ttl] = await Promise.all([
window.electronAPI.redis.getKeyType(connId, key),
window.electronAPI.redis.getKeyTTL(connId, key),
])
selectedType.value = type
selectedTTL.value = ttl
}
async function loadKeyData(connId: string, key: string) {
loading.value = true
try {
await loadKeyTypeAndTTL(connId, key)
selectedKey.value = key
switch (selectedType.value) {
case 'string':
keyData.value = await window.electronAPI.redis.getString(connId, key)
break
case 'hash': {
const result = await window.electronAPI.redis.hashScan(connId, key, '0', 100)
keyData.value = result.fields
break
}
case 'list': {
const len = await window.electronAPI.redis.listLength(connId, key)
keyData.value = { length: len }
break
}
case 'set': {
const size = await window.electronAPI.redis.setSize(connId, key)
keyData.value = { size }
break
}
case 'zset': {
const size = await window.electronAPI.redis.zsetSize(connId, key)
keyData.value = { size }
break
}
case 'stream': {
try {
const info = await window.electronAPI.redis.streamInfo(connId, key)
keyData.value = { length: info.length }
} catch {
keyData.value = { length: 0 }
}
break
}
default:
keyData.value = null
}
} finally {
loading.value = false
}
}
async function selectDb(connId: string, db: number) {
await window.electronAPI.redis.selectDb(connId, db)
currentDb.value = db
keys.value = []
treeKeys.value = []
selectedKey.value = null
keyData.value = null
scanCursor.value = '0'
hasMore.value = false
}
return {
keys, treeKeys, selectedKey, selectedType, selectedTTL, keyData,
loading, scanCursor, hasMore, pattern, useTree, currentDb,
scanKeys, loadKeyTypeAndTTL, loadKeyData, selectDb,
}
})
-26
View File
@@ -1,26 +0,0 @@
/* 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
@@ -1,42 +0,0 @@
/* 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);
}