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:
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// src/renderer/src/components/KeyDetail.vue
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useKeyStore } from '@renderer/stores/key'
|
||||
import { useConnectionStore } from '@renderer/stores/connection'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
@@ -9,10 +9,16 @@ 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)',
|
||||
@@ -34,6 +40,63 @@ const ttlDisplay = computed(() => {
|
||||
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 {
|
||||
@@ -59,12 +122,18 @@ async function refreshKey() {
|
||||
<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>
|
||||
<span class="ttl-badge">{{ ttlDisplay }}</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>
|
||||
@@ -76,11 +145,36 @@ async function refreshKey() {
|
||||
<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">
|
||||
@@ -109,7 +203,7 @@ async function refreshKey() {
|
||||
.key-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -128,6 +222,10 @@ async function refreshKey() {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
@@ -137,7 +235,7 @@ async function refreshKey() {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.ttl-badge {
|
||||
.ttl-btn {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@ 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'
|
||||
type Tab = 'status' | 'keys' | 'cli' | 'slowlog' | 'settings'
|
||||
const activeTab = ref<Tab>('status')
|
||||
|
||||
watch(
|
||||
@@ -57,12 +58,21 @@ watch(
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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 const useAppStore = defineStore('app', () => {
|
||||
const theme = ref<ThemeMode>('system')
|
||||
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
|
||||
@@ -29,8 +31,19 @@ export const useAppStore = defineStore('app', () => {
|
||||
|
||||
function setTheme(mode: ThemeMode) {
|
||||
applyTheme(mode)
|
||||
localStorage.setItem('theme', 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 }
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user