feat(phase5): stream editor, i18n, settings, key management
- StreamEditor: view/add/trim/delete entries with field management - KeyDetail: TTL editor dialog, rename dialog, copy key name button - i18n: English + Chinese locale system with composable - SettingsView: theme selector, language switch, scan count, font size - MainArea: added Settings tab - RedisService: stream/rename/exists operations - Preload: stream/rename/exists IPC channels
This commit is contained in:
@@ -182,4 +182,29 @@ export function registerIpcHandlers(): void {
|
|||||||
ipcMain.handle('redis:batchDelete', async (_e, id: string, keys: string[]) => {
|
ipcMain.handle('redis:batchDelete', async (_e, id: string, keys: string[]) => {
|
||||||
return redisService.batchDelete(id, keys)
|
return redisService.batchDelete(id, keys)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Stream
|
||||||
|
ipcMain.handle('redis:streamInfo', async (_e, id: string, key: string) => {
|
||||||
|
return redisService.streamInfo(id, key)
|
||||||
|
})
|
||||||
|
ipcMain.handle('redis:streamRange', async (_e, id: string, key: string, start: string, end: string, count: number) => {
|
||||||
|
return redisService.streamRange(id, key, start, end, count)
|
||||||
|
})
|
||||||
|
ipcMain.handle('redis:streamAdd', async (_e, id: string, key: string, streamId: string, fieldValues: string[]) => {
|
||||||
|
return redisService.streamAdd(id, key, streamId, fieldValues)
|
||||||
|
})
|
||||||
|
ipcMain.handle('redis:streamTrim', async (_e, id: string, key: string, maxLen: number) => {
|
||||||
|
return redisService.streamTrim(id, key, maxLen)
|
||||||
|
})
|
||||||
|
ipcMain.handle('redis:streamDel', async (_e, id: string, key: string, ids: string[]) => {
|
||||||
|
return redisService.streamDel(id, key, ...ids)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Key ops
|
||||||
|
ipcMain.handle('redis:renameKey', async (_e, id: string, oldKey: string, newKey: string) => {
|
||||||
|
await redisService.renameKey(id, oldKey, newKey)
|
||||||
|
})
|
||||||
|
ipcMain.handle('redis:existsKey', async (_e, id: string, key: string) => {
|
||||||
|
return redisService.existsKey(id, key)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,3 +290,50 @@ export async function batchDelete(id: string, keys: string[]): Promise<number> {
|
|||||||
const results = await pipeline.exec()
|
const results = await pipeline.exec()
|
||||||
return results?.filter(([err]) => !err).length ?? 0
|
return results?.filter(([err]) => !err).length ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stream operations
|
||||||
|
export async function streamInfo(id: string, key: string): Promise<any> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
return client.xinfo('STREAM', key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function streamRange(
|
||||||
|
id: string, key: string, start: string, end: string, count: number
|
||||||
|
): Promise<any[]> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
return client.xrange(key, start, end, 'COUNT', count)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function streamAdd(
|
||||||
|
id: string, key: string, streamId: string, fieldValues: string[]
|
||||||
|
): Promise<string | null> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
return client.xadd(key, streamId, ...fieldValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function streamTrim(id: string, key: string, maxLen: number): Promise<number> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
return client.xtrim(key, 'MAXLEN', maxLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function streamDel(id: string, key: string, ...ids: string[]): Promise<number> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
return client.xdel(key, ...ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameKey(id: string, oldKey: string, newKey: string): Promise<void> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
await client.rename(oldKey, newKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function existsKey(id: string, key: string): Promise<boolean> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
return (await client.exists(key)) === 1
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+7
@@ -87,6 +87,13 @@ export interface ElectronAPI {
|
|||||||
slowLogLen: (id: string) => Promise<number>
|
slowLogLen: (id: string) => Promise<number>
|
||||||
memoryUsage: (id: string, key: string) => Promise<number | null>
|
memoryUsage: (id: string, key: string) => Promise<number | null>
|
||||||
batchDelete: (id: string, keys: string[]) => Promise<number>
|
batchDelete: (id: string, keys: string[]) => Promise<number>
|
||||||
|
streamInfo: (id: string, key: string) => Promise<any>
|
||||||
|
streamRange: (id: string, key: string, start: string, end: string, count: number) => Promise<any[]>
|
||||||
|
streamAdd: (id: string, key: string, streamId: string, fieldValues: string[]) => Promise<string>
|
||||||
|
streamTrim: (id: string, key: string, maxLen: number) => Promise<number>
|
||||||
|
streamDel: (id: string, key: string, ids: string[]) => Promise<number>
|
||||||
|
renameKey: (id: string, oldKey: string, newKey: string) => Promise<void>
|
||||||
|
existsKey: (id: string, key: string) => Promise<boolean>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,22 @@ const electronAPI = {
|
|||||||
ipcRenderer.invoke('redis:memoryUsage', id, key),
|
ipcRenderer.invoke('redis:memoryUsage', id, key),
|
||||||
batchDelete: (id: string, keys: string[]): Promise<number> =>
|
batchDelete: (id: string, keys: string[]): Promise<number> =>
|
||||||
ipcRenderer.invoke('redis:batchDelete', id, keys),
|
ipcRenderer.invoke('redis:batchDelete', id, keys),
|
||||||
|
// Stream
|
||||||
|
streamInfo: (id: string, key: string): Promise<any> =>
|
||||||
|
ipcRenderer.invoke('redis:streamInfo', id, key),
|
||||||
|
streamRange: (id: string, key: string, start: string, end: string, count: number): Promise<any[]> =>
|
||||||
|
ipcRenderer.invoke('redis:streamRange', id, key, start, end, count),
|
||||||
|
streamAdd: (id: string, key: string, streamId: string, fieldValues: string[]): Promise<string> =>
|
||||||
|
ipcRenderer.invoke('redis:streamAdd', id, key, streamId, fieldValues),
|
||||||
|
streamTrim: (id: string, key: string, maxLen: number): Promise<number> =>
|
||||||
|
ipcRenderer.invoke('redis:streamTrim', id, key, maxLen),
|
||||||
|
streamDel: (id: string, key: string, ids: string[]): Promise<number> =>
|
||||||
|
ipcRenderer.invoke('redis:streamDel', id, key, ids),
|
||||||
|
// Key ops
|
||||||
|
renameKey: (id: string, oldKey: string, newKey: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('redis:renameKey', id, oldKey, newKey),
|
||||||
|
existsKey: (id: string, key: string): Promise<boolean> =>
|
||||||
|
ipcRenderer.invoke('redis:existsKey', id, key),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// src/renderer/src/components/KeyDetail.vue
|
// src/renderer/src/components/KeyDetail.vue
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useKeyStore } from '@renderer/stores/key'
|
import { useKeyStore } from '@renderer/stores/key'
|
||||||
import { useConnectionStore } from '@renderer/stores/connection'
|
import { useConnectionStore } from '@renderer/stores/connection'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
@@ -9,10 +9,16 @@ import HashEditor from './HashEditor.vue'
|
|||||||
import ListEditor from './ListEditor.vue'
|
import ListEditor from './ListEditor.vue'
|
||||||
import SetEditor from './SetEditor.vue'
|
import SetEditor from './SetEditor.vue'
|
||||||
import ZsetEditor from './ZsetEditor.vue'
|
import ZsetEditor from './ZsetEditor.vue'
|
||||||
|
import StreamEditor from './StreamEditor.vue'
|
||||||
|
|
||||||
const keyStore = useKeyStore()
|
const keyStore = useKeyStore()
|
||||||
const connStore = useConnectionStore()
|
const connStore = useConnectionStore()
|
||||||
|
|
||||||
|
const showTtlDialog = ref(false)
|
||||||
|
const ttlValue = ref(0)
|
||||||
|
const showRenameDialog = ref(false)
|
||||||
|
const newName = ref('')
|
||||||
|
|
||||||
const typeColor = computed(() => {
|
const typeColor = computed(() => {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
string: 'var(--color-green)',
|
string: 'var(--color-green)',
|
||||||
@@ -34,6 +40,63 @@ const ttlDisplay = computed(() => {
|
|||||||
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`
|
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() {
|
async function deleteKey() {
|
||||||
if (!connStore.activeId || !keyStore.selectedKey) return
|
if (!connStore.activeId || !keyStore.selectedKey) return
|
||||||
try {
|
try {
|
||||||
@@ -59,12 +122,18 @@ async function refreshKey() {
|
|||||||
<div class="key-info">
|
<div class="key-info">
|
||||||
<el-icon class="key-icon" :style="{ color: typeColor }"><Document /></el-icon>
|
<el-icon class="key-icon" :style="{ color: typeColor }"><Document /></el-icon>
|
||||||
<span class="key-name">{{ keyStore.selectedKey }}</span>
|
<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 }">
|
<span class="type-badge" :style="{ color: typeColor, borderColor: typeColor }">
|
||||||
{{ keyStore.selectedType?.toUpperCase() }}
|
{{ keyStore.selectedType?.toUpperCase() }}
|
||||||
</span>
|
</span>
|
||||||
<span class="ttl-badge">{{ ttlDisplay }}</span>
|
<el-button size="small" text class="ttl-btn" @click="openTtlDialog">
|
||||||
|
{{ ttlDisplay }}
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="key-actions">
|
<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" @click="refreshKey">Refresh</el-button>
|
||||||
<el-button size="small" type="danger" @click="deleteKey">Delete</el-button>
|
<el-button size="small" type="danger" @click="deleteKey">Delete</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,11 +145,36 @@ async function refreshKey() {
|
|||||||
<ListEditor v-else-if="keyStore.selectedType === 'list'" />
|
<ListEditor v-else-if="keyStore.selectedType === 'list'" />
|
||||||
<SetEditor v-else-if="keyStore.selectedType === 'set'" />
|
<SetEditor v-else-if="keyStore.selectedType === 'set'" />
|
||||||
<ZsetEditor v-else-if="keyStore.selectedType === 'zset'" />
|
<ZsetEditor v-else-if="keyStore.selectedType === 'zset'" />
|
||||||
|
<StreamEditor v-else-if="keyStore.selectedType === 'stream'" />
|
||||||
<div v-else class="unsupported">
|
<div v-else class="unsupported">
|
||||||
<el-icon><Warning /></el-icon>
|
<el-icon><Warning /></el-icon>
|
||||||
<p>Type "{{ keyStore.selectedType }}" viewer not yet implemented</p>
|
<p>Type "{{ keyStore.selectedType }}" viewer not yet implemented</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div v-else class="no-selection">
|
<div v-else class="no-selection">
|
||||||
@@ -109,7 +203,7 @@ async function refreshKey() {
|
|||||||
.key-info {
|
.key-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 6px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +222,10 @@ async function refreshKey() {
|
|||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.type-badge {
|
.type-badge {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -137,7 +235,7 @@ async function refreshKey() {
|
|||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ttl-badge {
|
.ttl-btn {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import StatusView from './StatusView.vue'
|
|||||||
import KeyBrowser from './KeyBrowser.vue'
|
import KeyBrowser from './KeyBrowser.vue'
|
||||||
import CliView from './CliView.vue'
|
import CliView from './CliView.vue'
|
||||||
import SlowLogView from './SlowLogView.vue'
|
import SlowLogView from './SlowLogView.vue'
|
||||||
|
import SettingsView from './SettingsView.vue'
|
||||||
|
|
||||||
const connStore = useConnectionStore()
|
const connStore = useConnectionStore()
|
||||||
|
|
||||||
type Tab = 'status' | 'keys' | 'cli' | 'slowlog'
|
type Tab = 'status' | 'keys' | 'cli' | 'slowlog' | 'settings'
|
||||||
const activeTab = ref<Tab>('status')
|
const activeTab = ref<Tab>('status')
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -57,12 +58,21 @@ watch(
|
|||||||
<el-icon><Clock /></el-icon>
|
<el-icon><Clock /></el-icon>
|
||||||
Slow Log
|
Slow Log
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ active: activeTab === 'settings' }"
|
||||||
|
@click="activeTab = 'settings'"
|
||||||
|
>
|
||||||
|
<el-icon><Setting /></el-icon>
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<StatusView v-if="activeTab === 'status'" />
|
<StatusView v-if="activeTab === 'status'" />
|
||||||
<KeyBrowser v-else-if="activeTab === 'keys'" />
|
<KeyBrowser v-else-if="activeTab === 'keys'" />
|
||||||
<CliView v-else-if="activeTab === 'cli'" />
|
<CliView v-else-if="activeTab === 'cli'" />
|
||||||
<SlowLogView v-else-if="activeTab === 'slowlog'" />
|
<SlowLogView v-else-if="activeTab === 'slowlog'" />
|
||||||
|
<SettingsView v-else-if="activeTab === 'settings'" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// 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') || 'en'
|
||||||
|
)
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// 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',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// 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: '字体大小',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ import { ref } from 'vue'
|
|||||||
export type ThemeMode = 'system' | 'dark' | 'light'
|
export type ThemeMode = 'system' | 'dark' | 'light'
|
||||||
|
|
||||||
export const useAppStore = defineStore('app', () => {
|
export const useAppStore = defineStore('app', () => {
|
||||||
const theme = ref<ThemeMode>('system')
|
const theme = ref<ThemeMode>((localStorage.getItem('theme') as ThemeMode) || 'system')
|
||||||
const isDark = ref(true)
|
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) {
|
function applyTheme(mode: ThemeMode, osDark?: boolean) {
|
||||||
theme.value = mode
|
theme.value = mode
|
||||||
@@ -29,8 +31,19 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
|
|
||||||
function setTheme(mode: ThemeMode) {
|
function setTheme(mode: ThemeMode) {
|
||||||
applyTheme(mode)
|
applyTheme(mode)
|
||||||
|
localStorage.setItem('theme', mode)
|
||||||
window.electronAPI?.theme.set(mode)
|
window.electronAPI?.theme.set(mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { theme, isDark, setTheme }
|
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 }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -150,6 +150,15 @@ export const useKeyStore = defineStore('key', () => {
|
|||||||
keyData.value = { size }
|
keyData.value = { size }
|
||||||
break
|
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:
|
default:
|
||||||
keyData.value = null
|
keyData.value = null
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user