-
-
RedisME
-
v{{ appVersion }}
-
-
{{ t('about.sourceCode') }}
-
-
{{
- t('about.officialWebsite')
- }}
+
+
+
+
RedisME
+
v{{ appVersion }}
+
+
{{ t('about.sourceCode') }}
+
+
{{
+ t('about.officialWebsite')
+ }}
+
+
Copyright © 2025 hepengju.com All Rights Reserved
- Copyright © 2025 hepengju.com All Rights Reserved
-
+
diff --git a/src/views/ext/CommandHelp.vue b/src/views/ext/CommandHelp.vue
new file mode 100644
index 00000000..258abe0c
--- /dev/null
+++ b/src/views/ext/CommandHelp.vue
@@ -0,0 +1,182 @@
+
+
+
+
+
+
+
+
+ {{ item }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.readonly ? t('redisTerminal.readonlyYes') : t('redisTerminal.readonlyNo') }}
+
+
+
+
+
+
+
+
+
diff --git a/src/views/ext/FieldAdd.vue b/src/views/ext/FieldAdd.vue
index 5fc12138..51d2e777 100644
--- a/src/views/ext/FieldAdd.vue
+++ b/src/views/ext/FieldAdd.vue
@@ -73,54 +73,30 @@ const rules = computed(() => ({
) => {
if (!(form.value.ttl === -1 || form.value.ttl > 0)) {
callback(new Error(t('fieldAdd.ttlValidator')))
+ return
}
callback()
},
},
],
value: [
- { required: true, message: t('fieldAdd.valueRequired') },
{
validator: (
_rule: FormItemRule,
value: unknown,
callback: (error?: string | Error) => void,
) => {
+ // string 等类型允许空串;json 类型空串与非法 JSON 均不通过
if (form.value.type === 'json') {
+ if (value === '') {
+ callback(new Error(t('fieldAdd.jsonValidator')))
+ return
+ }
try {
meJsonParse(String(value)) // json 输入支持 json5 格式,此处转换为正常 json 字符串
} catch {
callback(new Error(t('fieldAdd.jsonValidator')))
- }
- }
- callback()
- },
- },
- ],
- fieldValueList: [
- {
- validator: (
- _rule: FormItemRule,
- _value: unknown,
- callback: (error?: string | Error) => void,
- ) => {
- if (form.value.type === 'hash' || form.value.type === 'stream') {
- const count = form.value.fieldValueList.filter(
- d => d.fieldKey === '' || d.fieldValue === '',
- ).length
- if (count > 0) {
- callback(
- new Error(
- form.value.type === 'hash'
- ? t('fieldAdd.hashValidator')
- : t('fieldAdd.streamValidator'),
- ),
- )
- }
- } else {
- const count = form.value.fieldValueList.filter(d => d.fieldValue === '').length
- if (count > 0) {
- callback(new Error(t('fieldAdd.valueRequired')))
+ return
}
}
callback()
@@ -136,7 +112,7 @@ const rules = computed(() => ({
) => {
if (form.value.type === 'stream') {
if (value) return callback()
- callback(new Error(t('fieldAdd.streamIdRequired')))
+ return callback(new Error(t('fieldAdd.streamIdRequired')))
}
callback()
},
@@ -305,10 +281,7 @@ function handleKeyTypeChange() {
-
+
& {
keyWireFmt?: BytesFormat
/** 键级数据编码,用于默认字段 view */
keyViewFmt?: ViewBytesFormat
+ /** Stream 条目 ID */
+ streamId?: string
/** 查看模式:表单只读,隐藏保存 */
readonly?: boolean
}
@@ -37,12 +45,14 @@ const props = withDefaults(
defineProps<{
/** 与 RedisValue 值区美化开关一致,open 时同步为初始状态 */
pretty?: boolean
+ /** 与值页 HTTL 开关一致;关则隐藏 TTL 展示/编辑,保存时由后端保留原有过期 */
+ hashFieldTtlEnabled?: boolean
}>(),
- { pretty: true },
+ { pretty: true, hashFieldTtlEnabled: false },
)
const { t } = useI18n()
-const emit = defineEmits(['success', 'closed'])
+const emit = defineEmits(['success', 'closed', 'refreshed'])
defineExpose({ open, close })
const share = inject(shareProvideKey)!
@@ -59,6 +69,7 @@ const initForm: FieldSetForm = {
fieldValue: '',
fieldScore: 0,
fieldTtl: -1,
+ includeFieldTtl: false,
valFmt: 'utf8',
}
const form = ref
(cloneDeep(initForm))
@@ -66,9 +77,12 @@ const form = ref(cloneDeep(initForm))
/** fieldScan 原始 wire,切换字段编码时始终以此为源 */
const srcFieldWire = ref('')
const keyWireFmt = ref('utf8')
+/** 键级 view 编码,field_get 与值页表格刷新一致 */
+const keyViewFmt = ref('utf8')
const fieldViewFmt = ref('utf8')
const fieldPretty = ref(true)
const editorLoading = ref(false)
+const isRefreshing = ref(false)
const decodeFailed = ref(false)
const codeRemountKey = ref(0)
@@ -77,6 +91,11 @@ const fieldViewOptionList = computed(() => fieldViewOptions(keyWireFmt.value, cu
const prettyEnabled = computed(
() => fieldViewFmt.value === 'utf8' || fieldViewFmt.value === 'strjson',
)
+/** hash/list/zset 支持 field_get 单行刷新 */
+const supportsFieldRefresh = computed(() => {
+ const type = form.value.type
+ return type === 'hash' || type === 'list' || type === 'zset'
+})
/** wire + 字段 view → 编辑区文本 */
async function syncFieldEditor() {
@@ -117,6 +136,7 @@ function open(data: FieldSetOpen) {
Object.assign(form.value, data)
srcFieldWire.value = String(data.srcFieldValue ?? '')
keyWireFmt.value = data.keyWireFmt ?? 'utf8'
+ keyViewFmt.value = data.keyViewFmt ?? 'utf8'
fieldViewFmt.value = defaultFieldViewFmt(data.keyViewFmt ?? 'utf8', keyWireFmt.value)
fieldPretty.value = props.pretty
void syncFieldEditor()
@@ -149,7 +169,6 @@ watch(customNames, names => {
})
const rules = computed(() => ({
- fieldValue: [{ required: true, message: t('fieldSet.fieldValueRequired') }],
fieldScore: [{ required: true, message: t('fieldSet.fieldScoreRequired') }],
}))
@@ -181,7 +200,7 @@ function submit() {
const fmt = fieldViewFmt.value
let fieldValue = form.value.fieldValue
if (needsJsonNormalize(fmt)) {
- fieldValue = meJsonNormal(fieldValue)
+ fieldValue = fieldValue === '' ? '' : meJsonNormal(fieldValue)
}
if (isCustomView(fmt)) {
fieldValue = await meViewToWireAsync(fieldValue, fmt)
@@ -193,17 +212,63 @@ function submit() {
fieldKey: form.value.type === 'hash' && wireFieldKey ? wireFieldKey : form.value.fieldKey,
fieldValue,
valFmt: toWireFormat(fmt),
+ includeFieldTtl: form.value.type === 'hash' ? props.hashFieldTtlEnabled : null,
})
visible.value = false
emit('success')
meOk(t('editOk'))
- } catch (e) {
- meErr(e instanceof Error ? e.message : String(e))
} finally {
isSaving.value = false
}
})
}
+
+function buildFieldGetParam(): RedisFieldGet_Deserialize | null {
+ if (!form.value.key?.key) return null
+ const type = form.value.type
+ if (type !== 'hash' && type !== 'list' && type !== 'zset') return null
+ return {
+ key: form.value.key,
+ fieldIndex: form.value.fieldIndex,
+ fieldKey:
+ type === 'hash' && form.value.wireFieldKey ? form.value.wireFieldKey : form.value.fieldKey,
+ fieldValue: type === 'zset' ? srcFieldWire.value : '',
+ valFmt: toWireFormat(viewFmtForField(keyViewFmt.value)),
+ includeFieldTtl: type === 'hash' ? props.hashFieldTtlEnabled : null,
+ }
+}
+
+function applyFieldGetToForm(data: RedisFieldValue) {
+ const type = form.value.type
+ srcFieldWire.value = data.fieldValue
+ if (type === 'hash') {
+ form.value.fieldKey = data.fieldKey
+ if (props.hashFieldTtlEnabled) {
+ form.value.fieldTtl = data.fieldTtl
+ }
+ } else if (type === 'zset' && data.fieldScore != null) {
+ form.value.fieldScore = data.fieldScore
+ }
+}
+
+async function refreshField() {
+ const conn = share.conn
+ const param = buildFieldGetParam()
+ if (!conn || !param || isRefreshing.value) return
+ isRefreshing.value = true
+ try {
+ const data = await meCommands.fieldGet(conn.id, param, false)
+ applyFieldGetToForm(data)
+ await syncFieldEditor()
+ codeRemountKey.value++
+ emit('refreshed', data)
+ meOk(t('redisValue.refreshFieldRowOk'))
+ } catch (e) {
+ meErr(e instanceof Error ? e.message : String(e))
+ } finally {
+ isRefreshing.value = false
+ }
+}
@@ -217,7 +282,7 @@ function submit() {
+ v-if="form.type === 'hash' && share.capabilities.httlSupported && hashFieldTtlEnabled">
-
+
+
+/** 键 OBJECT 自省弹框:ENCODING / IDLETIME / REFCOUNT / FREQ,表格展示并附编码与不可用原因提示 */
+import { computed, inject, ref } from 'vue'
+import { useI18n } from 'vue-i18n'
+
+import { shareProvideKey } from '@/types/me-interface'
+import type { RedisObjectInfo } from '@/types/tauri-specta'
+import { meCommands, meHumanSeconds } from '@/utils/util'
+
+const { t } = useI18n()
+const share = inject(shareProvideKey)!
+
+const visible = ref(false)
+const loading = ref(false)
+const info = ref(null)
+
+type ObjectRow = {
+ command: string
+ item: string
+ value: string
+ tip?: string
+ unavailable?: boolean
+}
+
+const rows = computed(() => {
+ const data = info.value
+ if (!data) return []
+ const na = t('redisValue.objectInfoNA')
+
+ const idleUnavailable = !!data.idleTimeError
+ const idleValue =
+ data.idleTime !== null
+ ? `${data.idleTime} ${t('timeUnit.second', data.idleTime)} (${meHumanSeconds(data.idleTime)})`
+ : na
+ const idleTip = idleUnavailable
+ ? [t('redisValue.objectIdleTimeUnavailable'), data.idleTimeError].filter(Boolean).join('
')
+ : undefined
+
+ const freqUnavailable = !!data.freqError
+ const freqValue = data.freq !== null ? String(data.freq) : na
+ const freqTip = freqUnavailable
+ ? [t('redisValue.objectFreqUnavailable'), data.freqError].filter(Boolean).join('
')
+ : undefined
+
+ return [
+ {
+ command: 'ENCODING',
+ item: t('redisValue.objectEncoding'),
+ value: data.encoding ?? na,
+ tip: t('redisValue.objectEncodingTip'),
+ },
+ {
+ command: 'IDLETIME',
+ item: t('redisValue.objectIdleTime'),
+ value: idleValue,
+ tip: idleTip,
+ unavailable: idleUnavailable,
+ },
+ {
+ command: 'REFCOUNT',
+ item: t('redisValue.objectRefcount'),
+ value: data.refcount !== null ? String(data.refcount) : na,
+ },
+ {
+ command: 'FREQ',
+ item: t('redisValue.objectFreq'),
+ value: freqValue,
+ tip: freqTip,
+ unavailable: freqUnavailable,
+ },
+ ]
+})
+
+async function open() {
+ const conn = share.conn
+ const rk = share.redisKey
+ if (!conn || !rk) return
+ visible.value = true
+ loading.value = true
+ info.value = null
+ try {
+ info.value = await meCommands.objectInfo(conn.id, rk)
+ } finally {
+ loading.value = false
+ }
+}
+
+defineExpose({ open })
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.item }}
+
+
+
+
+
+ {{ row.value }}
+
+
+
+
+
diff --git a/src/views/ext/Official.vue b/src/views/ext/Official.vue
index bba713f7..67c73f19 100644
--- a/src/views/ext/Official.vue
+++ b/src/views/ext/Official.vue
@@ -1,13 +1,31 @@
-
-
-
-
+
+
+
+
+
+
diff --git a/src/views/ext/TableZsetRange.vue b/src/views/ext/TableZsetRange.vue
new file mode 100644
index 00000000..898d89b9
--- /dev/null
+++ b/src/views/ext/TableZsetRange.vue
@@ -0,0 +1,158 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/views/ext/ValueShortcut.vue b/src/views/ext/ValueShortcut.vue
new file mode 100644
index 00000000..c899b44d
--- /dev/null
+++ b/src/views/ext/ValueShortcut.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
diff --git a/src/views/key/KeyBatch.vue b/src/views/key/KeyBatch.vue
index f68f08c7..be7ee05e 100644
--- a/src/views/key/KeyBatch.vue
+++ b/src/views/key/KeyBatch.vue
@@ -105,7 +105,7 @@ const showScan = ref(true)
async function scanKey() {
loading.value = true
try {
- const params = { match: form.value.match, type: '', count: 0, loadAll: true, cursor: null }
+ const params = { match: form.value.match, type: '', cursor: null, exact: false }
const data = await meCommands.scan(share.conn!.id, params)
form.value.keyList = data.keyList
showScan.value = false
@@ -190,7 +190,7 @@ const exportFormatTip = computed(() =>
-
+
{{ item.data.key }}
diff --git a/src/views/key/KeyTree.vue b/src/views/key/KeyTree.vue
index b075fa9e..31d7e71a 100644
--- a/src/views/key/KeyTree.vue
+++ b/src/views/key/KeyTree.vue
@@ -382,6 +382,7 @@ const isContextNodeFavorited = computed(() => {
:item-size="keyHeight"
:show-checkbox="showCheckbox">
+
-
+
{
@click.stop="emit('contextKey', 'unfavoriteKey', node.data.redisKey)" />
-
+
-
- [ {{ node.data.keyCount }} ]
-
+
{{ node.label }}
+
[ {{ node.data.keyCount }} ]
@@ -530,6 +530,11 @@ const isContextNodeFavorited = computed(() => {
background-color: var(--el-color-info-light-8);
}
+/* 自定义节点可收缩,长名才能 ellipsis,右侧数量/图标不被挤出 */
+:deep(.el-tree-node__content) {
+ overflow: hidden;
+}
+
/* 右键选中的键 */
:deep(.context-key) {
outline: 1px dashed var(--el-color-primary);
@@ -541,9 +546,13 @@ const isContextNodeFavorited = computed(() => {
margin-left: -20px;
}
-.key-leaf-row {
- width: 100%;
+/* 占满 content 剩余宽度(勿用 width:100%,会和展开图标叠宽溢出) */
+.key-leaf-row,
+.folder-row {
+ flex: 1;
+ min-width: 0;
align-items: center;
+ overflow: hidden;
}
.key-leaf-main {
@@ -551,23 +560,54 @@ const isContextNodeFavorited = computed(() => {
min-width: 0;
align-items: center;
justify-content: flex-start;
+ overflow: hidden;
}
-.key-leaf-label {
+.key-leaf-main :deep(.el-tag) {
+ flex-shrink: 0;
+}
+
+.key-leaf-label,
+.folder-label {
flex: 1;
min-width: 0;
- margin-left: 5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+.key-leaf-label {
+ margin-left: 5px;
+}
+
+.folder-icon {
+ flex-shrink: 0;
+}
+
+.folder-label {
+ margin: 0 5px;
+}
+
+/* 数量区固定右侧;操作图标右缘大致对齐到 [ n ] 的最后一个数字 */
+.folder-count {
+ flex-shrink: 0;
+ margin-right: 10px;
+ color: var(--el-color-info);
+}
+
+.key-leaf-actions {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ margin-right: 15px;
+}
+
/* 删除图标:hover 行时显示 */
:deep(.el-tree-node__content:hover) .key-delete-btn {
visibility: visible;
}
-/* 删除图标右缘与文件夹 [数量] 末位数字对齐(略大于数量块的 margin-right) */
.key-delete-btn {
flex-shrink: 0;
visibility: hidden;
diff --git a/src/views/tab/RedisChart.vue b/src/views/tab/RedisChart.vue
index 03b3e410..cd08bbb6 100644
--- a/src/views/tab/RedisChart.vue
+++ b/src/views/tab/RedisChart.vue
@@ -16,10 +16,12 @@ import { cloneDeep, merge } from 'lodash'
import {
computed,
inject,
+ nextTick,
onMounted,
onUnmounted,
ref,
shallowRef,
+ unref,
useTemplateRef,
watch,
} from 'vue'
@@ -86,8 +88,8 @@ watch(
)
// 图表实例,手动刷新
-/** vue-chartjs Line 暴露的 chart 实例(避免 useTemplateRef 推断为 never) */
-type LineChartExposed = { chart: { update: () => void } }
+/** vue-chartjs Line 经 reforwardRef 暴露的 chart(可能是 Ref,需 unref) */
+type LineChartExposed = { chart: unknown }
const commandRef = useTemplateRef
('command')
const memoryRef = useTemplateRef('memory')
const networkRef = useTemplateRef('network')
@@ -98,16 +100,21 @@ const totalConnectionsReceivedRef = useTemplateRef('totalConne
const totalCommandsProcessedRef = useTemplateRef('totalCommandsProcessed')
const chartsRoot = useTemplateRef('charts')
+function updateLineChart(lineRef: LineChartExposed | null | undefined) {
+ const chart = unref(lineRef?.chart) as { update?: () => void } | null | undefined
+ chart?.update?.()
+}
+
function refreshInstance() {
- commandRef.value?.chart.update()
- memoryRef.value?.chart.update()
- networkRef.value?.chart.update()
-
- keyTotalRef.value?.chart.update()
- connectedClientsRef.value?.chart.update()
- cacheHitRatioRef.value?.chart.update()
- totalConnectionsReceivedRef.value?.chart.update()
- totalCommandsProcessedRef.value?.chart.update()
+ updateLineChart(commandRef.value)
+ updateLineChart(memoryRef.value)
+ updateLineChart(networkRef.value)
+
+ updateLineChart(keyTotalRef.value)
+ updateLineChart(connectedClientsRef.value)
+ updateLineChart(cacheHitRatioRef.value)
+ updateLineChart(totalConnectionsReceivedRef.value)
+ updateLineChart(totalCommandsProcessedRef.value)
}
let chartsFullscreenWasOurs = false
@@ -145,6 +152,12 @@ function onChartsFullscreenChange() {
onMounted(() => {
window.addEventListener('keydown', onChartsFullscreenKey, true)
document.addEventListener('fullscreenchange', onChartsFullscreenChange)
+ // 等 Line 子组件 onMounted 创建 chart 后再拉数并 refresh(setup 阶段 chart 仍为 null)
+ void nextTick(() => {
+ if (!share.conn?.cluster) {
+ void getData()
+ }
+ })
})
onUnmounted(() => {
window.removeEventListener('keydown', onChartsFullscreenKey, true)
@@ -154,11 +167,6 @@ onUnmounted(() => {
}
})
-// 单机节点上来就获取1次数据(注: 集群节点由于watch node的存在上来会自动获取1次数据)
-if (!share.conn?.cluster) {
- getData()
-}
-
// 从后台获取原始数据
async function getData() {
try {
@@ -186,10 +194,10 @@ async function getData() {
cutChartData(indexes, 'cacheHitRatio')
cutChartData(indexes, 'totalConnectionsReceived')
cutChartData(indexes, 'totalCommandsProcessed')
- chartData.value = cloneDeep(chartData.value) // 直接更新时图表没有重新渲染,因此克隆1份,让vue进行重新渲染
- nowPointCount.value = chartData.value.command.labels.length
}
- refreshInstance()
+ // shallowRef 原地 push 不会触发 vue-chartjs,克隆后 :data 引用变化才能重绘
+ chartData.value = cloneDeep(chartData.value)
+ nowPointCount.value = chartData.value.command.labels.length
} catch (e: unknown) {
meLog('get chart data error', e)
}
diff --git a/src/views/tab/RedisClient.vue b/src/views/tab/RedisClient.vue
index c0743333..f2316bf6 100644
--- a/src/views/tab/RedisClient.vue
+++ b/src/views/tab/RedisClient.vue
@@ -63,6 +63,7 @@ async function killClient(row: RedisClientListRow) {
command: `client kill ${row.addr}`,
node: node.value,
autoBroadcast: null,
+ outputMode: null,
}
await meCommands.executeCommand(share.conn!.id, param)
meOk(t('redisClient.killClientOk'))
diff --git a/src/views/tab/RedisTauri.vue b/src/views/tab/RedisTauri.vue
index dfc41468..3a1c08b2 100644
--- a/src/views/tab/RedisTauri.vue
+++ b/src/views/tab/RedisTauri.vue
@@ -11,8 +11,12 @@ import type {
RedisCommand,
RedisExportCsv_Deserialize,
RedisFieldAdd_Deserialize,
+ RedisFieldAsCommand_Deserialize,
RedisFieldDel_Deserialize,
+ RedisFieldGet_Deserialize,
RedisFieldSet_Deserialize,
+ RedisHashKeys_Deserialize,
+ RedisPop_Deserialize,
RedisImportCsv,
RedisKey_Deserialize,
RedisMemoryParam,
@@ -75,18 +79,26 @@ const emptyScanCursor: ScanCursor = {
finished: false,
}
-const minimalScanParam: ScanParam = { match: '*', type: null, cursor: emptyScanCursor }
+const minimalScanParam: ScanParam = {
+ match: '*',
+ type: null,
+ cursor: emptyScanCursor,
+ exact: false,
+}
const dummyKey: RedisKey_Deserialize = { key: 'k', bytes: '' }
const minimalFieldScan: FieldScanParam_Deserialize = {
key: dummyKey,
- hashKey: null,
count: 100,
cursor: null,
- loadAll: false,
+ match: '*',
+ exact: false,
meta: null,
bytesFormat: null,
+ includeMeta: null,
+ keyType: null,
+ includeFieldTtl: null,
}
const minimalSetParam: RedisSetParam_Deserialize = {
@@ -118,9 +130,23 @@ const minimalFieldSet: RedisFieldSet_Deserialize = {
fieldValue: '',
fieldScore: 0,
fieldTtl: -1,
+ includeFieldTtl: false,
+ valFmt: null,
+}
+
+const minimalFieldGet: RedisFieldGet_Deserialize = {
+ key: dummyKey,
+ fieldIndex: 0,
+ fieldKey: '',
+ fieldValue: '',
+ includeFieldTtl: false,
valFmt: null,
}
+const minimalHashKeys: RedisHashKeys_Deserialize = { key: dummyKey, valFmt: null }
+
+const minimalFieldPop: RedisPop_Deserialize = { key: dummyKey, mode: 'LPOP', valFmt: null }
+
const minimalFieldDel: RedisFieldDel_Deserialize = {
key: dummyKey,
fieldIndex: 0,
@@ -130,7 +156,21 @@ const minimalFieldDel: RedisFieldDel_Deserialize = {
valFmt: null,
}
-const minimalRedisCmd: RedisCommand = { command: 'PING', node: null, autoBroadcast: null }
+const minimalFieldAsCommand: RedisFieldAsCommand_Deserialize = {
+ key: dummyKey,
+ fieldIndex: 0,
+ fieldKey: '',
+ fieldValue: '',
+ streamId: '',
+ valFmt: null,
+}
+
+const minimalRedisCmd: RedisCommand = {
+ command: 'PING',
+ node: null,
+ autoBroadcast: null,
+ outputMode: null,
+}
const minimalMemoryParam: RedisMemoryParam = {
match: null,
@@ -205,8 +245,18 @@ function defaultPayload(cmd: CommandKey): Record {
return { id: connIdForDefaults(), param: { ...minimalFieldAdd } }
case 'fieldSet':
return { id: connIdForDefaults(), param: { ...minimalFieldSet } }
+ case 'fieldGet':
+ return { id: connIdForDefaults(), param: { ...minimalFieldGet } }
+ case 'hashKeys':
+ return { id: connIdForDefaults(), param: { ...minimalHashKeys } }
+ case 'hashValues':
+ return { id: connIdForDefaults(), param: { ...minimalHashKeys } }
+ case 'fieldPop':
+ return { id: connIdForDefaults(), param: { ...minimalFieldPop } }
case 'fieldDel':
return { id: connIdForDefaults(), param: { ...minimalFieldDel } }
+ case 'getFieldAsCommand':
+ return { id: connIdForDefaults(), param: { ...minimalFieldAsCommand } }
case 'memoryUsage':
return { id: connIdForDefaults(), param: { ...minimalMemoryParam } }
case 'batchDel':
diff --git a/src/views/tab/RedisTerminal.vue b/src/views/tab/RedisTerminal.vue
index 8e11ef8d..0b8a0664 100644
--- a/src/views/tab/RedisTerminal.vue
+++ b/src/views/tab/RedisTerminal.vue
@@ -6,9 +6,11 @@ import MeIcon from '@/components/MeIcon.vue'
import MeShortcut from '@/components/MeShortcut.vue'
import { commandHelp, isReadonlyCommand } from '@/locales/cmd'
import { shareProvideKey } from '@/types/me-interface'
+import type { CliOutputMode } from '@/types/tauri-specta'
import { getTerminalShortcuts } from '@/utils/shortcut'
import { meCopy, meCommands, isZh } from '@/utils/util'
+import CommandHelp from '../ext/CommandHelp.vue'
import NodeList from '../ext/NodeList.vue'
const { t } = useI18n()
@@ -18,6 +20,15 @@ const canEdit = computed(() => !share.readonly)
/** 只读列表头:英文 Read-only 较宽,中文只读可窄一些 */
const readonlyColWidth = computed(() => (isZh.value ? 88 : 120))
+/** 终端输出格式(对齐 redis-cli --raw / --json / --csv;每次进入默认 TTY) */
+const outputModeOptions: { value: CliOutputMode; labelKey: string }[] = [
+ { value: 'standard', labelKey: 'redisTerminal.outputStandard' },
+ { value: 'raw', labelKey: 'redisTerminal.outputRaw' },
+ { value: 'json', labelKey: 'redisTerminal.outputJson' },
+ { value: 'csv', labelKey: 'redisTerminal.outputCsv' },
+]
+const outputMode = ref('standard')
+
// 待颜色的文本
function colorText(color: string, text: string, bold = false): string {
return bold
@@ -32,6 +43,15 @@ const welcome = computed(() =>
t('redisTerminal.welcome', { RedisME: colorText(share.color, 'RedisME', true) }),
)
+// 成功输出统一绿色;raw 空结果保留空行占位
+function formatCommandResult(data: string): string {
+ if (outputMode.value === 'raw' && data === '') {
+ return colorText('var(--el-color-success)', '
')
+ }
+ const html = data.split(/\r?\n/).join('
')
+ return colorText('var(--el-color-success)', html)
+}
+
// 定制化执行命令
async function execCommand(command: string): Promise {
if (!canEdit.value && !isReadonlyCommand(command)) {
@@ -39,11 +59,15 @@ async function execCommand(command: string): Promise {
}
try {
- const param = { command, node: node.value, autoBroadcast: autoBroadcast.value }
+ const param = {
+ command,
+ node: node.value,
+ autoBroadcast: autoBroadcast.value,
+ outputMode: outputMode.value,
+ }
const data = await meCommands.executeCommand(share.conn!.id, param, false)
autoCopyIfNeed(data)
- const html = data.split(/\r?\n/).join('
')
- return colorText('var(--el-color-success)', html)
+ return formatCommandResult(data)
} catch (e: unknown) {
autoCopyIfNeed(e)
return colorText('var(--el-color-error)', `(error) ${String(e)}`)
@@ -68,48 +92,10 @@ watch(commandHelp, () => {
})
})
-// 表格Redis命令的帮助手册显示
-const visible = ref(false)
-const keyword = ref('')
-const group = ref('')
-const groupList = computed(() => new Set(commandHelp.value.map(row => row.group)))
-const tableKey = ref(0)
-/** 列头筛选(MeTable 分页前先过滤全量数据,列上 filter-method 仅保留 UI) */
-const activeFilters = ref>({})
-const sinceFilters = computed(() =>
- [...new Set(commandHelp.value.map(row => row.since))]
- .sort()
- .map(value => ({ text: value, value })),
-)
-const readonlyFilters = computed(() => [
- { text: t('redisTerminal.readonlyYes'), value: true },
- { text: t('redisTerminal.readonlyNo'), value: false },
-])
-const filterDataList = computed(() => {
- let rows = commandHelp.value
- const key = keyword.value.toLowerCase().trim()
- if (group.value) rows = rows.filter(row => row.group === group.value)
- if (key) {
- rows = rows.filter(
- row => row.title.toLowerCase().includes(key) || row.summary.toLowerCase().includes(key),
- )
- }
- const sinces = activeFilters.value.since as string[] | undefined
- if (sinces?.length) rows = rows.filter(row => sinces.includes(row.since))
- const readonlys = activeFilters.value.readonly as boolean[] | undefined
- if (readonlys?.length) rows = rows.filter(row => readonlys.includes(!!row.readonly))
- return rows
-})
-function onFilterChange(filters: Record) {
- // EP 每次只回传当前列,需合并保留其它列已选条件
- activeFilters.value = { ...activeFilters.value, ...filters }
-}
+// 命令帮助弹窗
+const commandHelpRef = ref>()
function openCommandDialog() {
- keyword.value = ''
- group.value = ''
- activeFilters.value = {}
- tableKey.value++
- visible.value = true
+ commandHelpRef.value?.open()
}
const keyShortVisible = ref(false)
@@ -144,8 +130,17 @@ const keyShortcuts = computed(() => getTerminalShortcuts(t))
@@ -281,25 +200,7 @@ const keyShortcuts = computed(() => getTerminalShortcuts(t))
right: 10px;
bottom: 0;
z-index: 10;
- }
-}
-
-
-
-
diff --git a/src/views/tab/RedisValue.vue b/src/views/tab/RedisValue.vue
index 0b298c9a..1c97967e 100644
--- a/src/views/tab/RedisValue.vue
+++ b/src/views/tab/RedisValue.vue
@@ -5,6 +5,7 @@
*/
// #region 导入
import dayjs from 'dayjs'
+import { minimatch } from 'minimatch'
import {
computed,
inject,
@@ -18,14 +19,16 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
-import MeShortcut from '@/components/MeShortcut.vue'
import { shareProvideKey, connUiProvideKey } from '@/types/me-interface'
import type {
FieldScanResult,
+ RedisFieldAsCommand_Deserialize,
RedisFieldDel_Deserialize,
+ RedisFieldGet_Deserialize,
+ RedisFieldValue,
RedisKey_Deserialize,
+ RedisZsetRankResult,
ScanCursor,
- XInfoGroup,
} from '@/types/tauri-specta'
import { useFavorites, addFavorite, removeFavorite, isFavorited } from '@/utils/favorite'
import {
@@ -44,33 +47,47 @@ import {
viewFmtForField,
type ViewBytesFormat,
} from '@/utils/format'
-import { getValueShortcuts } from '@/utils/shortcut'
+import {
+ buildScanPattern,
+ buildLocalFilterPattern,
+ computeScanProgress,
+ MINIMATCH_SCAN_OPTS,
+} from '@/utils/redis-glob'
import {
bus,
KEY_DELETE,
KEY_REFRESH,
meCommands,
+ meConfirm,
meCopy,
meDeleteKey,
meErr,
meHumanSeconds,
+ estimateStringMemory,
meHumanSize,
meFormatDisplayValue,
meJsonNormal,
meOk,
meWarn,
+ sleep,
} from '@/utils/util'
+import ObjectInfo from '@/views/ext/ObjectInfo.vue'
import TableGroup from '@/views/ext/TableGroup.vue'
+import TableHashKeys from '@/views/ext/TableHashKeys.vue'
+import TableZsetRange from '@/views/ext/TableZsetRange.vue'
import TTLSet from '@/views/ext/TTLSet.vue'
+import ValueShortcut from '@/views/ext/ValueShortcut.vue'
import KeyRename from '@/views/key/KeyRename.vue'
+import CommandHelp from '../ext/CommandHelp.vue'
import CustomCodec from '../ext/CustomCodec.vue'
import FieldAdd from '../ext/FieldAdd.vue'
import FieldSet from '../ext/FieldSet.vue'
// #endregion
// #region 类型与本地工具
-type FieldScanViewState = FieldScanResult & { newValue: string }
+/** newValue:null 未编辑,'' 表示用户主动保存空串 */
+type FieldScanViewState = FieldScanResult & { newValue: string | null }
/** fieldScan 的 `value` 在 Specta 中为 serde 联合类型,表格/拼接按行数组处理 */
function fieldValueRows(v: unknown): unknown[] {
@@ -78,7 +95,7 @@ function fieldValueRows(v: unknown): unknown[] {
}
function toViewState(data: FieldScanResult): FieldScanViewState {
- return { ...data, newValue: '' }
+ return { ...data, newValue: null }
}
/** 值表格行(fieldScan 各类型字段混合) */
@@ -88,6 +105,8 @@ type ValueTableRow = Record & {
id?: string
score?: number
ttl?: number
+ /** List 行的真实 Redis 索引(后端 fieldScan 返回) */
+ index?: number
}
// #endregion
@@ -96,34 +115,143 @@ const { t } = useI18n()
const share = inject(shareProvideKey)!
const connUi = inject(connUiProvideKey)!
const canEdit = computed(() => !share.readonly)
-const canSave = computed(() => canEdit.value && (stringType.value || jsonType.value))
// #endregion
// #region 核心状态(fieldScan 结果 / 游标 / 编辑)
const redisValue = ref(null)
const cursor = ref(null) // list/hash/set/zset/stream 分页游标
const loading = ref(false)
-const hashKey = ref('') // hash 子键 / stream 起始 ID
-const withHashKey = ref(false) // 是否处于「单字段」模式(hashKey 非空)
const isPretty = ref(true)
-const tableKeyword = ref('')
+/** 表格工具栏关键词:Hash/Set/ZSet 兼扫描参数与本地过滤,List/Stream 仅本地过滤 */
+const fieldKeyword = ref('')
+const fieldExact = ref(false)
+const fieldMatch = computed(() => buildScanPattern(fieldKeyword.value, fieldExact.value))
+const scanCancelled = ref(false)
+const scanPaused = ref(false)
+const scanLoadAll = ref(false)
+const scanBatchCount = ref(0)
+const SCAN_CONTROL_MIN_BATCHES = 10
+const showScanControl = computed(() => {
+ const type = redisValue.value?.type
+ if (!supportsTableView(type)) return false
+ return scanPaused.value || (loading.value && scanBatchCount.value >= SCAN_CONTROL_MIN_BATCHES)
+})
+const showFieldExactCheckbox = computed(() => supportsFieldServerScan(redisValue.value?.type))
+const fieldScanInputPlaceholder = computed(() => {
+ const type = redisValue.value?.type
+ if (type === 'list' || type === 'stream') {
+ return t('redisValue.listStreamFilterPlaceholder')
+ }
+ return t('redisValue.fieldScanPlaceholder')
+})
+const scanToggleTip = computed(() =>
+ loading.value ? t('keyMain.pauseScan') : t('keyMain.resumeScan'),
+)
+const FIELD_SCAN_FETCH_COUNT = computed(() => meTauri.settings.fieldScanCount as number)
+/** 进度环估算:与 settings.fieldScanCount 一致,不用键扫描的 scan_0_batch_count */
+const scanBatchSize = computed(() => FIELD_SCAN_FETCH_COUNT.value)
+const scanProgress = computed(() =>
+ computeScanProgress(
+ scanBatchCount.value,
+ scanBatchSize.value,
+ redisValue.value?.length ?? 0,
+ Boolean(cursor.value?.finished),
+ ),
+)
const suppressCodeUpdate = ref(false)
/** fieldScan 成功后递增,强制 me-code 与服务器同步(未保存时 modelValue 字符串可能不变) */
const valueEditorRemountKey = ref(0)
/** 手动控制「加载更多」按钮,避免 cursor 变化导致按钮闪现 */
const showMore = ref(false)
+/** STRING 全量加载阈值与预览长度,从 settings 读取 */
+const VALUE_BYTE_LIMIT = computed(
+ () => ((window.meTauri.settings.valueByteLimitMB as number) ?? 1) * 1024 * 1024,
+)
+const VALUE_PREVIEW_BYTES = computed(
+ () => (window.meTauri.settings.valuePreviewBytes as number) ?? 2000,
+)
+/** 用户确认「仍要加载全部」后为 true,fieldScan 走 GET 全量 */
+const forceFullValue = ref(false)
+const valueTruncatedDismissed = ref(false)
+const valueTruncated = computed(() => redisValue.value?.valueTruncated ?? false)
+const showValueTruncatedAlert = computed(
+ () => stringType.value && valueTruncated.value && !valueTruncatedDismissed.value,
+)
+
/** Stream 扫描范围(meta 传给 fieldScan) */
const meta = ref({ maxId: '', minId: '' })
+/** List 扫描范围与方向(经 meta 传给 fieldScan) */
+const listIndexMin = ref('')
+const listIndexMax = ref('')
+/** true=升序扫描;false=降序 */
+const listDescAsc = ref(true)
+/** Stream 扫描方向:true=升序(XRANGE),false=降序(XREVRANGE) */
+const streamDescAsc = ref(true)
+
+function parseListIndexInput(raw: string): number | null {
+ const s = raw.trim()
+ if (!s) return null
+ const n = Number.parseInt(s, 10)
+ return Number.isFinite(n) ? n : null
+}
+
+function listRowRedisIndex(row: ValueTableRow): number {
+ return typeof row.index === 'number' ? row.index : -1
+}
+
+function toggleListSortOrder() {
+ listDescAsc.value = !listDescAsc.value
+ void restartFieldScan()
+}
+
+function toggleStreamSortOrder() {
+ streamDescAsc.value = !streamDescAsc.value
+ void restartFieldScan()
+}
+
+/** List LPOP/RPOP / Set SPOP / ZSet ZPOPMIN/ZPOPMAX:统一走 field_pop API */
+async function runFieldPop(mode: string) {
+ const conn = share.conn
+ const key = share.redisKey
+ if (!conn || !key || !canEdit.value) return
+ const data = await meCommands.fieldPop(conn.id, {
+ key,
+ mode,
+ valFmt: toWireFormat(viewFmtForField(bytesFormat.value)),
+ })
+ meOk(data)
+ await restartFieldScan()
+}
+
+function onPopCommand(command: string) {
+ const confirmMap: Record = {
+ LPOP: 'redisValue.listLpopConfirm',
+ RPOP: 'redisValue.listRpopConfirm',
+ SPOP: 'redisValue.setPopConfirm',
+ ZPOPMIN: 'redisValue.zpopMinConfirm',
+ ZPOPMAX: 'redisValue.zpopMaxConfirm',
+ }
+ meConfirm(t(confirmMap[command]), () => runFieldPop(command))
+}
// #endregion
// #region 键类型(派生)
-const hashType = computed(() => 'hash' === redisValue.value?.type)
const stringType = computed(() => 'string' === redisValue.value?.type)
const jsonType = computed(() => 'json' === redisValue.value?.type)
const streamType = computed(() => 'stream' === redisValue.value?.type)
-const stringTypeOrWithHashKey = computed(
- () => 'string' === redisValue.value?.type || withHashKey.value,
+const hashType = computed(() => 'hash' === redisValue.value?.type)
+const listType = computed(() => 'list' === redisValue.value?.type)
+const setType = computed(() => 'set' === redisValue.value?.type)
+const zsetType = computed(() => 'zset' === redisValue.value?.type)
+/** 服务端支持 HTTL 时,可选是否在 fieldScan 中拉取 Hash 字段 TTL */
+const scanHashFieldTtl = ref(false)
+const showHashFieldTtlOption = computed(() => hashType.value && share.capabilities.httlSupported)
+const canSave = computed(
+ () =>
+ canEdit.value &&
+ (stringType.value || jsonType.value) &&
+ !(valueTruncated.value && !forceFullValue.value),
)
// #endregion
@@ -132,6 +260,38 @@ type FieldViewType = 'json' | 'table'
const viewTypeList: FieldViewType[] = ['json', 'table']
const viewType = ref('json')
+function supportsFieldServerScan(type: string | undefined) {
+ return type === 'hash' || type === 'set' || type === 'zset'
+}
+
+function pauseFieldScan() {
+ scanCancelled.value = true
+ scanPaused.value = true
+}
+
+function onFieldScanAction() {
+ if (loading.value) pauseFieldScan()
+ else if (scanPaused.value) {
+ scanPaused.value = false
+ void refreshKey(false, true, scanLoadAll.value, false)
+ }
+}
+
+/** Enter / 搜索图标:保留 keyword,中断进行中的扫描后重扫(无 F5 快捷键) */
+function restartFieldScan() {
+ return refreshKey(false, false, false, true)
+}
+
+async function onFieldSearch() {
+ await restartFieldScan()
+}
+
+/** 菜单 / 底部按钮手动刷新:保留 fieldKeyword,可 restart 中断扫描 */
+function manualRefreshKey() {
+ prepareManualKeyRefresh()
+ return restartFieldScan()
+}
+
/** 支持表格视图的类型(与底部 segmented 可见条件一致) */
function supportsTableView(type: string | undefined) {
return (
@@ -139,10 +299,15 @@ function supportsTableView(type: string | undefined) {
)
}
+/** field_get 可单行刷新的表格类型 */
+function supportsFieldRowRefresh(type: string | undefined) {
+ return type === 'hash' || type === 'list' || type === 'zset'
+}
+
/** 切换键或 reset 时,按 settings.fieldShow 决定默认视图 */
function applyDefaultViewType() {
const rv = redisValue.value
- if (!rv || stringTypeOrWithHashKey.value || jsonType.value) {
+ if (!rv || stringType.value || jsonType.value) {
viewType.value = 'json'
return
}
@@ -168,7 +333,7 @@ function onViewTypeChange(val: string | number | boolean) {
// string / json 仅支持 JSON 视图,强制切回
watchEffect(() => {
- if (stringTypeOrWithHashKey.value || jsonType.value) {
+ if (stringType.value || jsonType.value) {
viewType.value = 'json'
}
})
@@ -214,7 +379,7 @@ const formatOptions = computed(() => {
const viewDecodeFailed = computed(() => {
if (!stringType.value) return false
const fmt = displayBytesFormat.value
- if (fmt === 'utf8' || fmt === 'hex' || fmt === 'base64') return false
+ if (fmt === 'utf8' || fmt === 'hex' || fmt === 'binary' || fmt === 'base64') return false
const wire = displayWire.value
if (!wire) return false
if (isCustomView(fmt)) return customCodecFailed.value
@@ -259,7 +424,7 @@ function syncDisplaySnapshot() {
}
async function refreshResolvedWireView() {
- if (!stringTypeOrWithHashKey.value || !isCustomView(displayBytesFormat.value)) {
+ if (!stringType.value || !isCustomView(displayBytesFormat.value)) {
resolvedWireView.value = ''
customCodecFailed.value = false
return
@@ -296,7 +461,7 @@ const showValue = computed(() => {
if (obj === null || obj === undefined) return ''
if (isPretty.value) {
- if (stringTypeOrWithHashKey.value) {
+ if (stringType.value) {
const str = stringWireDisplayText(displayWire.value)
return meFormatDisplayValue(str, isPretty.value)
}
@@ -304,14 +469,14 @@ const showValue = computed(() => {
}
if (
- ('hash' === redisValue.value?.type && !withHashKey.value) ||
+ 'hash' === redisValue.value?.type ||
'zset' === redisValue.value?.type ||
'json' === redisValue.value?.type ||
'stream' === redisValue.value?.type
) {
return JSON.stringify(obj)
}
- if (stringTypeOrWithHashKey.value) {
+ if (stringType.value) {
return stringWireDisplayText(displayWire.value)
}
return obj.toString()
@@ -322,6 +487,13 @@ function onCodeUpdate(newValue: string) {
if (suppressCodeUpdate.value || !redisValue.value) return
redisValue.value.newValue = newValue
}
+
+/** 值区有未保存修改(含改为空串;null 表示未编辑) */
+const valueDirty = computed(() => {
+ const rv = redisValue.value
+ if (!rv || rv.newValue === null) return false
+ return rv.newValue !== showValue.value
+})
// #endregion
// #region 表格行数据与筛选
@@ -330,16 +502,16 @@ const dataList = computed(() => {
if (rv === null || rv === undefined || rv.value === null || rv.value === undefined) return []
const data: ValueTableRow[] = []
- if (rv.type === 'list' || rv.type === 'set') {
- fieldValueRows(rv.value).forEach(value => data.push({ value }))
- } else if (rv.type === 'zset' || rv.type === 'stream' || rv.type === 'hash') {
- fieldValueRows(rv.value).forEach(value => data.push(value as ValueTableRow))
- }
+ fieldValueRows(rv.value).forEach(value => {
+ // set 为裸字符串;list/hash/zset/stream 已是对象(list 含 index)
+ if (rv.type === 'set') data.push({ value })
+ else data.push(value as ValueTableRow)
+ })
return data
})
const filterDataList = computed(() => {
- const key = tableKeyword.value.toLowerCase()
+ const key = fieldKeyword.value.toLowerCase()
return dataList.value.filter(row => {
if (!key) return true
if ((formatTableCell(row.key).toLowerCase() ?? '').indexOf(key) > -1) return true
@@ -347,46 +519,132 @@ const filterDataList = computed(() => {
const cell = streamType.value ? JSON.stringify(row.value) : formatTableCell(row.value)
if (cell.toLowerCase().indexOf(key) > -1) return true
if ((row.score?.toString() ?? '').indexOf(key) > -1) return true
+ if (String(row.index ?? '').indexOf(key) > -1) return true
return false
})
})
+
+/** 切换 exact 未 Enter 时本地 minimatch(与 KeyMain filterKeyList 一致) */
+const filterFieldPattern = computed(() =>
+ buildLocalFilterPattern(fieldKeyword.value, fieldExact.value, fieldMatch.value),
+)
+
+const filterFieldList = computed(() => {
+ if (!filterFieldPattern.value) return dataList.value
+ return dataList.value.filter(row => {
+ const name = row.key ? formatTableCell(row.key) : formatTableCell(row.value)
+ return minimatch(name, filterFieldPattern.value, MINIMATCH_SCAN_OPTS)
+ })
+})
+
+const tableDisplayList = computed(() => {
+ const type = redisValue.value?.type
+ if (type === 'hash' || type === 'set' || type === 'zset') return filterFieldList.value
+ return filterDataList.value
+})
+
+/** 值表各类型默认排序列(与可见 sortable 列 prop 一致);List 不设 default-sort,保持 fieldScan 返回顺序(含升/降序扫描) */
+const tableDefaultSort = computed(
+ (): { prop: string; order: 'ascending' | 'descending' } | undefined => {
+ switch (redisValue.value?.type) {
+ case 'hash':
+ return { prop: 'key', order: 'ascending' }
+ case 'zset':
+ return { prop: 'score', order: 'ascending' }
+ case 'set':
+ return { prop: 'value', order: 'ascending' }
+ default:
+ return undefined
+ }
+ },
+)
// #endregion
// #region 键刷新 fieldScan
-/** 切换键或全量刷新时清空 hash 子键、表格筛选等 UI 状态 */
+/** 切换键或全量刷新时清空表格筛选等 UI 状态 */
function resetParam() {
- tableKeyword.value = ''
- hashKey.value = ''
- withHashKey.value = false
+ fieldKeyword.value = ''
+ fieldExact.value = false
+ scanHashFieldTtl.value = false
+ listIndexMin.value = ''
+ listIndexMax.value = ''
+ listDescAsc.value = true
+ streamDescAsc.value = true
}
-/** 组装 fieldScan 参数:游标分页、Stream 范围 meta、wire 字节格式 */
-function buildFieldScanParam(loadAll: boolean) {
+/** 续扫时 cursor 非空,跳过 TYPE/TTL/MEMORY/HLEN 等元数据命令 */
+function fieldScanIncludeMeta(): boolean {
+ return cursor.value == null
+}
+
+/** 组装 fieldScan 参数:count 来自 settings.fieldScanCount(HSCAN COUNT + 前端续扫阈值) */
+function buildFieldScanParam() {
+ const type = redisValue.value?.type
+ const serverScan = supportsFieldServerScan(type)
+ const includeMeta = fieldScanIncludeMeta()
return {
key: share.redisKey!,
- hashKey: hashKey.value,
count: meTauri.settings.fieldScanCount ?? 10,
cursor: cursor.value,
- loadAll,
- meta: meta.value,
+ match: serverScan ? fieldMatch.value : '*',
+ exact: serverScan ? fieldExact.value : false,
+ meta: {
+ ...meta.value,
+ listMinIndex: parseListIndexInput(listIndexMin.value),
+ listMaxIndex: parseListIndexInput(listIndexMax.value),
+ listDesc: listType.value ? !listDescAsc.value : null,
+ streamDesc: streamType.value ? !streamDescAsc.value : null,
+ valueByteLimit: VALUE_BYTE_LIMIT.value,
+ valuePreviewBytes: VALUE_PREVIEW_BYTES.value,
+ forceFullValue: forceFullValue.value,
+ },
bytesFormat: toWireFormat(bytesFormat.value),
+ includeMeta,
+ keyType: includeMeta ? null : (type ?? null),
+ includeFieldTtl: scanHashFieldTtl.value,
}
}
+function toggleHashFieldTtl() {
+ scanHashFieldTtl.value = !scanHashFieldTtl.value
+ void restartFieldScan()
+}
+
+function dismissValueTruncated() {
+ valueTruncatedDismissed.value = true
+}
+
+/** 用户主动刷新键时重新展示大值预览提示(与切换键时的 reset 不同,保留 forceFullValue) */
+function prepareManualKeyRefresh() {
+ valueTruncatedDismissed.value = false
+}
+
+async function loadFullValue() {
+ if (loading.value) return
+ forceFullValue.value = true
+ await refreshKey(false)
+}
+
/**
* 「加载更多」专用:把新一页行追加到已有 redisValue.value,避免整表重渲染。
* 仅 hash/list/set/zset/stream 的行数组可拼接;string/json 等走整包替换。
* @returns true 已就地 merge;false 调用方应 set replaceData 整包换
*/
-function mergeFieldScanPage(prev: FieldScanViewState, data: FieldScanResult): boolean {
+function mergeFieldScanPage(
+ prev: FieldScanViewState,
+ data: FieldScanResult,
+ includeMeta: boolean,
+): boolean {
if (!supportsTableView(data.type)) return false
const merged: unknown[] = [...fieldValueRows(prev.value), ...fieldValueRows(data.value)]
;(prev as { value: unknown }).value = merged
- // length/ttl/size 随服务端最新统计更新(length 为键内总条数,非当前已加载数)
- prev.length = data.length
- prev.ttl = data.ttl
- prev.size = data.size
+ if (includeMeta) {
+ // length/ttl/size 随服务端最新统计更新(length 为键内总条数,非当前已加载数)
+ prev.length = data.length
+ prev.ttl = data.ttl
+ prev.size = data.size
+ }
return true
}
@@ -400,7 +658,7 @@ async function finalizeAfterFieldScan(reset: boolean, replaceData?: FieldScanRes
}
// 清空未保存编辑;fieldScan 结果即当前权威内容
if (redisValue.value) {
- redisValue.value.newValue = ''
+ redisValue.value.newValue = null
}
suppressCodeUpdate.value = false
if (reset) applyDefaultViewType()
@@ -421,56 +679,105 @@ async function finalizeAfterFieldScan(reset: boolean, replaceData?: FieldScanRes
loading.value = false
}
+async function fieldScanCore(
+ useCursor: boolean,
+): Promise<{ count: number; replaceData?: FieldScanResult }> {
+ const includeMeta = fieldScanIncludeMeta()
+ const data = await meCommands.fieldScan(share.conn!.id, buildFieldScanParam())
+ cursor.value = data.cursor
+ scanBatchCount.value++
+
+ if (useCursor) {
+ const prev = redisValue.value
+ if (prev && mergeFieldScanPage(prev, data, includeMeta)) {
+ return { count: fieldValueRows(data.value).length }
+ }
+ }
+ return { count: fieldValueRows(data.value).length, replaceData: data }
+}
+
+async function fieldScanAuto(fetchedCount = 0): Promise {
+ if (!cursor.value || cursor.value.finished) return
+ if (scanCancelled.value) return
+ if (fetchedCount >= FIELD_SCAN_FETCH_COUNT.value) return
+
+ const { count } = await fieldScanCore(true)
+ await fieldScanAuto(fetchedCount + count)
+}
+
+async function fieldScanAll(): Promise {
+ if (!cursor.value || cursor.value.finished) return
+ if (scanCancelled.value) return
+
+ await fieldScanCore(true)
+ await fieldScanAll()
+}
+
+function shouldFieldScanAuto(type: string | undefined, exact: boolean) {
+ if (exact || !type) return false
+ // Hash/Set/ZSet pattern 扫描、List/Stream 前端分页循环
+ return supportsFieldServerScan(type) || type === 'list' || type === 'stream'
+}
+
/**
* 拉取/刷新当前键(fieldScan → 更新 redisValue → 同步编辑器)。
- *
- * 典型调用:
- * - refreshKey() / refreshKey(true):选中新键或切换 hashKey,从头 scan
- * - refreshKey(false):同键刷新(改 bytesFormat、保存/删字段后)
- * - refreshKey(false, true):加载更多(useCursor,追加一页)
- * - refreshKey(false, true, true):加载全部
- *
- * @param reset 是否 resetParam + 重算 json/table 默认视图
- * @param useCursor 为 true 时保留 cursor,请求下一页并尝试 merge
- * @param loadAll 为 true 时后端一次返回剩余全部页(仍走 cursor 协议)
+ * - reset=true:切换键,清空 fieldKeyword
+ * - restart=true:手动刷新 / Enter 搜索,保留 keyword 并中断进行中的扫描
+ * 值面板无 F5;F5 仅 KeyMain 刷新键列表。
*/
async function refreshKey(
reset: boolean = true,
useCursor: boolean = false,
loadAll: boolean = false,
+ restart: boolean = false,
) {
+ if (!share.conn || !share.redisKey) return
+
+ if (loading.value) {
+ if (!restart) return
+ scanCancelled.value = true
+ scanPaused.value = false
+ while (loading.value) {
+ await sleep(20)
+ }
+ }
+
fieldSetInit()
- // 刷新过程中 me-code 的 modelValue 会变,避免误写入 newValue
suppressCodeUpdate.value = true
+ scanLoadAll.value = loadAll
- if (reset) resetParam()
- // 非「加载更多」时从第一页重新 scan
+ if (reset) {
+ resetParam()
+ forceFullValue.value = false
+ valueTruncatedDismissed.value = false
+ }
if (!useCursor) cursor.value = null
loading.value = true
- // 有值则 finally 整包替换;undefined 表示 merge 成功或仅更新了 prev 字段
- let replaceData: FieldScanResult | undefined
+ scanCancelled.value = false
+ if (!useCursor) scanPaused.value = false
+
try {
- const data = await meCommands.fieldScan(share.conn!.id, buildFieldScanParam(loadAll))
- cursor.value = data.cursor
- // hashKey 非空时进入「单字段」模式,UI 按 string 展示
- withHashKey.value = !!hashKey.value
-
- if (useCursor) {
- const prev = redisValue.value
- if (!prev || !mergeFieldScanPage(prev, data)) {
- replaceData = data
- }
- } else {
- replaceData = data
+ if (!useCursor) scanBatchCount.value = 0
+
+ const first = await fieldScanCore(useCursor)
+ if (first.replaceData) {
+ redisValue.value = toViewState(first.replaceData)
+ }
+
+ const scanType = redisValue.value?.type
+ if (loadAll) {
+ await fieldScanAll()
+ } else if (shouldFieldScanAuto(scanType, fieldExact.value)) {
+ await fieldScanAuto(first.count)
}
showMore.value = !cursor.value?.finished
- // setTimer 用 ttl 启本地倒计时;merge 场景直接用 prev
- const rvDone = replaceData ? toViewState(replaceData) : redisValue.value
+ const rvDone = redisValue.value
if (rvDone) await setTimer(rvDone.ttl)
} finally {
- await finalizeAfterFieldScan(reset, replaceData)
+ await finalizeAfterFieldScan(reset)
+ if (cursor.value?.finished) scanPaused.value = false
}
}
// #endregion
@@ -526,6 +833,8 @@ function renameKey() {
keyRenameRef.value?.open({ redisKey: share.redisKey })
}
+const objectInfoRef = useTemplateRef>('objectInfoRef')
+
function duplicateKey() {
if (!share.redisKey) return
connUi.openKeyCopy(share.redisKey)
@@ -550,6 +859,45 @@ async function copyAsCommand() {
}
}
+async function onFooterRefreshKey() {
+ await manualRefreshKey()
+ meOk(t('redisValue.refreshKeyOk'))
+}
+
+function buildFieldAsCommandParam(row: ValueTableRow): RedisFieldAsCommand_Deserialize | null {
+ const rv = redisValue.value
+ const rk = share.redisKey
+ if (!rv || !rk) return null
+ const fieldViewFmt = viewFmtForField(bytesFormat.value)
+ const param: RedisFieldAsCommand_Deserialize = {
+ key: rk,
+ fieldKey: row.key || '',
+ fieldValue: String(row.value ?? ''),
+ streamId: row.id || '',
+ fieldIndex: -1,
+ valFmt: toWireFormat(fieldViewFmt),
+ }
+ if (rv.type === 'list') {
+ param.fieldIndex = listRowRedisIndex(row)
+ }
+ if (rv.type === 'stream') {
+ param.fieldValue = ''
+ }
+ return param
+}
+
+async function copyFieldAsCommand(row: ValueTableRow) {
+ const conn = share.conn
+ const param = buildFieldAsCommandParam(row)
+ if (!conn || !param) return
+ const text = await meCommands.getFieldAsCommand(conn.id, param)
+ if (!text.trim()) {
+ meWarn(t('redisValue.copyCommandEmpty'))
+ return
+ }
+ meCopy(text, t('redisValue.copyCommandOk'))
+}
+
// 收藏(与 KeyTree 右键菜单一致)
const favorites = useFavorites()
const isCurrentKeyFavorited = computed(() => {
@@ -572,17 +920,27 @@ function toggleFavorite() {
}
}
-function onKeyMoreCommand(command: string) {
+async function onKeyMoreCommand(command: string) {
if (command === 'refreshKey') {
- void refreshKey(false)
+ await onFooterRefreshKey()
} else if (command === 'copyKey') {
meCopy(showKey.value)
+ } else if (command === 'copyValue') {
+ meCopy(showValue.value)
+ } else if (command === 'copyAsCommand') {
+ void copyAsCommand()
} else if (command === 'renameKey') {
renameKey()
} else if (command === 'duplicateKey') {
duplicateKey()
- } else if (command === 'copyAsCommand') {
- void copyAsCommand()
+ } else if (command === 'objectInfo') {
+ objectInfoRef.value?.open()
+ } else if (command === 'showSlot') {
+ void showSlot()
+ } else if (command === 'showLocation') {
+ void showLocation()
+ } else if (command === 'commandHelp') {
+ openCommandHelp()
}
}
// #endregion
@@ -590,15 +948,21 @@ function onKeyMoreCommand(command: string) {
// #region 保存整键值(STRING / JSON)
async function setValue() {
const rv = redisValue.value
- if (!rv) return
+ if (!rv || rv.newValue === null) return
let value = rv.newValue
try {
- if (
- jsonType.value ||
- (stringType.value && (bytesFormat.value === 'msgpack' || bytesFormat.value === 'strjson'))
- ) {
+ if (jsonType.value) {
+ if (value === '') {
+ meErr(t('fieldAdd.jsonValidator'))
+ return
+ }
value = meJsonNormal(value)
+ } else if (
+ stringType.value &&
+ (bytesFormat.value === 'msgpack' || bytesFormat.value === 'strjson')
+ ) {
+ value = value === '' ? '' : meJsonNormal(value)
}
if (stringType.value && isCustomView(bytesFormat.value)) {
value = await meViewToWireAsync(value, bytesFormat.value)
@@ -609,7 +973,7 @@ async function setValue() {
const msg = e instanceof Error ? e.message : String(e)
if (stringType.value && isCustomView(bytesFormat.value)) {
setCustomCodecError(msg)
- rv.newValue = ''
+ rv.newValue = null
valueEditorRemountKey.value++
return
}
@@ -645,20 +1009,83 @@ function fieldAdd() {
const fieldSetIndex = ref(-1)
const fieldSetReadonly = ref(false)
+/** 单行刷新:list 在 value 数组中的下标;hash 为字段 wire key */
+const fieldEditIndex = ref(-1)
+const fieldEditKey = ref('')
+/** 编辑面板当前行(分页下不能用 fieldSetIndex 索引 filterDataList) */
+const fieldSetRow = ref(null)
const fieldSetRef = useTemplateRef('fieldSetRef')
+function pageRowIndexFromEvent(event: MouseEvent): number {
+ const tr = event.currentTarget as HTMLElement | null
+ if (!tr) return -1
+ for (const className of tr.classList) {
+ if (className.startsWith('table-row-index-')) {
+ return Number.parseInt(className.slice('table-row-index-'.length), 10)
+ }
+ }
+ return -1
+}
+
function fieldSetInit() {
fieldSetIndex.value = -1
fieldSetReadonly.value = false
+ fieldEditIndex.value = -1
+ fieldEditKey.value = ''
+ fieldSetRow.value = null
fieldSetRef.value?.close()
}
+function prepareFieldRowContext(row: ValueTableRow) {
+ const rv = redisValue.value
+ fieldEditKey.value = row.key || ''
+ fieldEditIndex.value = -1
+ if (rv?.type === 'list') {
+ fieldEditIndex.value = listRowRedisIndex(row)
+ }
+}
+
+function formatFieldTtl(ttl: number | undefined): string {
+ if (ttl === undefined || ttl === null) return '-'
+ if (ttl === -1) return t('redisValue.ttlForever')
+ return String(meHumanSeconds(ttl))
+}
+
+function buildFieldGetParam(row?: ValueTableRow): RedisFieldGet_Deserialize | null {
+ const rv = redisValue.value
+ const rk = share.redisKey
+ if (!rv || !rk) return null
+ return {
+ key: rk,
+ fieldIndex: fieldEditIndex.value,
+ fieldKey: fieldEditKey.value,
+ fieldValue: rv.type === 'zset' && row ? String(row.value ?? '') : '',
+ valFmt: toWireFormat(viewFmtForField(bytesFormat.value)),
+ includeFieldTtl: rv.type === 'hash' ? scanHashFieldTtl.value : null,
+ }
+}
+
+function fieldRowDisplayValue(row: ValueTableRow): string {
+ return streamType.value ? JSON.stringify(row.value) : formatTableCell(row.value)
+}
+
+/** 值列排序:与单元格展示一致(Stream 等为 JSON 字符串) */
+function compareFieldRowValue(a: ValueTableRow, b: ValueTableRow): number {
+ return fieldRowDisplayValue(a).localeCompare(fieldRowDisplayValue(b), undefined, {
+ numeric: true,
+ sensitivity: 'base',
+ })
+}
+
function openFieldPanel(row: ValueTableRow, index: number, readonly: boolean) {
const rv = redisValue.value
if (!rv) return
fieldSetIndex.value = index
fieldSetReadonly.value = readonly
- const rowValWire = String(row.value ?? '')
+ fieldSetRow.value = row
+ prepareFieldRowContext(row)
+ const rowValWire =
+ rv.type === 'stream' ? JSON.stringify(row.value ?? {}) : String(row.value ?? '')
const params = {
fieldKey: row.key || '',
fieldScore: row.score || 0,
@@ -670,37 +1097,33 @@ function openFieldPanel(row: ValueTableRow, index: number, readonly: boolean) {
type: rv.type,
key: share.redisKey!,
fieldIndex: -1,
+ streamId: row.id || '',
readonly,
}
if (rv.type === 'list') {
- // 表格可能被关键字过滤,list 索引需从完整 value 数组重算
- params.fieldIndex = fieldValueRows(rv.value).indexOf(row.value)
+ params.fieldIndex = fieldEditIndex.value
}
fieldSetRef.value?.open(params)
}
-function fieldSet(row: ValueTableRow, index: number) {
- openFieldPanel(row, index, false)
-}
-function fieldView(row: ValueTableRow, index: number) {
- openFieldPanel(row, index, true)
+function rowClassName({ rowIndex }: { row: ValueTableRow; rowIndex: number }) {
+ const classes = [`table-row-index-${rowIndex}`]
+ if (fieldSetIndex.value === rowIndex) classes.push('field-set-row')
+ return classes.join(' ')
}
-function rowClassName({ rowIndex }: { row: ValueTableRow; rowIndex: number }) {
- return `table-row-index-${rowIndex}`
+function rowDblClick(row: ValueTableRow, _column: unknown, event: MouseEvent) {
+ if ((event.target as HTMLElement)?.closest('.field-row-actions')) return
+ const rowIndex = pageRowIndexFromEvent(event)
+ if (rowIndex < 0) return
+ openFieldPanel(row, rowIndex, !(canEdit.value && !streamType.value))
}
function rowClick(row: ValueTableRow, _column: unknown, event: MouseEvent) {
if (fieldSetIndex.value === -1) return
- const trElement = event.currentTarget as HTMLElement | null
- if (!trElement) return
- for (const className of trElement.classList) {
- if (className.startsWith('table-row-index-')) {
- const rowIndex = Number.parseInt(className.split('-')[3]!, 10)
- openFieldPanel(row, rowIndex, fieldSetReadonly.value)
- break
- }
- }
+ const rowIndex = pageRowIndexFromEvent(event)
+ if (rowIndex < 0) return
+ openFieldPanel(row, rowIndex, fieldSetReadonly.value)
}
/** 编辑面板打开时:点表格行切换内容;点面板外空白/表头等关闭 */
@@ -713,6 +1136,129 @@ function onFieldPanelOutsideClick(e: MouseEvent) {
fieldSetInit()
}
+/** 将 field_get 结果写回表格对应行(就地更新,避免整表 fieldScan) */
+function applyFieldGetResult(rv: FieldScanViewState, data: RedisFieldValue, row: ValueTableRow) {
+ if (rv.type === 'hash') {
+ const rows = fieldValueRows(rv.value) as ValueTableRow[]
+ const idx = rows.findIndex(r => r.key === (row.key || fieldEditKey.value))
+ if (idx >= 0) {
+ rows[idx] = {
+ key: data.fieldKey,
+ value: data.fieldValue,
+ ttl: scanHashFieldTtl.value ? data.fieldTtl : (rows[idx].ttl ?? row.ttl),
+ }
+ }
+ } else if (rv.type === 'list') {
+ const rows = fieldValueRows(rv.value) as ValueTableRow[]
+ const redisIndex = fieldEditIndex.value >= 0 ? fieldEditIndex.value : listRowRedisIndex(row)
+ const idx = rows.findIndex(r => r.index === redisIndex)
+ if (idx >= 0) {
+ rows[idx] = { index: rows[idx].index, value: data.fieldValue }
+ }
+ } else if (rv.type === 'zset') {
+ const rows = fieldValueRows(rv.value) as ValueTableRow[]
+ const idx = rows.findIndex(r => r.value === row.value)
+ if (idx >= 0) {
+ rows[idx] = { value: data.fieldValue, score: data.fieldScore ?? row.score }
+ }
+ }
+}
+
+/** 单行 field_get 刷新;不支持的类型回退 refreshKey */
+async function refreshFieldRow(row: ValueTableRow) {
+ const rv = redisValue.value
+ const conn = share.conn
+ if (!rv || !conn || !share.redisKey) return
+ prepareFieldRowContext(row)
+
+ if (rv.type === 'hash' || rv.type === 'list' || rv.type === 'zset') {
+ const param = buildFieldGetParam(row)
+ if (!param) return
+ try {
+ const data = await meCommands.fieldGet(conn.id, param, false)
+ applyFieldGetResult(rv, data, row)
+ meOk(t('redisValue.refreshFieldRowOk'))
+ return
+ } catch {
+ // 回退整表刷新
+ }
+ }
+ await refreshKey(false)
+}
+
+function onFieldRowMoreCommand(command: string, row: ValueTableRow) {
+ if (command === 'refreshRow') {
+ void refreshFieldRow(row)
+ } else if (command === 'copyKey') {
+ meCopy(String(row.key ?? ''))
+ } else if (command === 'copyValue') {
+ meCopy(fieldRowDisplayValue(row))
+ } else if (command === 'copyIndex') {
+ meCopy(String(row.index ?? ''))
+ } else if (command === 'copyStreamId') {
+ meCopy(String(row.id ?? ''))
+ } else if (command === 'copyScore') {
+ meCopy(String(row.score ?? ''))
+ } else if (command === 'copyAsCommand') {
+ void copyFieldAsCommand(row)
+ } else if (command === 'showZsetRank') {
+ void showZsetRank(row)
+ }
+}
+
+async function showZsetRank(row: ValueTableRow) {
+ const conn = share.conn
+ const rk = share.redisKey
+ if (!conn || !rk) return
+ const member = fieldRowDisplayValue(row)
+ const data: RedisZsetRankResult = await meCommands.zsetRank(conn.id, {
+ key: rk,
+ member,
+ valFmt: toWireFormat(viewFmtForField(bytesFormat.value)),
+ })
+ const rankText = data.rank !== null ? String(data.rank) : t('redisValue.rankNotFound')
+ const revRankText = data.revRank !== null ? String(data.revRank) : t('redisValue.rankNotFound')
+ meOk(
+ `${t('redisValue.rank')}: ${rankText}
${t('redisValue.revRank')}: ${revRankText}`,
+ true,
+ t('redisValue.rankTitle'),
+ { dangerouslyUseHTMLString: true },
+ )
+}
+
+function onFieldSetRefreshed(data: RedisFieldValue) {
+ const rv = redisValue.value
+ const row = fieldSetRow.value
+ if (!rv || !row) return
+ applyFieldGetResult(rv, data, row)
+}
+
+/** 字段保存成功后优先 field_get 刷新单行;不支持或失败时回退整表 refreshKey */
+async function onFieldSetSuccess() {
+ const rv = redisValue.value
+ if (!rv || !share.redisKey || (rv.type !== 'hash' && rv.type !== 'list')) {
+ await refreshKey(false)
+ fieldSetInit()
+ return
+ }
+
+ const param = buildFieldGetParam()
+ if (!param) {
+ await refreshKey(false)
+ fieldSetInit()
+ return
+ }
+ try {
+ const data = await meCommands.fieldGet(share.conn!.id, param, false)
+ const row = fieldSetRow.value
+ if (row) applyFieldGetResult(rv, data, row)
+ fieldSetInit()
+ } catch {
+ await refreshKey(false)
+ fieldSetInit()
+ }
+}
+
async function fieldDel(row: ValueTableRow) {
const rv = redisValue.value
if (!rv) return
@@ -726,7 +1272,7 @@ async function fieldDel(row: ValueTableRow) {
valFmt: toWireFormat(fieldViewFmt),
}
if (rv.type === 'list') {
- param.fieldIndex = fieldValueRows(rv.value).indexOf(row.value)
+ param.fieldIndex = listRowRedisIndex(row)
}
if (rv.type === 'stream') {
param.fieldValue = ''
@@ -742,31 +1288,57 @@ async function fieldDel(row: ValueTableRow) {
function streamIdToDate(id: string) {
try {
const timestamp = Number.parseInt(id.split('-')[0]!, 10)
- return dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')
+ if (!Number.isFinite(timestamp)) return ''
+ return dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss.SSS')
} catch {
- return 'format err'
+ return ''
}
}
-const groupDataList = ref([])
-const tableGroupVisible = ref(false)
-async function showGroups() {
- groupDataList.value = await meCommands.xinfoGroups(share.conn!.id, share.redisKey!)
- tableGroupVisible.value = true
+const tableGroupRef = useTemplateRef('tableGroupRef')
+function showGroups() {
+ tableGroupRef.value?.open()
+}
+
+const hashKeysRef = useTemplateRef('hashKeysRef')
+function hashListValFmt() {
+ return toWireFormat(viewFmtForField(bytesFormat.value))
+}
+function showAllHashKeys() {
+ hashKeysRef.value?.open(hashListValFmt(), 'keys')
+}
+function showAllHashValues() {
+ hashKeysRef.value?.open(hashListValFmt(), 'values')
+}
+
+const zsetRangeRef = useTemplateRef('zsetRangeRef')
+function showZsetRange() {
+ zsetRangeRef.value?.open(hashListValFmt())
}
// #endregion
// #region 底部信息栏(内存 / 条数 / 槽位)
const textMemory = computed(() => {
- const sz = redisValue.value?.size
- return sz != null && sz > 0 ? t('redisValue.textMemory') + meHumanSize(sz) : ''
+ const rv = redisValue.value
+ if (!rv) return ''
+ let sz = rv.size
+ let estimated = false
+ // 兼容不支持 MEMORY USAGE 的 Redis 变体:String 按键名+值长度粗估
+ if (sz <= 0 && stringType.value) {
+ const key = share.redisKey?.key ?? ''
+ sz = estimateStringMemory(key, rv.length)
+ estimated = true
+ }
+ if (sz <= 0) return ''
+ const label = estimated ? t('redisValue.textMemoryEstimate') : t('redisValue.textMemory')
+ return label + meHumanSize(sz)
})
/** 与 textLength 同一位置:String/单字段为字节长度,集合类型为总数 */
const textLength = computed(() => {
const rv = redisValue.value
- if (!rv || jsonType.value || (streamType.value && withHashKey.value)) return ''
- if (stringTypeOrWithHashKey.value) {
+ if (!rv || jsonType.value) return ''
+ if (stringType.value) {
return t('redisValue.textLength') + rv.length
}
if (rv.length <= 0) return ''
@@ -775,8 +1347,8 @@ const textLength = computed(() => {
const textEntries = computed(() => {
const rv = redisValue.value
- if (!rv || jsonType.value || stringTypeOrWithHashKey.value) return ''
- const filtered = filterDataList.value.length
+ if (!rv || jsonType.value || stringType.value) return ''
+ const filtered = tableDisplayList.value.length
const loaded = fieldValueRows(rv.value).length
return t('redisValue.textEntries') + `${filtered} / ${loaded}`
})
@@ -806,15 +1378,35 @@ function locateKeyInTree(): void {
// #endregion
// #region 快捷键说明弹窗
-const keyShortVisible = ref(false)
+const valueShortcutRef = useTemplateRef('valueShortcutRef')
function openKeyShortDialog() {
- keyShortVisible.value = true
+ valueShortcutRef.value?.open()
+}
+// #endregion
+
+// #region 命令帮助弹窗
+const commandHelpRef = useTemplateRef>('commandHelpRef')
+
+/** 键类型到命令分组 group 的映射 */
+const KEY_TYPE_TO_GROUP: Record = {
+ string: 'string',
+ hash: 'hash',
+ list: 'list',
+ set: 'set',
+ zset: 'sorted-set',
+ stream: 'stream',
+ json: 'json',
}
-const keyShortcuts = computed(() => getValueShortcuts(t))
+function openCommandHelp() {
+ const type = redisValue.value?.type
+ const group = type ? KEY_TYPE_TO_GROUP[type] : ''
+ commandHelpRef.value?.open({ group })
+}
// #endregion
// #region 事件总线与生命周期
+/** 选中键时加载值(KEY_REFRESH);与 KeyMain F5 刷新键列表无关 */
const onKeyRefreshBus = () => {
bytesFormat.value = 'utf8'
void refreshKey()
@@ -834,8 +1426,8 @@ onUnmounted(() => {
-
-