Compare commits

..

4 Commits

Author SHA1 Message Date
Lykin ab8077999d fix: display incorrect value in zset content
fix: content pane cause error when value is null
2023-12-01 18:42:39 +08:00
Lykin eb8f68b628 perf: optimized the appearance 2023-12-01 18:03:19 +08:00
Lykin e2f33af1c7 feat: the browser pane is now set to display only a single database
feat: add search input in browser pane
2023-12-01 18:02:32 +08:00
Lykin 8201004478 feat: add filter input to browser pane 2023-11-29 16:16:13 +08:00
26 changed files with 707 additions and 455 deletions

View File

@ -390,7 +390,20 @@ func (b *browserService) ServerInfo(name string) (resp types.JSResp) {
// @param path contain connection name and db name // @param path contain connection name and db name
func (b *browserService) OpenDatabase(connName string, db int, match string, keyType string) (resp types.JSResp) { func (b *browserService) OpenDatabase(connName string, db int, match string, keyType string) (resp types.JSResp) {
b.setClientCursor(connName, db, 0) b.setClientCursor(connName, db, 0)
return b.LoadNextKeys(connName, db, match, keyType)
item, err := b.getRedisClient(connName, db)
if err != nil {
resp.Msg = err.Error()
return
}
client, ctx := item.client, item.ctx
maxKeys := b.loadDBSize(ctx, client)
resp.Success = true
resp.Data = map[string]any{
"maxKeys": maxKeys,
}
return
} }
// scan keys // scan keys
@ -433,6 +446,7 @@ func (b *browserService) scanKeys(ctx context.Context, client redis.UniversalCli
// cluster mode // cluster mode
var mutex sync.Mutex var mutex sync.Mutex
err = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error { err = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error {
// FIXME: BUG? can not fully load in cluster mode? maybe remove the shared "cursor"
return scan(ctx, cli, func(k []any) { return scan(ctx, cli, func(k []any) {
mutex.Lock() mutex.Lock()
keys = append(keys, k...) keys = append(keys, k...)
@ -882,7 +896,7 @@ func (b *browserService) GetKeyDetail(param types.KeyDetailParam) (resp types.JS
items = make([]types.ZSetEntryItem, 0, len(loadedVal)) items = make([]types.ZSetEntryItem, 0, len(loadedVal))
for _, z := range loadedVal { for _, z := range loadedVal {
val := strutil.AnyToString(z.Score, "", 0) val := strutil.AnyToString(z.Member, "", 0)
if doFilter && !strings.Contains(val, param.MatchPattern) { if doFilter && !strings.Contains(val, param.MatchPattern) {
continue continue
} }

View File

@ -25,6 +25,8 @@ const props = defineProps({
border: Boolean, border: Boolean,
disabled: Boolean, disabled: Boolean,
buttonStyle: [String, Object], buttonStyle: [String, Object],
buttonClass: [String, Object],
small: Boolean,
}) })
const hasTooltip = computed(() => { const hasTooltip = computed(() => {
@ -36,10 +38,12 @@ const hasTooltip = computed(() => {
<n-tooltip v-if="hasTooltip" :show-arrow="false"> <n-tooltip v-if="hasTooltip" :show-arrow="false">
<template #trigger> <template #trigger>
<n-button <n-button
:class="props.buttonClass"
:color="props.color" :color="props.color"
:disabled="disabled" :disabled="disabled"
:focusable="false" :focusable="false"
:loading="loading" :loading="loading"
:size="small ? 'small' : ''"
:style="props.buttonStyle" :style="props.buttonStyle"
:text="!border" :text="!border"
:type="type" :type="type"
@ -57,10 +61,13 @@ const hasTooltip = computed(() => {
</n-tooltip> </n-tooltip>
<n-button <n-button
v-else v-else
:class="props.buttonClass"
:color="props.color" :color="props.color"
:disabled="disabled" :disabled="disabled"
:focusable="false" :focusable="false"
:loading="loading" :loading="loading"
:size="small ? 'small' : ''"
:style="props.buttonStyle"
:text="!border" :text="!border"
:type="type" :type="type"
@click.prevent="emit('click')"> @click.prevent="emit('click')">

View File

@ -0,0 +1,83 @@
<script setup>
import { computed, h } from 'vue'
import { useThemeVars } from 'naive-ui'
import { types, typesBgColor, typesColor } from '@/consts/support_redis_type.js'
import { get, map, toUpper } from 'lodash'
const props = defineProps({
value: {
type: String,
default: 'ALL',
},
})
const emit = defineEmits(['update:value', 'select'])
const options = computed(() => {
const opts = map(types, (v) => ({
label: v,
key: v,
}))
return [{ label: 'ALL', key: 'ALL' }, ...opts]
})
const themeVars = useThemeVars()
const renderIcon = (option) => {
if (option.key === props.value) {
const backgroundColor = get(typesColor, option.key, themeVars.value.textColor3)
return h('div', { style: { borderRadius: '999px', width: '10px', height: '10px', backgroundColor } }, '')
}
}
const renderLabel = (option) => {
const color = get(typesColor, option.key, '')
return h('div', { style: { color, fontWeight: '450' } }, option.label)
}
const fontColor = computed(() => {
return get(typesColor, props.value, '')
})
const backgroundColor = computed(() => {
return get(typesBgColor, props.value, '')
})
const displayValue = computed(() => {
return get(types, toUpper(props.value), 'ALL')
})
const handleSelect = (select) => {
if (props.value !== select) {
emit('update:value', select)
emit('select', select)
}
}
</script>
<template>
<n-dropdown
:options="options"
:render-icon="renderIcon"
:render-label="renderLabel"
show-arrow
@select="handleSelect">
<n-tag
:bordered="true"
:color="{ color: backgroundColor, textColor: fontColor }"
class="redis-tag"
size="medium"
strong>
{{ displayValue }}
</n-tag>
</n-dropdown>
</template>
<style lang="scss" scoped>
.redis-tag {
padding: 0 10px;
}
:deep(.dropdown-type-item) {
padding: 10px;
}
</style>

View File

@ -12,7 +12,6 @@ const props = defineProps({
default: 'STRING', default: 'STRING',
}, },
binaryKey: Boolean, binaryKey: Boolean,
bordered: Boolean,
size: String, size: String,
}) })
@ -27,9 +26,8 @@ const backgroundColor = computed(() => {
<template> <template>
<n-tag <n-tag
:bordered="props.bordered"
:class="[props.size === 'small' ? 'redis-type-tag-small' : 'redis-type-tag']" :class="[props.size === 'small' ? 'redis-type-tag-small' : 'redis-type-tag']"
:color="{ color: backgroundColor, borderColor: fontColor, textColor: fontColor }" :color="{ color: backgroundColor, textColor: fontColor }"
:size="props.size" :size="props.size"
strong> strong>
{{ props.type }} {{ props.type }}

View File

@ -101,6 +101,7 @@ const handleMouseOver = () => {
top: 0; top: 0;
bottom: 0; bottom: 0;
transition: background-color 0.3s ease-in; transition: background-color 0.3s ease-in;
z-index: 1;
} }
.resize-divider-hide { .resize-divider-hide {

View File

@ -94,26 +94,13 @@ const viewLanguage = computed(() => {
} }
}) })
const btnStyle = computed(() => ({
padding: '3px',
border: 'solid 1px #0000',
borderRadius: '3px',
}))
const pinBtnStyle = computed(() => ({
padding: '3px',
border: `solid 1px ${themeVars.value.borderColor}`,
borderRadius: '3px',
backgroundColor: themeVars.value.borderColor,
}))
/** /**
* *
* @param {decodeTypes} decode * @param {decodeTypes|null} decode
* @param {formatTypes} format * @param {formatTypes|null} format
* @return {Promise<void>} * @return {Promise<void>}
*/ */
const onFormatChanged = async (decode = '', format = '') => { const onFormatChanged = async (decode = null, format = null) => {
try { try {
loading.value = true loading.value = true
const { const {
@ -196,21 +183,21 @@ const onSave = () => {
<template #header-extra> <template #header-extra>
<n-space :size="5"> <n-space :size="5">
<icon-button <icon-button
:button-style="isPin ? pinBtnStyle : btnStyle" :button-class="{ 'pinable-btn': true, 'unpin-btn': !isPin, 'pin-btn': isPin }"
:icon="Pin" :icon="Pin"
:size="19" :size="19"
:t-tooltip="isPin ? 'interface.unpin_edit' : 'interface.pin_edit'" :t-tooltip="isPin ? 'interface.unpin_edit' : 'interface.pin_edit'"
stroke-width="4" stroke-width="4"
@click="isPin = !isPin" /> @click="isPin = !isPin" />
<icon-button <icon-button
:button-style="btnStyle" :button-class="['pinable-btn', 'unpin-btn']"
:icon="props.fullscreen ? OffScreen : FullScreen" :icon="props.fullscreen ? OffScreen : FullScreen"
:size="18" :size="18"
stroke-width="5" stroke-width="5"
t-tooltip="interface.fullscreen" t-tooltip="interface.fullscreen"
@click="onToggleFullscreen" /> @click="onToggleFullscreen" />
<icon-button <icon-button
:button-style="btnStyle" :button-class="['pinable-btn', 'unpin-btn']"
:icon="WindowClose" :icon="WindowClose"
:size="18" :size="18"
stroke-width="5" stroke-width="5"
@ -267,6 +254,22 @@ const onSave = () => {
background-color: unset; background-color: unset;
} }
:deep(.pinable-btn) {
padding: 3px;
border-style: solid;
border-width: 1px;
border-radius: 3px;
}
:deep(.unpin-btn) {
border-color: #0000;
}
:deep(.pin-btn) {
border-color: v-bind('themeVars.iconColorDisabled');
background-color: v-bind('themeVars.iconColorDisabled');
}
//:deep(.n-card--bordered) { //:deep(.n-card--bordered) {
// border-radius: 0; // border-radius: 0;
//} //}

View File

@ -2,6 +2,23 @@
import { computed, reactive } from 'vue' import { computed, reactive } from 'vue'
import { debounce, isEmpty, trim } from 'lodash' import { debounce, isEmpty, trim } from 'lodash'
import { NButton, NInput } from 'naive-ui' import { NButton, NInput } from 'naive-ui'
import IconButton from '@/components/common/IconButton.vue'
import Help from '@/components/icons/Help.vue'
const props = defineProps({
fullSearchIcon: {
type: [String, Object],
default: null,
},
debounceWait: {
type: Number,
default: 500,
},
small: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['filterChanged', 'matchChanged']) const emit = defineEmits(['filterChanged', 'matchChanged'])
@ -34,7 +51,13 @@ const onFullSearch = () => {
const _onInput = () => { const _onInput = () => {
emit('filterChanged', inputData.filter) emit('filterChanged', inputData.filter)
} }
const onInput = debounce(_onInput, 500, { leading: true, trailing: true }) const onInput = debounce(_onInput, props.debounceWait, { leading: true, trailing: true })
const onKeyup = (evt) => {
if (evt.key === 'Enter') {
onFullSearch()
}
}
const onClearFilter = () => { const onClearFilter = () => {
inputData.filter = '' inputData.filter = ''
@ -58,13 +81,18 @@ defineExpose({
<template> <template>
<n-input-group> <n-input-group>
<slot name="prepend" />
<n-input <n-input
v-model:value="inputData.filter" v-model:value="inputData.filter"
:placeholder="$t('interface.filter')" :placeholder="$t('interface.filter')"
:size="props.small ? 'small' : ''"
clearable clearable
@clear="onClearFilter" @clear="onClearFilter"
@input="onInput"> @input="onInput"
@keyup.enter="onKeyup">
<template #prefix> <template #prefix>
<slot name="prefix" />
<n-tooltip v-if="hasMatch"> <n-tooltip v-if="hasMatch">
<template #trigger> <template #trigger>
<n-tag closable size="small" @close="onClearMatch"> <n-tag closable size="small" @close="onClearMatch">
@ -74,10 +102,31 @@ defineExpose({
{{ $t('interface.full_search_result', { pattern: inputData.match }) }} {{ $t('interface.full_search_result', { pattern: inputData.match }) }}
</n-tooltip> </n-tooltip>
</template> </template>
<template #suffix>
<n-tooltip trigger="hover">
<template #trigger>
<n-icon :component="Help" />
</template>
<div class="text-block" style="max-width: 600px">
{{ $t('dialogue.filter.filter_pattern_tip') }}
</div>
</n-tooltip>
</template>
</n-input> </n-input>
<n-button :disabled="hasMatch && !hasFilter" :focusable="false" @click="onFullSearch">
<icon-button
v-if="props.fullSearchIcon"
:disabled="hasMatch && !hasFilter"
:icon="props.fullSearchIcon"
:size="small ? 16 : 20"
border
small
t-tooltip="interface.full_search"
@click="onFullSearch" />
<n-button v-else :disabled="hasMatch && !hasFilter" :focusable="false" @click="onFullSearch">
{{ $t('interface.full_search') }} {{ $t('interface.full_search') }}
</n-button> </n-button>
<slot name="append" />
</n-input-group> </n-input-group>
</template> </template>

View File

@ -40,11 +40,7 @@ watch(
) )
const browserStore = useBrowserStore() const browserStore = useBrowserStore()
const onConfirm = () => { const onConfirm = () => {}
const { server, db, type, pattern } = filterForm
browserStore.setKeyFilter(server, db, pattern, type)
browserStore.reopenDatabase(server, db)
}
const onClose = () => { const onClose = () => {
dialogStore.closeKeyFilterDialog() dialogStore.closeKeyFilterDialog()

View File

@ -109,14 +109,16 @@ const onAdd = async () => {
$message.error(err) $message.error(err)
} }
}) })
await subFormRef.value?.validate((errs) => { if (subFormRef.value?.validate) {
const err = get(errs, '0.0.message') await subFormRef.value?.validate((errs) => {
if (err != null) { const err = get(errs, '0.0.message')
$message.error(err) if (err != null) {
} else { $message.error(err)
$message.error(i18n.t('dialogue.spec_field_required', { key: i18n.t('dialogue.field.element') })) } else {
} $message.error(i18n.t('dialogue.spec_field_required', { key: i18n.t('dialogue.field.element') }))
}) }
})
}
try { try {
const { server, db, key, type, ttl } = newForm const { server, db, key, type, ttl } = newForm
let { value } = newForm let { value } = newForm

View File

@ -12,13 +12,13 @@ const props = defineProps({
<path <path
:stroke-width="props.strokeWidth" :stroke-width="props.strokeWidth"
clip-rule="evenodd" clip-rule="evenodd"
d="M23.9999 29.0001L12 17.0001L19.9999 17.0001L19.9999 6.00011L27.9999 6.00011L27.9999 17.0001L35.9999 17.0001L23.9999 29.0001Z" d="M23.9999 31L12 19L19.9999 19L19.9999 8L27.9999 8L27.9999 19L35.9999 19L23.9999 31Z"
fill="none" fill="none"
fill-rule="evenodd" fill-rule="evenodd"
stroke="currentColor" stroke="currentColor"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" /> stroke-linejoin="round" />
<path :stroke-width="props.strokeWidth" d="M42 37L6 37" stroke="currentColor" stroke-linecap="round" /> <path :stroke-width="props.strokeWidth" d="M42 38L6 38" stroke="currentColor" stroke-linecap="round" />
</svg> </svg>
</template> </template>

View File

@ -9,15 +9,9 @@ const props = defineProps({
<template> <template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"> <svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path <circle :r="props.strokeWidth" cx="12" cy="24" fill="currentColor" />
:stroke-width="strokeWidth" <circle :r="props.strokeWidth" cx="24" cy="24" fill="currentColor" />
d="M24 44C35.0457 44 44 35.0457 44 24C44 12.9543 35.0457 4 24 4C12.9543 4 4 12.9543 4 24C4 35.0457 12.9543 44 24 44Z" <circle :r="props.strokeWidth" cx="36" cy="24" fill="currentColor" />
fill="none"
stroke="currentColor"
stroke-linejoin="round" />
<circle cx="14" cy="24" fill="currentColor" r="3" />
<circle cx="24" cy="24" fill="currentColor" r="3" />
<circle cx="34" cy="24" fill="currentColor" r="3" />
</svg> </svg>
</template> </template>

View File

@ -0,0 +1,27 @@
<script setup>
const props = defineProps({
strokeWidth: {
type: [Number, String],
default: 4,
},
})
</script>
<template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
:stroke-width="props.strokeWidth"
d="M24.0605 10L24.0239 38"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round" />
<path
:stroke-width="props.strokeWidth"
d="M10 24L38 24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -17,13 +17,7 @@ const props = defineProps({
stroke-linejoin="round" /> stroke-linejoin="round" />
<path <path
:stroke-width="props.strokeWidth" :stroke-width="props.strokeWidth"
d="M26.657 14.3431C25.2093 12.8954 23.2093 12 21.0001 12C18.791 12 16.791 12.8954 15.3433 14.3431" d="M33 33L42 42"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round" />
<path
:stroke-width="props.strokeWidth"
d="M33.2216 33.2217L41.7069 41.707"
stroke="currentColor" stroke="currentColor"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" /> stroke-linejoin="round" />

View File

@ -1,54 +1,173 @@
<script setup> <script setup>
import { NIcon, useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import BrowserTree from './BrowserTree.vue' import BrowserTree from './BrowserTree.vue'
import IconButton from '@/components/common/IconButton.vue' import IconButton from '@/components/common/IconButton.vue'
import useTabStore from 'stores/tab.js' import useTabStore from 'stores/tab.js'
import { computed, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref, unref, watch } from 'vue'
import { get } from 'lodash' import { get, map } from 'lodash'
import Refresh from '@/components/icons/Refresh.vue' import Refresh from '@/components/icons/Refresh.vue'
import useDialogStore from 'stores/dialog.js' import useDialogStore from 'stores/dialog.js'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { types } from '@/consts/support_redis_type.js'
import Search from '@/components/icons/Search.vue' import Search from '@/components/icons/Search.vue'
import Unlink from '@/components/icons/Unlink.vue' import Unlink from '@/components/icons/Unlink.vue'
import ContentSearchInput from '@/components/content_value/ContentSearchInput.vue'
import LoadAll from '@/components/icons/LoadAll.vue'
import LoadList from '@/components/icons/LoadList.vue'
import Delete from '@/components/icons/Delete.vue'
import useBrowserStore from 'stores/browser.js'
import { useRender } from '@/utils/render.js'
import RedisTypeSelector from '@/components/common/RedisTypeSelector.vue'
import { types } from '@/consts/support_redis_type.js'
import Plus from '@/components/icons/Plus.vue'
const themeVars = useThemeVars() const themeVars = useThemeVars()
const i18n = useI18n()
const dialogStore = useDialogStore() const dialogStore = useDialogStore()
// const prefStore = usePreferencesStore()
const tabStore = useTabStore() const tabStore = useTabStore()
const browserStore = useBrowserStore()
const render = useRender()
const currentName = computed(() => get(tabStore.currentTab, 'name', '')) const currentName = computed(() => get(tabStore.currentTab, 'name', ''))
const browserTreeRef = ref(null) const browserTreeRef = ref(null)
const loading = ref(false)
const fullyLoaded = ref(false)
const onInfo = () => { const selectedDB = computed(() => {
browserTreeRef.value?.handleSelectContextMenu('server_info') return browserStore.selectedDatabases[currentName.value] || 0
})
const dbSelectOptions = computed(() => {
const dblist = browserStore.getDBList(currentName.value)
return map(dblist, (db) => {
if (selectedDB.value === db.db) {
return {
value: db.db,
label: `db${db.db} (${db.keys}/${db.maxKeys})`,
}
}
return {
value: db.db,
label: `db${db.db} (${db.maxKeys})`,
}
})
})
const loadProgress = computed(() => {
const db = browserStore.getDatabase(currentName.value, selectedDB.value)
if (db.maxKeys <= 0) {
return 100
}
return (db.keys * 100) / db.maxKeys
})
const onReload = async () => {
try {
loading.value = true
tabStore.setSelectedKeys(currentName.value)
const db = selectedDB.value
browserStore.closeDatabase(currentName.value, db)
browserTreeRef.value?.resetExpandKey(currentName.value, db)
let matchType = unref(filterForm.type)
if (!types.hasOwnProperty(matchType)) {
matchType = ''
}
browserStore.setKeyFilter(currentName.value, {
type: matchType,
pattern: unref(filterForm.pattern),
})
await browserStore.openDatabase(currentName.value, db)
fullyLoaded.value = await browserStore.loadMoreKeys(currentName.value, db)
// $message.success(i18n.t('dialogue.reload_succ'))
} catch (e) {
console.warn(e)
} finally {
loading.value = false
}
}
const onAddKey = () => {
dialogStore.openNewKeyDialog('', currentName.value, selectedDB.value)
}
const onLoadMore = async () => {
try {
loading.value = true
fullyLoaded.value = await browserStore.loadMoreKeys(currentName.value, selectedDB.value)
} catch (e) {
$message.error(e.message)
} finally {
loading.value = false
}
}
const onLoadAll = async () => {
try {
loading.value = true
await browserStore.loadAllKeys(currentName.value, selectedDB.value)
fullyLoaded.value = true
} catch (e) {
$message.error(e.message)
} finally {
loading.value = false
}
}
const onFlush = () => {
dialogStore.openFlushDBDialog(currentName.value, selectedDB.value)
} }
const i18n = useI18n()
const onDisconnect = () => { const onDisconnect = () => {
browserTreeRef.value?.handleSelectContextMenu('server_close') browserStore.closeConnection(currentName.value)
} }
const onRefresh = () => { const handleSelectDB = async (db, prevDB) => {
browserTreeRef.value?.handleSelectContextMenu('server_reload') // watch 'browserStore.openedDB[currentName.value]' instead
} }
const filterForm = reactive({ const filterForm = reactive({
showFilter: false,
type: '', type: '',
pattern: '', pattern: '',
filter: '',
}) })
const onSelectFilterType = (select) => {
onReload()
}
const filterTypeOptions = computed(() => { const onFilterInput = (val) => {
const options = Object.keys(types).map((t) => ({ filterForm.filter = val
value: t, }
label: t,
}))
options.splice(0, 0, {
value: '',
label: i18n.t('common.all'),
})
return options
})
const onMatchInput = (matchVal, filterVal) => {
filterForm.pattern = matchVal
filterForm.filter = filterVal
onReload()
}
watch(
() => browserStore.openedDB[currentName.value],
async (db, prevDB) => {
if (db === undefined) {
return
}
try {
loading.value = true
browserStore.closeDatabase(currentName.value, prevDB)
browserStore.setKeyFilter(currentName.value, {})
await browserStore.openDatabase(currentName.value, db)
browserTreeRef.value?.resetExpandKey(currentName.value, db)
fullyLoaded.value = await browserStore.loadMoreKeys(currentName.value, db)
browserTreeRef.value?.refreshTree()
} catch (e) {
$message.error(e.message)
} finally {
loading.value = false
}
},
)
onMounted(() => onReload())
// forbid dynamic switch key view due to performance issues // forbid dynamic switch key view due to performance issues
// const viewType = ref(0) // const viewType = ref(0)
// const onSwitchView = (selectView) => { // const onSwitchView = (selectView) => {
@ -59,47 +178,142 @@ const filterTypeOptions = computed(() => {
<template> <template>
<div class="nav-pane-container flex-box-v"> <div class="nav-pane-container flex-box-v">
<browser-tree ref="browserTreeRef" :server="currentName" /> <!-- top function bar -->
<div class="flex-box-h nav-pane-func">
<div v-if="filterForm.showFilter" class="nav-pane-bottom flex-box-h"> <content-search-input
<n-input-group> :debounce-wait="1000"
<n-select :full-search-icon="Search"
v-model:value="filterForm.type" small
:consistent-menu-width="false" @filter-changed="onFilterInput"
:options="filterTypeOptions" @match-changed="onMatchInput">
style="width: 120px" /> <template #prepend>
<n-input clearable placeholder="" /> <redis-type-selector v-model:value="filterForm.type" @update:value="onSelectFilterType" />
<n-button :focusable="false" ghost> </template>
<template #icon> </content-search-input>
<n-icon :component="Search" /> <n-button-group>
<n-tooltip :show-arrow="false">
<template #trigger>
<n-button :disabled="loading" :focusable="false" bordered size="small" @click="onReload">
<template #icon>
<n-icon :component="Refresh" size="18" />
</template>
</n-button>
</template> </template>
</n-button> {{ $t('interface.reload') }}
</n-input-group> </n-tooltip>
<n-tooltip :show-arrow="false">
<template #trigger>
<n-button :disabled="loading" :focusable="false" bordered size="small" @click="onAddKey">
<template #icon>
<n-icon :component="Plus" size="18" />
</template>
</n-button>
</template>
{{ $t('interface.new_key') }}
</n-tooltip>
</n-button-group>
</div> </div>
<!-- loaded progress -->
<n-progress
:border-radius="0"
:color="fullyLoaded ? '#0000' : themeVars.primaryColor"
:height="2"
:percentage="loadProgress"
:processing="loading"
:show-indicator="false"
status="success"
type="line" />
<!-- tree view -->
<browser-tree
ref="browserTreeRef"
:full-loaded="fullyLoaded"
:loading="loading && loadProgress <= 0"
:pattern="filterForm.filter"
:server="currentName" />
<!-- bottom function bar --> <!-- bottom function bar -->
<div class="nav-pane-bottom flex-box-h"> <div class="nav-pane-bottom flex-box-v">
<!-- <switch-button--> <!-- <switch-button-->
<!-- v-model:value="viewType"--> <!-- v-model:value="viewType"-->
<!-- :icons="[TreeView, ListView]"--> <!-- :icons="[TreeView, ListView]"-->
<!-- :t-tooltips="['interface.tree_view', 'interface.list_view']"--> <!-- :t-tooltips="['interface.tree_view', 'interface.list_view']"-->
<!-- stroke-width="4"--> <!-- :stroke-width="3.5"-->
<!-- unselect-stroke-width="3"--> <!-- unselect-stroke-width="3"-->
<!-- @update:value="onSwitchView" />--> <!-- @update:value="onSwitchView" />-->
<!-- <icon-button :icon="Status" size="20" stroke-width="4" t-tooltip="interface.status" @click="onInfo" />--> <div class="flex-box-h nav-pane-func">
<icon-button :icon="Refresh" size="20" stroke-width="4" t-tooltip="interface.reload" @click="onRefresh" /> <n-select
<div class="flex-item-expand" /> v-model:value="browserStore.openedDB[currentName]"
<icon-button :consistent-menu-width="false"
:icon="Unlink" :filter="(pattern, option) => option.value.toString() === pattern"
size="20" :options="dbSelectOptions"
stroke-width="4" filterable
t-tooltip="interface.disconnect" size="small"
@click="onDisconnect" /> style="min-width: 100px; max-width: 200px"
@update:value="handleSelectDB" />
<icon-button
:button-class="['nav-pane-func-btn']"
:disabled="fullyLoaded"
:icon="LoadList"
:loading="loading"
:stroke-width="3.5"
size="20"
t-tooltip="interface.load_more"
@click="onLoadMore" />
<icon-button
:button-class="['nav-pane-func-btn']"
:disabled="fullyLoaded"
:icon="LoadAll"
:loading="loading"
:stroke-width="3.5"
size="20"
t-tooltip="interface.load_all"
@click="onLoadAll" />
<div class="flex-item-expand" />
<icon-button
:button-class="['nav-pane-func-btn']"
:icon="Delete"
:stroke-width="3.5"
size="20"
t-tooltip="interface.flush_db"
@click="onFlush" />
<icon-button
:button-class="['nav-pane-func-btn']"
:icon="Unlink"
:stroke-width="3.5"
size="20"
t-tooltip="interface.disconnect"
@click="onDisconnect" />
</div>
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@import '@/styles/style';
:deep(.toggle-btn) {
border-style: solid;
border-width: 1px;
}
:deep(.filter-on) {
border-color: v-bind('themeVars.iconColorDisabled');
background-color: v-bind('themeVars.iconColorDisabled');
}
:deep(.filter-off) {
border-color: #0000;
}
.nav-pane-top {
//@include bottom-shadow(0.1);
color: v-bind('themeVars.iconColor');
border-bottom: v-bind('themeVars.borderColor') 1px solid;
}
.nav-pane-bottom { .nav-pane-bottom {
@include top-shadow(0.1);
color: v-bind('themeVars.iconColor'); color: v-bind('themeVars.iconColor');
border-top: v-bind('themeVars.borderColor') 1px solid; border-top: v-bind('themeVars.borderColor') 1px solid;
} }

View File

@ -1,7 +1,7 @@
<script setup> <script setup>
import { computed, h, nextTick, onMounted, reactive, ref } from 'vue' import { computed, h, nextTick, reactive, ref } from 'vue'
import { ConnectionType } from '@/consts/connection_type.js' import { ConnectionType } from '@/consts/connection_type.js'
import { NIcon, NSpace, NTag, useThemeVars } from 'naive-ui' import { NIcon, NSpace, useThemeVars } from 'naive-ui'
import Key from '@/components/icons/Key.vue' import Key from '@/components/icons/Key.vue'
import Binary from '@/components/icons/Binary.vue' import Binary from '@/components/icons/Binary.vue'
import Database from '@/components/icons/Database.vue' import Database from '@/components/icons/Database.vue'
@ -12,30 +12,29 @@ import CopyLink from '@/components/icons/CopyLink.vue'
import Add from '@/components/icons/Add.vue' import Add from '@/components/icons/Add.vue'
import Layer from '@/components/icons/Layer.vue' import Layer from '@/components/icons/Layer.vue'
import Delete from '@/components/icons/Delete.vue' import Delete from '@/components/icons/Delete.vue'
import Connect from '@/components/icons/Connect.vue'
import useDialogStore from 'stores/dialog.js' import useDialogStore from 'stores/dialog.js'
import { ClipboardSetText } from 'wailsjs/runtime/runtime.js' import { ClipboardSetText } from 'wailsjs/runtime/runtime.js'
import useConnectionStore from 'stores/connections.js' import useConnectionStore from 'stores/connections.js'
import Unlink from '@/components/icons/Unlink.vue'
import Filter from '@/components/icons/Filter.vue' import Filter from '@/components/icons/Filter.vue'
import Close from '@/components/icons/Close.vue'
import { typesBgColor, typesColor } from '@/consts/support_redis_type.js'
import useTabStore from 'stores/tab.js' import useTabStore from 'stores/tab.js'
import IconButton from '@/components/common/IconButton.vue' import IconButton from '@/components/common/IconButton.vue'
import { parseHexColor } from '@/utils/rgb.js' import { parseHexColor } from '@/utils/rgb.js'
import LoadList from '@/components/icons/LoadList.vue' import LoadList from '@/components/icons/LoadList.vue'
import LoadAll from '@/components/icons/LoadAll.vue' import LoadAll from '@/components/icons/LoadAll.vue'
import useBrowserStore from 'stores/browser.js' import useBrowserStore from 'stores/browser.js'
import { useRender } from '@/utils/render.js'
const props = defineProps({ const props = defineProps({
server: String, server: String,
keyView: String, keyView: String,
loading: Boolean,
pattern: String,
fullLoaded: Boolean,
}) })
const themeVars = useThemeVars() const themeVars = useThemeVars()
const render = useRender()
const i18n = useI18n() const i18n = useI18n()
const loading = ref(false)
const loadingConnections = ref(false)
const expandedKeys = ref([props.server]) const expandedKeys = ref([props.server])
const connectionStore = useConnectionStore() const connectionStore = useConnectionStore()
const browserStore = useBrowserStore() const browserStore = useBrowserStore()
@ -55,8 +54,9 @@ const selectedKeys = computed(() => {
}) })
const data = computed(() => { const data = computed(() => {
const dbs = get(browserStore.databases, props.server, []) // const dbs = get(browserStore.databases, props.server, [])
return dbs // return dbs
return browserStore.getKeyList(props.server)
}) })
const backgroundColor = computed(() => { const backgroundColor = computed(() => {
@ -74,90 +74,44 @@ const contextMenuParam = reactive({
y: 0, y: 0,
options: null, options: null,
}) })
const renderIcon = (icon) => {
return () => {
return h(NIcon, null, {
default: () => h(icon),
})
}
}
const menuOptions = { const menuOptions = {
[ConnectionType.Server]: () => {
return [
{
key: 'server_reload',
label: i18n.t('interface.reload'),
icon: renderIcon(Refresh),
},
{
key: 'server_close',
label: i18n.t('interface.disconnect'),
icon: renderIcon(Unlink),
},
]
},
[ConnectionType.RedisDB]: ({ opened }) => { [ConnectionType.RedisDB]: ({ opened }) => {
if (opened) { if (opened) {
return [ return [
{
key: 'db_reload',
label: i18n.t('interface.reload'),
icon: renderIcon(Refresh),
},
{
key: 'db_newkey',
label: i18n.t('interface.new_key'),
icon: renderIcon(Add),
},
{ {
key: 'db_filter', key: 'db_filter',
label: i18n.t('interface.filter_key'), label: i18n.t('interface.filter_key'),
icon: renderIcon(Filter), icon: render.renderIcon(Filter),
}, },
{ {
type: 'divider', type: 'divider',
key: 'd1', key: 'd1',
}, },
{
key: 'db_flush',
label: i18n.t('interface.flush_db'),
icon: renderIcon(Delete),
},
{ {
type: 'divider', type: 'divider',
key: 'd2', key: 'd2',
}, },
{
key: 'db_close',
label: i18n.t('interface.close_db'),
icon: renderIcon(Close),
},
] ]
} else { } else {
return [ return []
{
key: 'db_open',
label: i18n.t('interface.open_db'),
icon: renderIcon(Connect),
},
]
} }
}, },
[ConnectionType.RedisKey]: () => [ [ConnectionType.RedisKey]: () => [
// { // {
// key: 'key_reload', // key: 'key_reload',
// label: i18n.t('interface.reload'), // label: i18n.t('interface.reload'),
// icon: renderIcon(Refresh), // icon: render.renderIcon(Refresh),
// }, // },
{ {
key: 'key_newkey', key: 'key_newkey',
label: i18n.t('interface.new_key'), label: i18n.t('interface.new_key'),
icon: renderIcon(Add), icon: render.renderIcon(Add),
}, },
{ {
key: 'key_copy', key: 'key_copy',
label: i18n.t('interface.copy_path'), label: i18n.t('interface.copy_path'),
icon: renderIcon(CopyLink), icon: render.renderIcon(CopyLink),
}, },
{ {
type: 'divider', type: 'divider',
@ -166,19 +120,19 @@ const menuOptions = {
{ {
key: 'key_remove', key: 'key_remove',
label: i18n.t('interface.batch_delete_key'), label: i18n.t('interface.batch_delete_key'),
icon: renderIcon(Delete), icon: render.renderIcon(Delete),
}, },
], ],
[ConnectionType.RedisValue]: () => [ [ConnectionType.RedisValue]: () => [
{ {
key: 'value_reload', key: 'value_reload',
label: i18n.t('interface.reload'), label: i18n.t('interface.reload'),
icon: renderIcon(Refresh), icon: render.renderIcon(Refresh),
}, },
{ {
key: 'value_copy', key: 'value_copy',
label: i18n.t('interface.copy_key'), label: i18n.t('interface.copy_key'),
icon: renderIcon(CopyLink), icon: render.renderIcon(CopyLink),
}, },
{ {
type: 'divider', type: 'divider',
@ -187,7 +141,7 @@ const menuOptions = {
{ {
key: 'value_remove', key: 'value_remove',
label: i18n.t('interface.remove_key'), label: i18n.t('interface.remove_key'),
icon: renderIcon(Delete), icon: render.renderIcon(Delete),
}, },
], ],
} }
@ -196,15 +150,6 @@ const renderContextLabel = (option) => {
return h('div', { class: 'context-menu-item' }, option.label) return h('div', { class: 'context-menu-item' }, option.label)
} }
onMounted(async () => {
try {
// TODO: Show loading list status
loadingConnections.value = true
} finally {
loadingConnections.value = false
}
})
const expandKey = (key) => { const expandKey = (key) => {
const idx = indexOf(expandedKeys.value, key) const idx = indexOf(expandedKeys.value, key)
if (idx === -1) { if (idx === -1) {
@ -236,40 +181,11 @@ const handleSelectContextMenu = (key) => {
const redisKey = rkc || rk const redisKey = rkc || rk
const redisKeyName = !!rkc ? label : redisKey const redisKeyName = !!rkc ? label : redisKey
switch (key) { switch (key) {
case 'server_info':
tabStore.setSelectedKeys(props.server)
onUpdateSelectedKeys()
break
case 'server_reload':
expandedKeys.value = [props.server]
tabStore.setSelectedKeys(props.server)
browserStore.openConnection(props.server, true).then(() => {
$message.success(i18n.t('dialogue.reload_succ'))
})
break
case 'server_close':
browserStore.closeConnection(props.server)
break
case 'db_open':
nextTick().then(() => expandKey(nodeKey))
break
case 'db_reload':
resetExpandKey(props.server, db)
browserStore.reopenDatabase(props.server, db)
break
case 'db_close':
resetExpandKey(props.server, db, true)
browserStore.closeDatabase(props.server, db)
break
case 'db_flush':
dialogStore.openFlushDBDialog(props.server, db)
break
case 'db_newkey':
case 'key_newkey': case 'key_newkey':
dialogStore.openNewKeyDialog(redisKey, props.server, db) dialogStore.openNewKeyDialog(redisKey, props.server, db)
break break
case 'db_filter': case 'db_filter':
const { match: pattern, type } = browserStore.getKeyFilter(props.server, db) const { match: pattern, type } = browserStore.getKeyFilter(props.server)
dialogStore.openKeyFilterDialog(props.server, db, pattern, type) dialogStore.openKeyFilterDialog(props.server, db, pattern, type)
break break
case 'key_reload': case 'key_reload':
@ -311,23 +227,6 @@ const handleSelectContextMenu = (key) => {
$message.error(e.message) $message.error(e.message)
}) })
break break
case 'db_loadmore':
if (node != null && !!!node.loading && !!!node.fullLoaded) {
node.loading = true
browserStore
.loadMoreKeys(props.server, db)
.then((end) => {
// fully loaded
node.fullLoaded = end === true
})
.catch((e) => {
$message.error(e.message)
})
.finally(() => {
delete node.loading
})
}
break
case 'db_loadall': case 'db_loadall':
if (node != null && !!!node.loading) { if (node != null && !!!node.loading) {
node.loading = true node.loading = true
@ -348,10 +247,6 @@ const handleSelectContextMenu = (key) => {
} }
} }
defineExpose({
handleSelectContextMenu,
})
const onUpdateExpanded = (value, option, meta) => { const onUpdateExpanded = (value, option, meta) => {
expandedKeys.value = value expandedKeys.value = value
if (!meta.node) { if (!meta.node) {
@ -445,61 +340,6 @@ const renderPrefix = ({ option }) => {
// render tree item label // render tree item label
const renderLabel = ({ option }) => { const renderLabel = ({ option }) => {
switch (option.type) { switch (option.type) {
case ConnectionType.Server:
return h('b', {}, { default: () => option.label })
case ConnectionType.RedisDB:
const { name: server, db, opened = false } = option
let { match: matchPattern, type: typeFilter } = browserStore.getKeyFilter(server, db)
const items = []
if (opened) {
items.push(`${option.label} (${option.keys || 0}/${Math.max(option.maxKeys || 0, option.keys || 0)})`)
} else {
items.push(`${option.label} (${Math.max(option.maxKeys || 0, option.keys || 0)})`)
}
// show filter tag after label
// type filter tag
if (!isEmpty(typeFilter)) {
items.push(
h(
NTag,
{
size: 'small',
closable: true,
bordered: false,
color: {
color: typesBgColor[typeFilter],
textColor: typesColor[typeFilter],
},
onClose: () => {
// remove type filter
browserStore.setKeyFilter(server, db, matchPattern)
browserStore.reopenDatabase(server, db)
},
},
{ default: () => typeFilter },
),
)
}
// match pattern tag
if (!isEmpty(matchPattern) && matchPattern !== '*') {
items.push(
h(
NTag,
{
bordered: false,
closable: true,
size: 'small',
onClose: () => {
// remove key match pattern
browserStore.setKeyFilter(server, db, '*', typeFilter)
browserStore.reopenDatabase(server, db)
},
},
{ default: () => matchPattern },
),
)
}
return renderIconMenu(items)
case ConnectionType.RedisKey: case ConnectionType.RedisKey:
return `${option.label} (${option.keys || 0})` return `${option.label} (${option.keys || 0})`
// case ConnectionType.RedisValue: // case ConnectionType.RedisValue:
@ -528,24 +368,6 @@ const calcDBMenu = (opened, loading, end) => {
const btns = [] const btns = []
if (opened) { if (opened) {
btns.push( btns.push(
h(IconButton, {
tTooltip: 'interface.filter_key',
icon: Filter,
disabled: loading === true,
onClick: () => handleSelectContextMenu('db_filter'),
}),
h(IconButton, {
tTooltip: 'interface.reload',
icon: Refresh,
disabled: loading === true,
onClick: () => handleSelectContextMenu('db_reload'),
}),
h(IconButton, {
tTooltip: 'interface.new_key',
icon: Add,
disabled: loading === true,
onClick: () => handleSelectContextMenu('db_newkey'),
}),
h(IconButton, { h(IconButton, {
tTooltip: 'interface.load_more', tTooltip: 'interface.load_more',
icon: LoadList, icon: LoadList,
@ -562,38 +384,24 @@ const calcDBMenu = (opened, loading, end) => {
color: loading === true ? themeVars.value.primaryColor : '', color: loading === true ? themeVars.value.primaryColor : '',
onClick: () => handleSelectContextMenu('db_loadall'), onClick: () => handleSelectContextMenu('db_loadall'),
}), }),
h(IconButton, {
tTooltip: 'interface.flush_db',
icon: Delete,
disabled: loading === true,
onClick: () => handleSelectContextMenu('db_flush'),
}),
// h(IconButton, { // h(IconButton, {
// tTooltip: 'interface.more_action', // tTooltip: 'interface.more_action',
// icon: More, // icon: More,
// onClick: () => handleSelectContextMenu('more_action'), // onClick: () => handleSelectContextMenu('more_action'),
// }), // }),
) )
} else {
btns.push(
h(IconButton, {
tTooltip: 'interface.open_db',
icon: Connect,
onClick: () => handleSelectContextMenu('db_open'),
}),
)
} }
return btns return btns
} }
const calcLayerMenu = (loading, end) => { const calcLayerMenu = (loading) => {
return [ return [
// reload layer enable only full loaded // reload layer enable only full loaded
h(IconButton, { h(IconButton, {
tTooltip: end === true ? 'interface.reload' : 'interface.reload', tTooltip: props.fullLoaded ? 'interface.reload' : 'interface.reload',
icon: Refresh, icon: Refresh,
loading: loading === true, loading: loading === true,
disabled: end !== true, disabled: !props.fullLoaded,
onClick: () => handleSelectContextMenu('key_reload'), onClick: () => handleSelectContextMenu('key_reload'),
}), }),
h(IconButton, { h(IconButton, {
@ -626,8 +434,7 @@ const renderSuffix = ({ option }) => {
case ConnectionType.RedisDB: case ConnectionType.RedisDB:
return renderIconMenu(calcDBMenu(option.opened, option.loading, option.fullLoaded)) return renderIconMenu(calcDBMenu(option.opened, option.loading, option.fullLoaded))
case ConnectionType.RedisKey: case ConnectionType.RedisKey:
const fullLoaded = browserStore.isFullLoaded(props.server, option.db) return renderIconMenu(calcLayerMenu(option.loading))
return renderIconMenu(calcLayerMenu(option.loading, fullLoaded))
case ConnectionType.RedisValue: case ConnectionType.RedisValue:
return renderIconMenu(calcValueMenu()) return renderIconMenu(calcValueMenu())
} }
@ -638,7 +445,7 @@ const renderSuffix = ({ option }) => {
const nodeProps = ({ option }) => { const nodeProps = ({ option }) => {
return { return {
onDblclick: () => { onDblclick: () => {
if (loading.value) { if (props.loading) {
console.warn('TODO: alert to ignore double click when loading') console.warn('TODO: alert to ignore double click when loading')
return return
} }
@ -665,37 +472,29 @@ const nodeProps = ({ option }) => {
} }
} }
const onLoadTree = async (node) => {
switch (node.type) {
case ConnectionType.RedisDB:
loading.value = true
try {
await browserStore.openDatabase(props.server, node.db)
} catch (e) {
$message.error(e.message)
node.isLeaf = undefined
} finally {
loading.value = false
}
break
// case ConnectionType.RedisKey:
// console.warn('load redis key', node.redisKey)
// node.keys = sumBy(node.children, 'keys')
// break
// case ConnectionType.RedisValue:
// node.keys = 1
// break
}
}
const handleOutsideContextMenu = () => { const handleOutsideContextMenu = () => {
contextMenuParam.show = false contextMenuParam.show = false
} }
// the NTree node may get incorrect height after change data
// add key property to force refresh the component and then everything back to normal
const treeKey = ref(0)
defineExpose({
handleSelectContextMenu,
resetExpandKey,
refreshTree: () => {
treeKey.value = Date.now()
},
})
</script> </script>
<template> <template>
<div :style="{ backgroundColor }" class="browser-tree-wrapper"> <div :style="{ backgroundColor }" class="flex-box-v browser-tree-wrapper">
<n-spin v-if="props.loading" class="fill-height" />
<n-empty v-else-if="!props.loading && isEmpty(data)" class="empty-content" />
<n-tree <n-tree
v-show="!props.loading && !isEmpty(data)"
:key="treeKey"
:animated="false" :animated="false"
:block-line="true" :block-line="true"
:block-node="true" :block-node="true"
@ -703,14 +502,16 @@ const handleOutsideContextMenu = () => {
:data="data" :data="data"
:expand-on-click="false" :expand-on-click="false"
:expanded-keys="expandedKeys" :expanded-keys="expandedKeys"
:filter="(pattern, node) => includes(node.redisKey, pattern)"
:node-props="nodeProps" :node-props="nodeProps"
:pattern="props.pattern"
:render-label="renderLabel" :render-label="renderLabel"
:render-prefix="renderPrefix" :render-prefix="renderPrefix"
:render-suffix="renderSuffix" :render-suffix="renderSuffix"
:selected-keys="selectedKeys" :selected-keys="selectedKeys"
:show-irrelevant-nodes="false"
class="fill-height" class="fill-height"
virtual-scroll virtual-scroll
@load="onLoadTree"
@update:selected-keys="onUpdateSelectedKeys" @update:selected-keys="onUpdateSelectedKeys"
@update:expanded-keys="onUpdateExpanded" /> @update:expanded-keys="onUpdateExpanded" />
<n-dropdown <n-dropdown
@ -727,6 +528,8 @@ const handleOutsideContextMenu = () => {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@import '@/styles/content';
.browser-tree-wrapper { .browser-tree-wrapper {
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;

View File

@ -18,14 +18,16 @@ const filterPattern = ref('')
<connection-tree :filter-pattern="filterPattern" /> <connection-tree :filter-pattern="filterPattern" />
<!-- bottom function bar --> <!-- bottom function bar -->
<div class="nav-pane-bottom flex-box-h"> <div class="nav-pane-bottom nav-pane-func flex-box-h">
<icon-button <icon-button
:button-class="['nav-pane-func-btn']"
:icon="AddLink" :icon="AddLink"
size="20" size="20"
stroke-width="4" stroke-width="4"
t-tooltip="interface.new_conn" t-tooltip="interface.new_conn"
@click="dialogStore.openNewDialog()" /> @click="dialogStore.openNewDialog()" />
<icon-button <icon-button
:button-class="['nav-pane-func-btn']"
:icon="AddGroup" :icon="AddGroup"
size="20" size="20"
stroke-width="4" stroke-width="4"

View File

@ -20,9 +20,11 @@ import { hexGammaCorrection, parseHexColor, toHexColor } from '@/utils/rgb.js'
import IconButton from '@/components/common/IconButton.vue' import IconButton from '@/components/common/IconButton.vue'
import usePreferencesStore from 'stores/preferences.js' import usePreferencesStore from 'stores/preferences.js'
import useBrowserStore from 'stores/browser.js' import useBrowserStore from 'stores/browser.js'
import { useRender } from '@/utils/render.js'
const themeVars = useThemeVars() const themeVars = useThemeVars()
const i18n = useI18n() const i18n = useI18n()
const render = useRender()
const connectingServer = ref('') const connectingServer = ref('')
const connectionStore = useConnectionStore() const connectionStore = useConnectionStore()
const browserStore = useBrowserStore() const browserStore = useBrowserStore()
@ -59,12 +61,12 @@ const menuOptions = {
{ {
key: 'group_rename', key: 'group_rename',
label: i18n.t('interface.rename_conn_group'), label: i18n.t('interface.rename_conn_group'),
icon: renderIcon(Edit), icon: render.renderIcon(Edit),
}, },
{ {
key: 'group_delete', key: 'group_delete',
label: i18n.t('interface.remove_conn_group'), label: i18n.t('interface.remove_conn_group'),
icon: renderIcon(Delete), icon: render.renderIcon(Delete),
}, },
], ],
[ConnectionType.Server]: ({ name }) => { [ConnectionType.Server]: ({ name }) => {
@ -74,17 +76,17 @@ const menuOptions = {
{ {
key: 'server_close', key: 'server_close',
label: i18n.t('interface.disconnect'), label: i18n.t('interface.disconnect'),
icon: renderIcon(Unlink), icon: render.renderIcon(Unlink),
}, },
{ {
key: 'server_edit', key: 'server_edit',
label: i18n.t('interface.edit_conn'), label: i18n.t('interface.edit_conn'),
icon: renderIcon(Config), icon: render.renderIcon(Config),
}, },
{ {
key: 'server_dup', key: 'server_dup',
label: i18n.t('interface.dup_conn'), label: i18n.t('interface.dup_conn'),
icon: renderIcon(CopyLink), icon: render.renderIcon(CopyLink),
}, },
{ {
type: 'divider', type: 'divider',
@ -93,7 +95,7 @@ const menuOptions = {
{ {
key: 'server_remove', key: 'server_remove',
label: i18n.t('interface.remove_conn'), label: i18n.t('interface.remove_conn'),
icon: renderIcon(Delete), icon: render.renderIcon(Delete),
}, },
] ]
} else { } else {
@ -101,17 +103,17 @@ const menuOptions = {
{ {
key: 'server_open', key: 'server_open',
label: i18n.t('interface.open_connection'), label: i18n.t('interface.open_connection'),
icon: renderIcon(Connect), icon: render.renderIcon(Connect),
}, },
{ {
key: 'server_edit', key: 'server_edit',
label: i18n.t('interface.edit_conn'), label: i18n.t('interface.edit_conn'),
icon: renderIcon(Config), icon: render.renderIcon(Config),
}, },
{ {
key: 'server_dup', key: 'server_dup',
label: i18n.t('interface.dup_conn'), label: i18n.t('interface.dup_conn'),
icon: renderIcon(CopyLink), icon: render.renderIcon(CopyLink),
}, },
{ {
type: 'divider', type: 'divider',
@ -120,7 +122,7 @@ const menuOptions = {
{ {
key: 'server_remove', key: 'server_remove',
label: i18n.t('interface.remove_conn'), label: i18n.t('interface.remove_conn'),
icon: renderIcon(Delete), icon: render.renderIcon(Delete),
}, },
] ]
} }

View File

@ -13,8 +13,10 @@ import usePreferencesStore from 'stores/preferences.js'
import Record from '@/components/icons/Record.vue' import Record from '@/components/icons/Record.vue'
import { extraTheme } from '@/utils/extra_theme.js' import { extraTheme } from '@/utils/extra_theme.js'
import useBrowserStore from 'stores/browser.js' import useBrowserStore from 'stores/browser.js'
import { useRender } from '@/utils/render.js'
const themeVars = useThemeVars() const themeVars = useThemeVars()
const render = useRender()
const props = defineProps({ const props = defineProps({
value: { value: {
@ -30,9 +32,6 @@ const props = defineProps({
const emit = defineEmits(['update:value']) const emit = defineEmits(['update:value'])
const iconSize = computed(() => Math.floor(props.width * 0.45)) const iconSize = computed(() => Math.floor(props.width * 0.45))
const renderIcon = (icon) => {
return () => h(NIcon, null, { default: () => h(icon, { strokeWidth: 3 }) })
}
const browserStore = useBrowserStore() const browserStore = useBrowserStore()
const i18n = useI18n() const i18n = useI18n()
@ -62,12 +61,12 @@ const preferencesOptions = computed(() => {
{ {
label: i18n.t('menu.preferences'), label: i18n.t('menu.preferences'),
key: 'preferences', key: 'preferences',
icon: renderIcon(Config), icon: render.renderIcon(Config, { strokeWidth: 3 }),
}, },
// { // {
// label: i18n.t('menu.help'), // label: i18n.t('menu.help'),
// key: 'help', // key: 'help',
// icon: renderIcon(Help), // icon: render.renderIcon(Help, { strokeWidth: 3 }),
// }, // },
{ {
label: i18n.t('menu.check_update'), label: i18n.t('menu.check_update'),
@ -122,22 +121,20 @@ const exThemeVars = computed(() => {
}" }"
class="flex-box-v"> class="flex-box-v">
<div class="ribbon-wrapper flex-box-v"> <div class="ribbon-wrapper flex-box-v">
<div <n-tooltip v-for="(m, i) in menuOptions" :key="i" :delay="2" :show-arrow="false" placement="right">
v-for="(m, i) in menuOptions" <template #trigger>
v-show="m.show !== false" <div
:key="i" v-show="m.show !== false"
:class="{ 'ribbon-item-active': props.value === m.key }" :class="{ 'ribbon-item-active': props.value === m.key }"
class="ribbon-item clickable" class="ribbon-item clickable"
@click="emit('update:value', m.key)"> @click="emit('update:value', m.key)">
<n-tooltip :delay="2" :show-arrow="false" placement="right">
<template #trigger>
<n-icon :size="iconSize"> <n-icon :size="iconSize">
<component :is="m.icon" :stroke-width="3.5" /> <component :is="m.icon" :stroke-width="3.5" />
</n-icon> </n-icon>
</template> </div>
{{ m.label }} </template>
</n-tooltip> {{ m.label }}
</div> </n-tooltip>
</div> </div>
<div class="flex-item-expand"></div> <div class="flex-item-expand"></div>
<div class="nav-menu-item flex-box-v"> <div class="nav-menu-item flex-box-v">

View File

@ -255,7 +255,7 @@
"filter": { "filter": {
"set_key_filter": "Set Key Filter", "set_key_filter": "Set Key Filter",
"filter_pattern": "Pattern", "filter_pattern": "Pattern",
"filter_pattern_tip": "prefix_*: Matches key names starting with \"prefix_\".\n*_suffix: Matches key names ending with \"_suffix\".\n*pattern*: Matches key names containing \"pattern\".\nprefix_??: Matches key names starting with \"prefix_\" followed by any two characters.\n*abc*: Matches key names containing \"abc\" at any position." "filter_pattern_tip": "* : Matches zero or more characters. For example, 'key*' matches all keys starting with 'key'.\n? : Matches a single character. For example, 'key?' matches 'key1', 'key2'.\n[] : Matches a single character within the specified range. For example, 'key[1-3]' matches keys like 'key1', 'key2', 'key3'.\n\\ : Escape character. To match *, ?, [, or ], use the backslash '\\' for escaping."
}, },
"ttl": { "ttl": {
"title": "Set Key TTL" "title": "Set Key TTL"

View File

@ -246,8 +246,7 @@
}, },
"filter": { "filter": {
"set_key_filter": "Definir Filtro de Chave", "set_key_filter": "Definir Filtro de Chave",
"filter_pattern": "Padrão", "filter_pattern": "Padrão"
"filter_pattern_tip": "prefixo_*: Corresponde a nomes de chaves que começam com \"prefixo_\".\n*_sufixo: Corresponde a nomes de chaves que terminam com \"_sufixo\".\n*padrão*: Corresponde a nomes de chaves que contêm \"padrão\".\nprefixo_??: Corresponde a nomes de chaves que começam com \"prefixo_\" seguido por dois caracteres.\n*abc*: Corresponde a nomes de chaves que contêm \"abc\" em qualquer posição."
}, },
"ttl": { "ttl": {
"title": "Definir TTL da Chave" "title": "Definir TTL da Chave"

View File

@ -254,7 +254,7 @@
"filter": { "filter": {
"set_key_filter": "设置键过滤器", "set_key_filter": "设置键过滤器",
"filter_pattern": "过滤表达式", "filter_pattern": "过滤表达式",
"filter_pattern_tip": "prefix_*:匹配以\"prefix_\"开头的键名\n*_suffix匹配以\"_suffix\"结尾的键名\n*pattern*:匹配包含\"pattern\"的键名\nprefix_??:匹配以\"prefix_\"开头后跟两个任意字符的键名\n*abc*:匹配包含\"abc\"的任意位置的键名" "filter_pattern_tip": "*:匹配零个或多个字符。例如:\"key*\"匹配到以\"key\"开头的所有键\n?:匹配单个字符。例如:\"key?\"匹配\"key1\"、\"key2\"\n[ ]:匹配指定范围内的单个字符。例如:\"key[1-3]\"可以匹配类似于 \"key1\"、\"key2\"、\"key3\" 的键\n\\:转义字符。如果想要匹配 *、?、[、或],需要使用反斜杠\"\\\"进行转义"
}, },
"ttl": { "ttl": {
"title": "设置键存活时间" "title": "设置键存活时间"

View File

@ -50,9 +50,9 @@ import { decodeRedisKey, nativeRedisKey } from '@/utils/key_convert.js'
import { BrowserTabType } from '@/consts/browser_tab_type.js' import { BrowserTabType } from '@/consts/browser_tab_type.js'
import { KeyViewType } from '@/consts/key_view_type.js' import { KeyViewType } from '@/consts/key_view_type.js'
import { ConnectionType } from '@/consts/connection_type.js' import { ConnectionType } from '@/consts/connection_type.js'
import { types } from '@/consts/support_redis_type.js'
import useConnectionStore from 'stores/connections.js' import useConnectionStore from 'stores/connections.js'
import { decodeTypes, formatTypes } from '@/consts/value_view_type.js' import { decodeTypes, formatTypes } from '@/consts/value_view_type.js'
import { isRedisGlob } from '@/utils/glob_pattern.js'
const useBrowserStore = defineStore('browser', { const useBrowserStore = defineStore('browser', {
/** /**
@ -70,8 +70,18 @@ const useBrowserStore = defineStore('browser', {
* @property {boolean} [opened] - redis db is opened, type == ConnectionType.RedisDB only * @property {boolean} [opened] - redis db is opened, type == ConnectionType.RedisDB only
* @property {boolean} [expanded] - current node is expanded * @property {boolean} [expanded] - current node is expanded
* @property {DatabaseItem[]} [children] * @property {DatabaseItem[]} [children]
* @property {boolean} [loading] - indicated that is loading children now */
* @property {boolean} [fullLoaded] - indicated that all children already loaded
/**
* @typedef {Object} FilterItem
* @property {string} pattern key pattern filter
* @property {string} type type filter
*/
/**
* @typedef {Object} LoadingState
* @property {string} loading indicated that is loading children now
* @property {string} fullLoaded indicated that all children already loaded
*/ */
/** /**
@ -85,8 +95,7 @@ const useBrowserStore = defineStore('browser', {
/** /**
* @typedef {Object} BrowserState * @typedef {Object} BrowserState
* @property {Object} serverStats * @property {Object} serverStats
* @property {Object.<string, string>} keyFilter key is 'server#db', 'server#-1' stores default filter pattern * @property {Object.<string, FilterItem>} filter
* @property {Object.<string, string>} typeFilter key is 'server#db'
* @property {Object.<string, KeyViewType>} viewType * @property {Object.<string, KeyViewType>} viewType
* @property {Object.<string, DatabaseItem[]>} databases * @property {Object.<string, DatabaseItem[]>} databases
* @property {Object.<string, Map<string, DatabaseItem>>} nodeMap key format likes 'server#db', children key format likes 'key#type' * @property {Object.<string, Map<string, DatabaseItem>>} nodeMap key format likes 'server#db', children key format likes 'key#type'
@ -98,17 +107,21 @@ const useBrowserStore = defineStore('browser', {
*/ */
state: () => ({ state: () => ({
serverStats: {}, // current server status info serverStats: {}, // current server status info
keyFilter: {}, // all key filters in opened connections group by 'server+db' filter: {}, // all filters in opened connections map by server and FilterItem
typeFilter: {}, // all key type filters in opened connections group by 'server+db' loadingState: {}, // all loading state in opened connections map by server and LoadingState
viewType: {}, // view type selection for all opened connections group by 'server' viewType: {}, // view type selection for all opened connections group by 'server'
databases: {}, // all databases in opened connections group by 'server name' databases: {}, // all databases in opened connections group by 'server name'
nodeMap: {}, // all nodes in opened connections group by 'server#db' and 'type/key' nodeMap: {}, // all nodes in opened connections group by 'server#db' and 'type/key'
keySet: {}, // all keys set in opened connections group by 'server#db keySet: {}, // all keys set in opened connections group by 'server#db
openedDB: {}, // opened database map by server and database index
}), }),
getters: { getters: {
anyConnectionOpened() { anyConnectionOpened() {
return !isEmpty(this.databases) return !isEmpty(this.databases)
}, },
selectedDatabases() {
return this.openedDB || {}
},
}, },
actions: { actions: {
/** /**
@ -139,13 +152,22 @@ const useBrowserStore = defineStore('browser', {
}, },
/** /**
* get database by server name and index * get database info list
* @param {string} connName * @param server
* @return {DatabaseItem[]}
*/
getDBList(server) {
return this.databases[server] || []
},
/**
* get database by server name and database index
* @param {string} server
* @param {number} db * @param {number} db
* @return {DatabaseItem|null} * @return {DatabaseItem|null}
*/ */
getDatabase(connName, db) { getDatabase(server, db) {
const dbs = this.databases[connName] const dbs = this.databases[server]
if (dbs != null) { if (dbs != null) {
const selDB = find(dbs, (item) => item.db === db) const selDB = find(dbs, (item) => item.db === db)
if (selDB != null) { if (selDB != null) {
@ -156,17 +178,24 @@ const useBrowserStore = defineStore('browser', {
}, },
/** /**
* get full loaded status of database * get current selection database by server
* @param connName * @param server
* @param db * @return {number}
* @return {boolean}
*/ */
isFullLoaded(connName, db) { getSelectedDB(server) {
const selDB = this.getDatabase(connName, db) return this.selectedDatabases[server] || 0
if (selDB != null) { },
return selDB.fullLoaded === true
} /**
return false * get key list in current database
* @param server
* @return {DatabaseItem[]}
*/
getKeyList(server) {
const db = this.getSelectedDB(server)
const dbNodes = this.databases[server]
const node = find(dbNodes, (n) => n.db === db)
return node.children
}, },
/** /**
@ -248,6 +277,7 @@ const useBrowserStore = defineStore('browser', {
} }
this.databases[name] = dbs this.databases[name] = dbs
this.viewType[name] = view this.viewType[name] = view
this.openedDB[name] = get(dbs, '0.db', 0)
}, },
/** /**
@ -265,12 +295,11 @@ const useBrowserStore = defineStore('browser', {
const dbs = this.databases[name] const dbs = this.databases[name]
if (!isEmpty(dbs)) { if (!isEmpty(dbs)) {
for (const db of dbs) { for (const db of dbs) {
this.removeKeyFilter(name, db.db)
this._getNodeMap(name, db.db).clear() this._getNodeMap(name, db.db).clear()
this._getKeySet(name, db.db).clear() this._getKeySet(name, db.db).clear()
} }
} }
this.removeKeyFilter(name, -1) delete this.filter[name]
delete this.databases[name] delete this.databases[name]
delete this.serverStats[name] delete this.serverStats[name]
@ -281,32 +310,32 @@ const useBrowserStore = defineStore('browser', {
/** /**
* open database and load all keys * open database and load all keys
* @param connName * @param server
* @param db * @param db
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async openDatabase(connName, db) { async openDatabase(server, db) {
const { match: filterPattern, type: filterType } = this.getKeyFilter(connName, db) const { match: filterPattern, type: filterType } = this.getKeyFilter(server)
const { data, success, msg } = await OpenDatabase(connName, db, filterPattern, filterType) const { data, success, msg } = await OpenDatabase(server, db, filterPattern, filterType)
if (!success) { if (!success) {
throw new Error(msg) throw new Error(msg)
} }
const { keys = [], end = false, maxKeys = 0 } = data const { keys = [], end = false, maxKeys = 0 } = data
const selDB = this.getDatabase(connName, db) const selDB = this.getDatabase(server, db)
if (selDB == null) { if (selDB == null) {
return return
} }
selDB.opened = true selDB.opened = true
selDB.fullLoaded = end
selDB.maxKeys = maxKeys selDB.maxKeys = maxKeys
set(this.loadingState, 'fullLoaded', end)
if (isEmpty(keys)) { if (isEmpty(keys)) {
selDB.children = [] selDB.children = []
} else { } else {
// append db node to current connection's children // append db node to current connection's children
this._addKeyNodes(connName, db, keys) this._addKeyNodes(server, db, keys)
} }
this._tidyNode(connName, db) this._tidyNode(server, db)
}, },
/** /**
@ -325,24 +354,27 @@ const useBrowserStore = defineStore('browser', {
this._getNodeMap(connName, db).clear() this._getNodeMap(connName, db).clear()
this._getKeySet(connName, db).clear() this._getKeySet(connName, db).clear()
delete this.filter[connName]
}, },
/** /**
* close database * close database
* @param connName * @param server
* @param db * @param db
*/ */
closeDatabase(connName, db) { closeDatabase(server, db) {
const selDB = this.getDatabase(connName, db) const selDB = this.getDatabase(server, db)
if (selDB == null) { if (selDB == null) {
return return
} }
delete selDB.children delete selDB.children
selDB.isLeaf = false selDB.isLeaf = false
selDB.opened = false selDB.opened = false
selDB.keys = 0
this._getNodeMap(connName, db).clear() this._getNodeMap(server, db).clear()
this._getKeySet(connName, db).clear() this._getKeySet(server, db).clear()
delete this.filter[server]
}, },
/** /**
@ -547,7 +579,7 @@ const useBrowserStore = defineStore('browser', {
let match = prefix let match = prefix
if (isEmpty(match)) { if (isEmpty(match)) {
match = '*' match = '*'
} else { } else if (!isRedisGlob(match)) {
const separator = this._getSeparator(connName) const separator = this._getSeparator(connName)
if (!endsWith(prefix, separator + '*')) { if (!endsWith(prefix, separator + '*')) {
match = prefix + separator + '*' match = prefix + separator + '*'
@ -563,7 +595,7 @@ const useBrowserStore = defineStore('browser', {
* @return {Promise<boolean>} * @return {Promise<boolean>}
*/ */
async loadMoreKeys(connName, db) { async loadMoreKeys(connName, db) {
const { match, type: keyType } = this.getKeyFilter(connName, db) const { match, type: keyType } = this.getKeyFilter(connName)
const { keys, maxKeys, end } = await this._loadKeys(connName, db, match, keyType, false) const { keys, maxKeys, end } = await this._loadKeys(connName, db, match, keyType, false)
this._setDBMaxKeys(connName, db, maxKeys) this._setDBMaxKeys(connName, db, maxKeys)
// remove current keys below prefix // remove current keys below prefix
@ -1936,41 +1968,32 @@ const useBrowserStore = defineStore('browser', {
/** /**
* get key filter pattern and filter type * get key filter pattern and filter type
* @param {string} server * @param {string} server
* @param {number} db
* @returns {{match: string, type: string}} * @returns {{match: string, type: string}}
*/ */
getKeyFilter(server, db) { getKeyFilter(server) {
let match, type let { pattern = '', type = '' } = this.filter[server] || {}
const key = `${server}#${db}` if (isEmpty(pattern)) {
if (!this.keyFilter.hasOwnProperty(key)) { // no custom match pattern, use default
// get default key filter from connection profile
const conn = useConnectionStore() const conn = useConnectionStore()
match = conn.getDefaultKeyFilter(server) pattern = conn.getDefaultKeyFilter(server)
} else {
match = this.keyFilter[key] || '*'
} }
type = this.typeFilter[`${server}#${db}`] || ''
return { return {
match, match: pattern,
type: toUpper(type), type: toUpper(type),
} }
}, },
/** /**
* set key filter *
* @param {string} server * @param {string} server
* @param {number} db
* @param {string} pattern
* @param {string} [type] * @param {string} [type]
* @param {string} [pattern]
*/ */
setKeyFilter(server, db, pattern, type) { setKeyFilter(server, { type, pattern }) {
this.keyFilter[`${server}#${db}`] = pattern || '*' const filter = this.filter[server] || {}
this.typeFilter[`${server}#${db}`] = types[toUpper(type)] || '' filter.type = type === null ? filter.type : type
}, filter.pattern = type === null ? filter.pattern : pattern
this.filter[server] = filter
removeKeyFilter(server, db) {
this.keyFilter[`${server}#${db}`] = '*'
delete this.typeFilter[`${server}#${db}`]
}, },
}, },
}) })

View File

@ -235,7 +235,7 @@ const useTabStore = defineStore('tab', {
tabData.value = assign(value, tabData.value || {}) tabData.value = assign(value, tabData.value || {})
} }
} else { } else {
tabData.value = value tabData.value = value || []
} }
}, },

View File

@ -25,6 +25,14 @@ body {
overflow: hidden; overflow: hidden;
} }
@mixin bottom-shadow($transparent) {
box-shadow: 0 5px 5px -5px rgba(0, 0, 0, $transparent);
}
@mixin top-shadow($transparent) {
box-shadow: 0 -5px 5px -5px rgba(0, 0, 0, $transparent);
}
#app { #app {
height: 100vh; height: 100vh;
} }
@ -61,13 +69,9 @@ body {
} }
.ellipsis { .ellipsis {
white-space: nowrap; /* 禁止文本换行 */ white-space: nowrap;
overflow: hidden; /* 隐藏超出容器的文本 */ overflow: hidden;
text-overflow: ellipsis; /* 使用省略号表示被截断的文本 */ text-overflow: ellipsis;
}
.unit-item {
margin-left: 10px;
} }
.fill-height { .fill-height {
@ -106,6 +110,7 @@ body {
} }
.value-footer { .value-footer {
@include top-shadow(0.1);
align-items: center; align-items: center;
gap: 0; gap: 0;
padding: 3px 10px 3px 10px; padding: 3px 10px 3px 10px;
@ -130,12 +135,18 @@ body {
.nav-pane-container { .nav-pane-container {
overflow: hidden; overflow: hidden;
.nav-pane-bottom { .nav-pane-func {
align-items: center; align-items: center;
gap: 8px; justify-content: flex-end;
padding: 3px 10px 3px 10px; gap: 3px;
padding: 3px 8px;
min-height: 30px; min-height: 30px;
//border-top: v-bind('themeVars.borderColor') 1px solid;
.nav-pane-func-btn {
padding: 3px;
border-radius: 3px;
box-sizing: border-box;
}
} }
} }

View File

@ -0,0 +1,13 @@
import { includes, isEmpty } from 'lodash'
const REDIS_GLOB_CHAR = ['?', '*', '[', ']', '{', '}']
export const isRedisGlob = (str) => {
if (!isEmpty(str)) {
for (const c of REDIS_GLOB_CHAR) {
if (includes(str, c)) {
return true
}
}
}
return false
}

View File

@ -0,0 +1,20 @@
import { h } from 'vue'
import { NIcon } from 'naive-ui'
export function useRender() {
return {
/**
*
* @param {string|Object} icon
* @param {{}} [props]
* @return {*}
*/
renderIcon: (icon, props = {}) => {
return () => {
return h(NIcon, null, {
default: () => h(icon, props),
})
}
},
}
}