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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">></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">></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)">✎</button>
|
||||
<button class="card-btn card-btn-danger" title="Delete" @click="emit('delete', connection.id)">✕</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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))
|
||||
}
|
||||
Vendored
-7
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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: '字体大小',
|
||||
},
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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; }
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user