diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..5a801366 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +# vite-plus preview build registry bridge (auto-added by vp) +registry=https://registry-bridge.viteplus.dev/ diff --git a/README.md b/README.md index f603335e..1a181a72 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,25 @@
- -
-

RedisME

-

- English -| 简体中文 -| Screenshots -

-
+ +# RedisME + +### A modern, lightweight, cross-platform Redis desktop manager + +English | 简体中文 | Screenshots [![License](https://img.shields.io/github/license/hepengju/redis-me)](https://github.com/hepengju/redis-me/blob/main/LICENSE) [![GitHub release](https://img.shields.io/github/release/hepengju/redis-me)](https://github.com/hepengju/redis-me/releases) -![GitHub All Releases](https://img.shields.io/github/downloads/hepengju/redis-me/total) - -RedisME is a modern lightweight cross-platform Redis desktop manager +[![GitHub All Releases](https://img.shields.io/github/downloads/hepengju/redis-me/total)](https://github.com/hepengju/redis-me/releases)
-![empty.png](docs/public/images/empty.png) -![light.png](docs/public/images/light.png) -![dark.png](docs/public/images/dark.png) +![Connection list empty state](docs/public/images/empty.png) +![Light theme](docs/public/images/light.png) +![Dark theme](docs/public/images/dark.png) ## Features -- Super Lightweight: Based on Webview2, no embedded browser (Thanks to [Tauri](https://tauri.app)) -- Pretty UI: Provides light/dark themes(Thanks to [ElementPlus](https://element-plus.org), [CodeMirror](https://codemirror.net/), [VueWebTerminal](https://tzfun.github.io/vue-web-terminal/)) +- Super Lightweight: Based on Webview, no embedded browser (Thanks to [Tauri](https://tauri.app)) +- Pretty UI: Provides light/dark themes (Thanks to [ElementPlus](https://element-plus.org), [CodeMirror](https://codemirror.net/), [VueWebTerminal](https://tzfun.github.io/vue-web-terminal/)) - Cross-platform: Supports Windows/Mac/Linux - Multi-language: English, Chinese, more languages coming soon - Rich functionality: info, value, terminal, memory analysis, slow logs, command monitoring, pub/sub etc @@ -58,7 +53,7 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh curl -fsSL https://vite.plus | bash # clone -git clone https://github.com/hepengju/redis-me.git --depth=1 +git clone https://github.com/hepengju/redis-me.git # install package.json and start dev vp install @@ -67,6 +62,6 @@ vp run tauri dev ## WeChat Official Account -Regularly share the special features and update logs of RedisME, as well as other technical issues and solutions. +Regularly shares RedisME special features and update logs, as well as other technical issues and solutions. wechat diff --git a/README_zh.md b/README_zh.md index 88cd09fa..bec0a73c 100644 --- a/README_zh.md +++ b/README_zh.md @@ -1,31 +1,26 @@
- -
-

RedisME

-

- English -| 简体中文 -| 更多截图 -

-
+ +# RedisME + +### 一个现代、轻量、跨平台的 Redis 桌面客户端 + +English | 简体中文 | 更多截图 [![License](https://img.shields.io/github/license/hepengju/redis-me)](https://github.com/hepengju/redis-me/blob/main/LICENSE) [![GitHub release](https://img.shields.io/github/release/hepengju/redis-me)](https://github.com/hepengju/redis-me/releases) -![GitHub All Releases](https://img.shields.io/github/downloads/hepengju/redis-me/total) - -一个现代、轻量、跨平台的Redis桌面客户端 +[![GitHub All Releases](https://img.shields.io/github/downloads/hepengju/redis-me/total)](https://github.com/hepengju/redis-me/releases)
-![empty.png](docs/public/images/empty-zh.png) -![light.png](docs/public/images/light-zh.png) -![dark.png](docs/public/images/dark-zh.png) +![连接列表空状态](docs/public/images/empty-zh.png) +![浅色主题](docs/public/images/light-zh.png) +![深色主题](docs/public/images/dark-zh.png) -## 功能特性 +## 功能 -- 极度轻量:基于Webview2,无内嵌浏览器,安装包小于10M(感谢 [Tauri](https://tauri.app/zh-cn)) +- 极度轻量:基于 Webview,无内嵌浏览器,安装包小于 10M(感谢 [Tauri](https://tauri.app/zh-cn)) - 界面美观:提供浅色/深色主题(感谢 [ElementPlus](https://cn.element-plus.org/zh-CN/), [CodeMirror](https://codemirror.net/), [VueWebTerminal](https://tzfun.github.io/vue-web-terminal/zh/)) -- 跨平台支持:支持Window/Mac/Linux +- 跨平台支持:支持 Windows/Mac/Linux - 多语言支持:英文、中文,敬请期待其他语言 - 功能丰富:支持信息、键值、终端、内存分析、慢日志、命令监控、发布订阅等 @@ -33,6 +28,7 @@ - 只读/可写模式动态切换 - 终端命令提示及详细解释 +- Info 字段高亮与详细解释 - 配置字段的差异比对、详细解释、默认值参考 - 精细化的内存扫描参数配置,快速排查内存问题 - 终端执行命令,支持自动广播到集群的多节点 @@ -40,7 +36,7 @@ ## 应用安装 -提供Mac、Windows和Linux安装包,可免费下载 [Github](https://github.com/hepengju/redis-me/releases)、[Gitee](https://gitee.com/hepengju/redis-me/releases) +提供 Mac、Windows 和 Linux 安装包,可免费下载 [Github](https://github.com/hepengju/redis-me/releases)、[Gitee](https://gitee.com/hepengju/redis-me/releases) ## 构建项目 @@ -57,7 +53,7 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh curl -fsSL https://vite.plus | bash # 克隆项目 -git clone https://github.com/hepengju/redis-me.git --depth=1 +git clone https://github.com/hepengju/redis-me.git # 安装前端依赖,然后本地启动开发模式 vp install @@ -66,6 +62,6 @@ vp run tauri dev ## 微信公众号 -定期分享RedisME的特色功能与图文更新日志,及其他技术疑难问题和解决方案 +定期分享 RedisME 的特色功能与图文更新日志,及其他技术疑难问题和解决方案 wechat diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 1622f9b1..d7bbca08 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,5 +1,7 @@ import { defineConfig } from 'vitepress' +import { createLangRedirectScript } from './theme/lang-redirect' + /** 子路径部署时改为 `/仓库名/`(建议以 / 结尾),与 Vite `base` 一致 */ const siteBase: string = '/' @@ -20,6 +22,8 @@ export default defineConfig({ head: [ ['link', { rel: 'icon', type: 'image/svg+xml', href: siteAsset('images/logo.svg') }], ['link', { rel: 'icon', type: 'image/png', href: siteAsset('images/logo.png') }], + // 根路径:中文浏览器或已选中文 → /zh/(见 theme/lang-redirect.ts) + ['script', { type: 'text/javascript' }, createLangRedirectScript(viteBase)], ], themeConfig: { logo: siteAsset('images/logo.svg'), @@ -58,5 +62,5 @@ export default defineConfig({ // srcExclude: ['latest.json', '/zz/**'], // Windows(Hyper-V/WSL 等)常保留 5172–5271,Vite 默认 5173 会 EACCES - vite: { server: { port: 3333, host: '127.0.0.1' }, preview: { port: 3333, host: '127.0.0.1' } }, + vite: { server: { port: 33333, host: '127.0.0.1' }, preview: { port: 3333, host: '127.0.0.1' } }, }) diff --git a/docs/.vitepress/theme/components/AppPreview.vue b/docs/.vitepress/theme/components/AppPreview.vue index d6163167..7870f4ae 100644 --- a/docs/.vitepress/theme/components/AppPreview.vue +++ b/docs/.vitepress/theme/components/AppPreview.vue @@ -25,7 +25,7 @@ const imgSrc = computed(() => { diff --git a/src/locales/lang/en.ts b/src/locales/lang/en.ts index b89615d5..cd92a457 100644 --- a/src/locales/lang/en.ts +++ b/src/locales/lang/en.ts @@ -69,19 +69,25 @@ export default { updateNow: 'Check Now', updateAppStore: 'Update By AppStore', - baseSetting: 'Base Setting', - moreSetting: 'More Setting', + baseSetting: 'Basic Settings', + moreSetting: 'More Settings', extLabelWidth: '140px', keyScanCount: 'Key Scan', fieldScanCount: 'Field Scan', keyScanCountTip: 'Number of keys loaded per SCAN; too large may affect performance. Range {min}–{max}', - fieldScanCountTip: 'Fields loaded per HSCAN or SSCAN. Range {min}~{max}', + fieldScanCountTip: + 'HSCAN/SSCAN/ZSCAN COUNT per request; also the cumulative threshold for frontend auto-load. Range {min}~{max}', commandTimeout: 'Cmd Timeout', commandTimeoutTip: 'Max wait for one Redis command on an open connection; takes effect after reconnect. Range {min}–{max} s', scriptTimeout: 'Script Timeout', scriptTimeoutTip: 'Max runtime for custom codec scripts. Range {min}–{max} s', + valueByteLimitMB: 'Safe Limit', + valueByteLimitMBTip: 'STRING value size threshold to prompt full load. Range {min}–{max} M', + valuePreviewBytes: 'Preview Bytes', + valuePreviewBytesTip: + 'Preview first N bytes when value exceeds safe limit. Range {min}–{max} B', secUnit: 's', countUnit: '', pxUnit: 'px', @@ -110,7 +116,7 @@ export default { openDir: 'Open', resetWindow: 'Reset Window', resetWindowTip: 'Restore default size and center', - resetWindowOk: 'Window restored to default size', + resetWindowOk: 'Window restored to default position and size', shortcuts: 'Shortcuts', appFullscreen: 'Fullscreen', shortcutTips: 'Shortcuts', @@ -336,6 +342,8 @@ export default { checkUpdateErr: 'Check Update Fail', invalidHexString: 'Invalid hex string: odd number of characters', invalidHexCharacter: 'Invalid hex character', + invalidBinaryString: 'Invalid binary string: length not multiple of 8', + invalidBinaryCharacter: 'Invalid binary character', }, tabMain: { @@ -393,10 +401,7 @@ export default { typeRequired: 'Type required', ttlRequired: 'TTL required', ttlValidator: '-1 (Forever) or positive integer', - valueRequired: 'Value required', jsonValidator: 'Value must be in a valid JSON format', - hashValidator: 'HashKey and hashValue required', - streamValidator: 'Field and value required', hashHint: '(HashKey: HashValue)', hashHintTtl: '(HashKey: HashValue: TTL Seconds)', zsetHint: '(Value: Score)', @@ -418,7 +423,6 @@ export default { }, fieldSet: { - fieldValueRequired: 'Value Required', fieldScoreRequired: 'Score Required', editField: 'Edit Field', viewField: 'View Field', @@ -759,6 +763,11 @@ export default { readonlyHint: 'Executing commands is temporarily not supported in read-only mode', readonlyWriteHint: 'Write or non-readonly commands are not allowed in read-only mode', autoCopyHint: 'Auto Copy Command Result', + outputModeHint: 'Output format: redis-cli TTY / --raw / --json / --csv', + outputStandard: 'TTY', + outputRaw: 'Raw', + outputJson: 'JSON', + outputCsv: 'CSV', commandHint: 'View Command List', commandTitle: 'Commands', @@ -801,10 +810,29 @@ export default { locationTitle: 'Cluster Node', slotHint: 'View the cluster slot where the key is located', slotTitle: 'Cluster Slot', - tableKeyword: 'Fuzzy Filter', - insertRow: 'Insert Row', + tableKeyword: 'Local filter', + fieldScanPlaceholder: 'Scan params / local filter', + listStreamFilterPlaceholder: 'Local filter', + listIndexMin: 'Min index', + listIndexMax: 'Max index', + listSortAsc: 'Asc', + listSortDesc: 'Desc', + listLpopConfirm: 'Run LPOP? This will pop and remove the first element of the list.', + listRpopConfirm: 'Run RPOP? This will pop and remove the last element of the list.', + setPopConfirm: 'Run SPOP? This will randomly pop and remove an element from the set.', + zpopMinConfirm: 'Run ZPOPMIN? This will pop and remove the element with the lowest score.', + zpopMaxConfirm: 'Run ZPOPMAX? This will pop and remove the element with the highest score.', + fieldCommands: 'More', + fieldExactSearch: + 'Scan (unchecked)
app: contains app
app*: starts with app
*app: ends with app
Use * ? [ as glob when present

Exact (checked)
Whether a field/member exactly matches input (Hash matches field name only)', + insertRow: 'Insert', + allHashKeys: 'All Keys', + allHashValues: 'All Values', + hashKeysEmpty: 'No fields', + hashValuesEmpty: 'No values', id: 'ID', key: 'Key', + index: 'Index', ttl: 'TTL', value: 'Value', score: 'Score', @@ -816,17 +844,83 @@ export default { loadAll: 'Load All', renameKey: 'Rename Key', duplicateKey: 'Create Duplicate', - copyAsCommand: 'Copy as Command', + copyValue: 'Copy Value', + copyKey: 'Copy Key', + copyIndex: 'Copy Index', + copyStreamId: 'Copy ID', + copyScore: 'Copy Score', + showZsetRank: 'View Index', + rank: 'Index (ZRANK)', + revRank: 'Rev Index (ZREVRANK)', + rankTitle: 'Member Rank', + rankNotFound: 'Not Found', + zsetRange: 'TopN', + zsetRangeTitle: 'ZSet Rank Query', + zsetRangeCount: 'Count', + zsetRangeAsc: 'Asc', + zsetRangeDesc: 'Desc', + zsetRangeQuery: 'Query', + zsetRangeFilter: 'Filter (value, score)', + zsetRangeEmpty: 'No data', + copyAsCommand: 'Copy Command', + refreshFieldRow: 'Refresh Row', + refreshFieldRowOk: 'Row refreshed', + refreshKeyOk: 'Key refreshed', copyCommandOk: 'Command copied', copyCommandEmpty: 'Empty key, nothing to copy', refreshKey: 'Refresh Key', locateKeyHint: 'Locate current key in key list', textMemory: 'Memory Usage: ', + textMemoryEstimate: 'Memory Usage (est.): ', textLength: 'Bytes Length: ', textEntries: 'Scanned: ', totalCount: 'Total: ', + valueTruncatedTitle: 'Value too large — showing preview', + valueTruncatedDesc: + 'This key is about {size}, exceeding the {limit} limit. Only the first {preview} bytes are shown to keep the UI responsive.', + valueTruncatedDismiss: 'Keep Preview', + valueTruncatedLoadAll: 'Load Full Value', viewCodec: 'Codec', + commandHelp: 'Command Help', + objectInfo: 'Object Introspection', + objectInfoCommand: 'Command', + objectInfoItem: 'Item', + objectInfoValue: 'Value', + objectEncoding: 'Internal Encoding', + objectIdleTime: 'Idle Time', + objectRefcount: 'Reference Count', + objectFreq: 'Access Frequency', + objectInfoNA: 'N/A', + objectIdleTimeUnavailable: 'OBJECT IDLETIME is unavailable when maxmemory-policy is LFU-based', + objectFreqUnavailable: + 'OBJECT FREQ is unavailable when maxmemory-policy is not LFU (including no eviction policy)', + objectEncodingTip: `Redis objects can be encoded in different ways

+Strings
+• raw: normal string encoding
+• int: a string representing a 64-bit signed integer, encoded this way to save space
+• embstr: embedded string; an object where the SDS is an immutable string allocated in the same memory block as the object itself; length limited by OBJ_ENCODING_EMBSTR_SIZE_LIMIT (44 bytes)

+Lists
+• linkedlist: simple list encoding (old, no longer used)
+• ziplist: (Redis ≤ 6.2) space-efficient encoding for small lists
+• listpack: (Redis ≥ 7.0) space-efficient encoding for small lists
+• quicklist: a linked list of ziplist or listpack

+Sets
+• hashtable: normal set encoding
+• intset: special encoding for small sets of integers only
+• listpack: (Redis ≥ 7.2) space-efficient encoding for small sets

+Hashes
+• zipmap: old hash encoding (no longer used)
+• hashtable: normal hash table encoding
+• ziplist: (Redis ≤ 6.2) space-efficient encoding for small hashes
+• listpack: (Redis ≥ 7.0) space-efficient encoding for small hashes

+Sorted Sets
+• skiplist: normal sorted set encoding
+• ziplist: (Redis ≤ 6.2) space-efficient encoding for small sorted sets
+• listpack: (Redis ≥ 7.0) space-efficient encoding for small sorted sets

+Streams
+• stream: a radix tree encoded as listpack

+Once an operation prevents Redis from keeping the space-saving encoding, special encodings are automatically converted to the general type`, keyShortHint: 'View KeyShort', keyShort: { fullscreen: 'Fullscreen Editor', @@ -868,7 +962,7 @@ export default { keyMain: { keyword: 'Enter to Search', exactSearch: - 'Exact Match
• Off: input app → auto add * before/after, scan *app*
• On: input app* → query keys starting with app; input app → use EXISTS to check if key exists', + 'Scan (unchecked)
app: contains app
app*: starts with app
*app: ends with app
With * ? [: use pattern as typed

Exact (checked)
Check if key name exactly matches input', refreshKey: 'Refresh List (F5)', scanning: 'Scanning...', pauseScan: 'Pause Scan', diff --git a/src/locales/lang/zh-cn.ts b/src/locales/lang/zh-cn.ts index ff960bbb..526418f9 100644 --- a/src/locales/lang/zh-cn.ts +++ b/src/locales/lang/zh-cn.ts @@ -70,11 +70,16 @@ export default { keyScanCount: '键扫描', fieldScanCount: '字段扫描', keyScanCountTip: '键列表每次 SCAN 加载的数量;过大可能影响性能。范围 {min}–{max} 个', - fieldScanCountTip: '每次 HSCAN、SSCAN 加载的数量。范围 {min}~{max} 个', + fieldScanCountTip: + 'HSCAN/SSCAN/ZSCAN 每次 COUNT;同时作为前端自动续扫的累积条数阈值。范围 {min}~{max} 个', commandTimeout: '命令超时', commandTimeoutTip: '单次 Redis 命令读写的最长等待时间;修改后需重连生效。范围 {min}–{max} 秒', scriptTimeout: '脚本超时', scriptTimeoutTip: '自定义编解码脚本的最长执行时间。范围 {min}–{max} 秒', + valueByteLimitMB: '安全阈值', + valueByteLimitMBTip: 'STRING 类型值超过此大小时提示是否全量加载;范围 {min}–{max} M', + valuePreviewBytes: '预览字节', + valuePreviewBytesTip: 'STRING 类型值超过安全阈值时仅展示前 N 字节;范围 {min}–{max} B', secUnit: '秒', countUnit: '个', pxUnit: 'px', @@ -103,7 +108,7 @@ export default { openDir: '打开', resetWindow: '重置窗口', resetWindowTip: '恢复为默认大小并居中', - resetWindowOk: '窗口已恢复默认大小', + resetWindowOk: '窗口已恢复默认位置和大小', shortcuts: '快捷键', appFullscreen: '全屏应用', shortcutTips: '快捷键', @@ -326,6 +331,8 @@ export default { checkUpdateErr: '检查更新失败', invalidHexString: '无效的十六进制字符串:长度为奇数', invalidHexCharacter: '无效的十六进制字符', + invalidBinaryString: '无效的二进制字符串:长度不是 8 的倍数', + invalidBinaryCharacter: '无效的二进制字符', }, tabMain: { @@ -384,10 +391,7 @@ export default { typeRequired: '请选择类型', ttlRequired: '请输入TTL', ttlValidator: '只允许-1(永久) 或 正整数', - valueRequired: '值不允许为空', jsonValidator: '值必须为有效的JSON格式', - hashValidator: '哈希键和值不允许为空', - streamValidator: '字段和值不允许为空', hashHint: '(哈希键:值)', hashHintTtl: '(哈希键:值:过期秒)', zsetHint: '(值:分数)', @@ -409,7 +413,6 @@ export default { }, fieldSet: { - fieldValueRequired: '值不能为空', fieldScoreRequired: '请输入分数', editField: '编辑字段', viewField: '查看字段', @@ -489,7 +492,7 @@ export default { noData: '没有数据', refreshKey: '刷新列表', reloadKey: '重新载入', - copyKey: '复制键名', + copyKey: '复制键', deleteKey: '删除键', addKey: '添加新键', copyFolder: '复制路径', @@ -748,6 +751,11 @@ export default { readonlyHint: '只读模式下暂不支持执行命令', readonlyWriteHint: '只读模式下不允许执行写入或非只读命令', autoCopyHint: '自动复制命令结果', + outputModeHint: '输出格式,对齐 redis-cli 的 TTY / --raw / --json / --csv', + outputStandard: 'TTY', + outputRaw: 'Raw', + outputJson: 'JSON', + outputCsv: 'CSV', commandHint: '查看命令列表', commandTitle: '命令列表', keywordHint: '命令或概述模糊筛选', @@ -788,10 +796,29 @@ export default { locationTitle: '集群节点', slotHint: '查看键所在集群槽位', slotTitle: '集群槽位', - tableKeyword: '模糊筛选', - insertRow: '插入行', + tableKeyword: '本地过滤', + fieldScanPlaceholder: '扫描参数 / 本地过滤', + listStreamFilterPlaceholder: '本地过滤', + listIndexMin: '索引最小值', + listIndexMax: '索引最大值', + listSortAsc: '升序', + listSortDesc: '降序', + listLpopConfirm: '确认执行 LPOP?将弹出并删除列表的第一个元素。', + listRpopConfirm: '确认执行 RPOP?将弹出并删除列表的最后一个元素。', + setPopConfirm: '确认执行 SPOP?将随机弹出并删除集合中的一个元素。', + zpopMinConfirm: '确认执行 ZPOPMIN?将弹出并删除分数最小的元素。', + zpopMaxConfirm: '确认执行 ZPOPMAX?将弹出并删除分数最大的元素。', + fieldCommands: '更多', + fieldExactSearch: + '扫描(未勾选)
app: 包含 app
app*: 以 app 开头
*app: 以 app 结尾
含 * ? [ 时原样作为模式

精确(勾选)
判断 field/member 与输入完全一致是否存在(Hash 仅匹配 field 名)', + insertRow: '插入', + allHashKeys: '所有键', + allHashValues: '所有值', + hashKeysEmpty: '暂无字段', + hashValuesEmpty: '暂无值', id: 'ID', key: '键', + index: '索引', ttl: '过期', value: '值', score: '分数', @@ -803,17 +830,82 @@ export default { loadAll: '加载剩余所有键', renameKey: '重命名键', duplicateKey: '创建副本', + copyValue: '复制值', + copyKey: '复制键', + copyIndex: '复制索引', + copyStreamId: '复制ID', + copyScore: '复制分数', + showZsetRank: '查看索引', + rank: '正序索引 (ZRANK)', + revRank: '倒序索引 (ZREVRANK)', + rankTitle: '成员索引', + rankNotFound: '未找到', + zsetRange: 'TopN', + zsetRangeTitle: 'ZSet 排名查询', + zsetRangeCount: '数量', + zsetRangeAsc: '正序', + zsetRangeDesc: '倒序', + zsetRangeQuery: '查询', + zsetRangeFilter: '模糊筛选(值、分数)', + zsetRangeEmpty: '暂无数据', copyAsCommand: '复制为命令', + refreshFieldRow: '刷新行', + refreshFieldRowOk: '行已刷新', + refreshKeyOk: '键已刷新', copyCommandOk: '命令已复制', copyCommandEmpty: '空键,无可复制命令', refreshKey: '刷新键', locateKeyHint: '在键列表中定位当前键', textMemory: '内存占用:', + textMemoryEstimate: '内存占用(估算):', textLength: '字节长度:', textEntries: '已扫描:', totalCount: '总数:', + valueTruncatedTitle: '值过大,已切换为预览模式', + valueTruncatedDesc: + '该键约 {size},超过 {limit} 安全阈值。为避免卡顿,当前仅展示前 {preview} 字节。', + valueTruncatedDismiss: '继续使用预览', + valueTruncatedLoadAll: '仍要加载全部', viewCodec: '数据编码', + commandHelp: '命令帮助', + objectInfo: '对象自省', + objectInfoCommand: '命令', + objectInfoItem: '项目', + objectInfoValue: '值', + objectEncoding: '内部编码', + objectIdleTime: '空闲时间', + objectRefcount: '引用计数', + objectFreq: '访问频率', + objectInfoNA: '不可用', + objectIdleTimeUnavailable: '当 maxmemory-policy 为 LFU 策略时,OBJECT IDLETIME 不可用', + objectFreqUnavailable: '当 maxmemory-policy 非 LFU 策略(含无淘汰策略)时,OBJECT FREQ 不可用', + objectEncodingTip: `Redis 对象可以使用不同的方式进行编码

+字符串
+• raw:普通字符串编码
+• int:表示 64 位有符号整数的字符串,以此方式编码以节省空间
+• embstr:嵌入式字符串;内部 SDS 与对象本身分配在同一内存块的不可变字符串,长度受 OBJ_ENCODING_EMBSTR_SIZE_LIMIT(44 字节)限制

+列表
+• linkedlist:简单列表编码(旧编码,已不再使用)
+• ziplist:(Redis ≤ 6.2)小列表的高效空间编码
+• listpack:(Redis ≥ 7.0)小列表的高效空间编码
+• quicklist:由 ziplist 或 listpack 编码的链表

+集合
+• hashtable:普通集合编码
+• intset:仅含整数的小集合的特殊编码
+• listpack:(Redis ≥ 7.2)小集合的高效空间编码

+哈希表
+• zipmap:旧哈希编码(已不再使用)
+• hashtable:普通哈希表编码
+• ziplist:(Redis ≤ 6.2)小哈希的高效空间编码
+• listpack:(Redis ≥ 7.0)小哈希的高效空间编码

+有序集合
+• skiplist:普通有序集合编码
+• ziplist:(Redis ≤ 6.2)小有序集合的高效空间编码
+• listpack:(Redis ≥ 7.0)小有序集合的高效空间编码

+
+• stream:编码为 listpack 的基数树

+一旦执行的操作导致 Redis 无法保留节省空间的编码,所有特殊编码类型都会自动转换为通用类型`, keyShortHint: '查看快捷键', keyShort: { fullscreen: '全屏编辑器', @@ -855,7 +947,7 @@ export default { keyMain: { keyword: 'Enter 键进行搜索', exactSearch: - '精确匹配
• 关闭时:输入 app → 自动前后追加*,扫描 *app*
• 开启时:输入 app* → 查询 app 开头的键;输入 app → 使用 EXISTS 判断键是否存在', + '扫描(未勾选)
app: 包含 app
app*: 以 app 开头
*app: 以 app 结尾
含 * ? [ 时原样作为模式

精确(勾选)
判断键名与输入完全一致是否存在', refreshKey: '刷新键列表 (F5)', scanning: '扫描中...', pauseScan: '暂停扫描', diff --git a/src/plugins/tauri.ts b/src/plugins/tauri.ts index 62a2f475..fe7b4dbd 100644 --- a/src/plugins/tauri.ts +++ b/src/plugins/tauri.ts @@ -6,12 +6,11 @@ import { reactive, watch } from 'vue' import type { App } from 'vue' import { normalizeAppLocale } from '@/locales' -import { commands, type ConnConfig } from '@/types/tauri-specta' +import { commands } from '@/types/tauri-specta' +import { checkConnList, type ConnFromStore } from '@/utils/conn-compat' +import { defaultSettings } from '@/utils/settings-defaults' import { meLog } from '@/utils/util' -/** 本地 store / 旧版数据:字段可能缺失,或含已迁移的扁平哨兵字段 */ -type ConnFromStore = { [K in keyof ConnConfig]?: ConnConfig[K] } & Record - // 打包后关闭右键菜单 if (import.meta.env.PROD) { document.addEventListener('contextmenu', event => event.preventDefault()) @@ -39,32 +38,7 @@ const storeSettings = rawSettings !== null && typeof rawSettings === 'object' && !Array.isArray(rawSettings) ? (rawSettings as Record) : {} -const initSettings = { - language: 'system', - theme: 'system', - uiFont: [], - codeFont: [], - autoUpdate: true, - - // 扩展设置 - keyScanCount: 1000, - fieldScanCount: 10, - keyShow: 'tree', - keySort: 'count', - keyHeight: 20, - fieldShow: 'auto', // 'table' 始终表格 | 'auto' 默认表格、记住手动切换 - fieldShowView: 'table', // auto 模式下上次手动选择的 json/table,持久化供切换连接/键沿用 - // 首页连接分组(见 src/utils/conn.ts) - connShow: 'flat', // 'flat' | 'group' - connGroups: [] as string[], // 分组名有序列表 - connGroupExpanded: {} as Record, // 分组折叠状态,键为分组名(''=默认分组) - // 自定义 Codec(STRING 值编解码,见 plans/custom-formatter.md) - customCodecs: [] as { name: string; command: string }[], - codecExecTimeoutSec: 5, - // Redis 命令读写超时(秒),同步至 Rust AppSettings - commandTimeout: 30, -} -const settings = { ...initSettings, ...storeSettings } +const settings = { ...defaultSettings, ...storeSettings } if (settings.fieldShow !== 'auto' && settings.fieldShow !== 'table') settings.fieldShow = 'auto' if (settings.fieldShowView !== 'json' && settings.fieldShowView !== 'table') settings.fieldShowView = 'table' @@ -117,64 +91,4 @@ watch(meTauri, async newValue => { await syncAppSettings() }) -export function checkConnList(connList: ConnFromStore[]): void { - connList.forEach(conn => { - // v1.6.0 兼容旧版本,补充哨兵模式属性; - // v2.7.0 属性移动到sentinelOption中 - if (!('sentinel' in conn) || typeof conn.sentinel != 'boolean') conn.sentinel = false - if (!conn.sentinelOption) - conn.sentinelOption = { masterName: '', masterUsername: '', masterPassword: '' } - const so = conn.sentinelOption - - const legacyMasterName = conn['masterName'] - const legacyMasterUsername = conn['masterUsername'] - const legacyMasterPassword = conn['masterPassword'] - if (typeof legacyMasterName === 'string' && !so.masterName) so.masterName = legacyMasterName - if (typeof legacyMasterUsername === 'string' && !so.masterUsername) - so.masterUsername = legacyMasterUsername - if (typeof legacyMasterPassword === 'string' && !so.masterPassword) - so.masterPassword = legacyMasterPassword - if ('masterName' in conn) delete conn.masterName - if ('masterUsername' in conn) delete conn.masterUsername - if ('masterPassword' in conn) delete conn.masterPassword - - // v2.5.0 兼容旧版本,补充meta属性 - if (!('meta' in conn) || typeof conn.meta !== 'object' || conn.meta === null) conn.meta = {} - const meta = conn.meta as Record - const group = meta['group'] - if (group !== undefined && typeof group !== 'string') delete meta['group'] - else if (typeof group === 'string') meta['group'] = group.trim() - - // 命令映射:meta.commandMap 为 { 原命令小写: 映射名 } - const commandMap = meta['commandMap'] - if (commandMap !== undefined) { - if (!commandMap || typeof commandMap !== 'object' || Array.isArray(commandMap)) { - delete meta['commandMap'] - } else { - const cleaned: Record = {} - for (const [k, v] of Object.entries(commandMap as Record)) { - const cmd = typeof k === 'string' ? k.trim().toLowerCase() : '' - const mapped = typeof v === 'string' ? v.trim() : '' - if (cmd && mapped) cleaned[cmd] = mapped - } - if (Object.keys(cleaned).length) meta['commandMap'] = cleaned - else delete meta['commandMap'] - } - } - - // v2.7.0 兼容旧版本,补充SSH属性 - if (!('ssh' in conn) || typeof conn.ssh != 'boolean') conn.ssh = false - if (!conn.sshOption) - conn.sshOption = { - host: '', - port: 22, - loginType: 'pwd', // pwd 用户名/密码, pkfile 私钥文件 - username: '', - password: '', - pkfile: '', // 私钥文件 - passphrase: '', // 私钥密码 - } - }) -} - export default function setupTauri(_app: App): void {} diff --git a/src/types/tauri-specta.ts b/src/types/tauri-specta.ts index 2650b089..79d1f250 100644 --- a/src/types/tauri-specta.ts +++ b/src/types/tauri-specta.ts @@ -32,7 +32,14 @@ export const commands = { copy: (id: string, param: RedisCopyParam_Deserialize) => typedError(__TAURI_INVOKE("copy", { id, param })), fieldAdd: (id: string, param: RedisFieldAdd_Deserialize) => typedError(__TAURI_INVOKE("field_add", { id, param })), fieldSet: (id: string, param: RedisFieldSet_Deserialize) => typedError(__TAURI_INVOKE("field_set", { id, param })), + fieldGet: (id: string, param: RedisFieldGet_Deserialize) => typedError(__TAURI_INVOKE("field_get", { id, param })), + hashKeys: (id: string, param: RedisHashKeys_Deserialize) => typedError(__TAURI_INVOKE("hash_keys", { id, param })), + hashValues: (id: string, param: RedisHashKeys_Deserialize) => typedError(__TAURI_INVOKE("hash_values", { id, param })), + fieldPop: (id: string, param: RedisPop_Deserialize) => typedError(__TAURI_INVOKE("field_pop", { id, param })), fieldDel: (id: string, param: RedisFieldDel_Deserialize) => typedError(__TAURI_INVOKE("field_del", { id, param })), + zsetRank: (id: string, param: RedisZsetRank_Deserialize) => typedError(__TAURI_INVOKE("zset_rank", { id, param })), + zsetRange: (id: string, param: RedisZsetRange_Deserialize) => typedError(__TAURI_INVOKE("zset_range", { id, param })), + objectInfo: (id: string, key: RedisKey_Deserialize) => typedError(__TAURI_INVOKE("object_info", { id, key })), executeCommand: (id: string, param: RedisCommand) => typedError(__TAURI_INVOKE("execute_command", { id, param })), aclUsers: (id: string) => typedError(__TAURI_INVOKE("acl_users", { id })), aclListUsers: (id: string) => typedError(__TAURI_INVOKE("acl_list_users", { id })), @@ -65,6 +72,7 @@ export const commands = { mockData: (id: string, count: number) => typedError(__TAURI_INVOKE("mock_data", { id, count })), keyType: (id: string, key: RedisKey_Deserialize) => typedError(__TAURI_INVOKE("key_type", { id, key })), getKeyAsCommand: (id: string, key: RedisKey_Deserialize) => typedError(__TAURI_INVOKE("get_key_as_command", { id, key })), + getFieldAsCommand: (id: string, param: RedisFieldAsCommand_Deserialize) => typedError(__TAURI_INVOKE("get_field_as_command", { id, param })), xinfoGroups: (id: string, key: RedisKey_Deserialize) => typedError(__TAURI_INVOKE("xinfo_groups", { id, key })), xinfoConsumers: (id: string, key: RedisKey_Deserialize, group: string) => typedError(__TAURI_INVOKE("xinfo_consumers", { id, key, group })), keySlot: (id: string, key: RedisKey_Deserialize) => typedError(__TAURI_INVOKE("key_slot", { id, key })), @@ -119,6 +127,9 @@ export type AppSettings = { /** 前后端 IPC 字节格式:utf8 文本或 base64 原始字节(hex/binary/msgpack 等视图格式在前端处理) */ export type BytesFormat = "utf8" | "base64"; +/** 终端输出格式,对应 redis-cli `--raw` / `--csv` / `--json`;默认 TTY */ +export type CliOutputMode = "standard" | "raw" | "json" | "csv"; + export type CommandLogEntry = { id: number, timestamp: string, @@ -155,22 +166,38 @@ export type FieldScanParam = FieldScanParam_Serialize | FieldScanParam_Deseriali export type FieldScanParam_Deserialize = { key: RedisKey_Deserialize, - hashKey: string | null, count: number, cursor: ScanCursor | null, - loadAll: boolean, + /** HSCAN/SSCAN/ZSCAN 的 MATCH pattern(前端字段名 match) */ + match: string, + /** 完全匹配:true 时走 HGET / SISMEMBER / ZSCORE */ + exact: boolean, meta: FiledScanMeta | null, bytesFormat: BytesFormat | null, + /** 是否拉取 TYPE/TTL/MEMORY/HLEN;前端续扫时为 false */ + includeMeta: boolean | null, + /** 续扫时传入(include_meta=false),避免重复 TYPE */ + keyType: string | null, + /** Hash 扫描是否附带 HTTL(默认 false 以提速) */ + includeFieldTtl: boolean | null, }; export type FieldScanParam_Serialize = { key: RedisKey_Serialize, - hashKey: string | null, count: number, cursor: ScanCursor | null, - loadAll: boolean, + /** HSCAN/SSCAN/ZSCAN 的 MATCH pattern(前端字段名 match) */ + match: string, + /** 完全匹配:true 时走 HGET / SISMEMBER / ZSCORE */ + exact: boolean, meta: FiledScanMeta | null, bytesFormat: BytesFormat | null, + /** 是否拉取 TYPE/TTL/MEMORY/HLEN;前端续扫时为 false */ + includeMeta: boolean | null, + /** 续扫时传入(include_meta=false),避免重复 TYPE */ + keyType: string | null, + /** Hash 扫描是否附带 HTTL(默认 false 以提速) */ + includeFieldTtl: boolean | null, }; export type FieldScanResult = { @@ -180,11 +207,26 @@ export type FieldScanResult = { value: any, cursor: ScanCursor, length: number, + /** STRING 因超过 value_byte_limit 仅返回预览片段时为 true */ + valueTruncated: boolean, }; export type FiledScanMeta = { + /** Stream XREVRANGE 上界和下界 */ maxId: string, minId: string, + /** STRING 全量加载字节上限;超过且未 force 时仅 GETRANGE 预览前 value_preview_bytes */ + valueByteLimit: number | null, + valuePreviewBytes: number | null, + forceFullValue: boolean | null, + /** List LRANGE 下界;空则 0 */ + listMinIndex: number | null, + /** List LRANGE 上界;空则 len-1 */ + listMaxIndex: number | null, + /** List 扫描方向:true 从 max 向 min,false 从 min 向 max */ + listDesc: boolean | null, + /** Stream 扫描方向:true 从 max 向 min(XREVRANGE),false 从 min 向 max(XRANGE) */ + streamDesc: boolean | null, }; export type RedisBatchKey = RedisBatchKey_Serialize | RedisBatchKey_Deserialize; @@ -263,6 +305,8 @@ export type RedisCommand = { command: string, node: string | null, autoBroadcast: boolean | null, + /** 终端输出格式;`None` 等同 `standard`(TTY) */ + outputMode: CliOutputMode | null, }; export type RedisCopyParam = RedisCopyParam_Serialize | RedisCopyParam_Deserialize; @@ -336,6 +380,26 @@ export type RedisFieldAdd_Serialize = { valFmt: BytesFormat | null, }; +export type RedisFieldAsCommand = RedisFieldAsCommand_Serialize | RedisFieldAsCommand_Deserialize; + +export type RedisFieldAsCommand_Deserialize = { + key: RedisKey_Deserialize, + fieldIndex: number, + fieldKey: string, + fieldValue: string, + streamId: string, + valFmt: BytesFormat | null, +}; + +export type RedisFieldAsCommand_Serialize = { + key: RedisKey_Serialize, + fieldIndex: number, + fieldKey: string, + fieldValue: string, + streamId: string, + valFmt: BytesFormat | null, +}; + export type RedisFieldDel = RedisFieldDel_Serialize | RedisFieldDel_Deserialize; export type RedisFieldDel_Deserialize = { @@ -356,6 +420,30 @@ export type RedisFieldDel_Serialize = { valFmt: BytesFormat | null, }; +export type RedisFieldGet = RedisFieldGet_Serialize | RedisFieldGet_Deserialize; + +export type RedisFieldGet_Deserialize = { + key: RedisKey_Deserialize, + fieldIndex: number, + fieldKey: string, + /** ZSet 成员定位;Hash 用 field_key、List 用 field_index */ + fieldValue: string, + /** 为 true 时对 Hash 执行 HTTL;默认 false */ + includeFieldTtl: boolean | null, + valFmt: BytesFormat | null, +}; + +export type RedisFieldGet_Serialize = { + key: RedisKey_Serialize, + fieldIndex: number, + fieldKey: string, + /** ZSet 成员定位;Hash 用 field_key、List 用 field_index */ + fieldValue: string, + /** 为 true 时对 Hash 执行 HTTL;默认 false */ + includeFieldTtl: boolean | null, + valFmt: BytesFormat | null, +}; + export type RedisFieldSet = RedisFieldSet_Serialize | RedisFieldSet_Deserialize; export type RedisFieldSet_Deserialize = { @@ -366,6 +454,8 @@ export type RedisFieldSet_Deserialize = { fieldValue: string, fieldScore: number | null, fieldTtl: number, + /** true:界面展示/编辑字段 TTL;false:不拉取列表 TTL,保存时仍保留原有过期 */ + includeFieldTtl: boolean | null, /** 编辑字段时解析用户输入(含 Hash 字段名);Redis 键由 `key` 承载,不再经此格式解析 */ valFmt: BytesFormat | null, }; @@ -378,6 +468,8 @@ export type RedisFieldSet_Serialize = { fieldValue: string, fieldScore: number | null, fieldTtl: number, + /** true:界面展示/编辑字段 TTL;false:不拉取列表 TTL,保存时仍保留原有过期 */ + includeFieldTtl: boolean | null, /** 编辑字段时解析用户输入(含 Hash 字段名);Redis 键由 `key` 承载,不再经此格式解析 */ valFmt: BytesFormat | null, }; @@ -389,6 +481,20 @@ export type RedisFieldValue = { fieldTtl: number, }; +export type RedisHashKeys = RedisHashKeys_Serialize | RedisHashKeys_Deserialize; + +export type RedisHashKeys_Deserialize = { + key: RedisKey_Deserialize, + /** Hash 字段名/值解码格式,与 field_get / fieldScan 一致 */ + valFmt: BytesFormat | null, +}; + +export type RedisHashKeys_Serialize = { + key: RedisKey_Serialize, + /** Hash 字段名/值解码格式,与 field_get / fieldScan 一致 */ + valFmt: BytesFormat | null, +}; + export type RedisImportCsv = { file: string, ttl: number, @@ -447,6 +553,35 @@ export type RedisNode = { slaveOfNode: string | null, }; +export type RedisObjectInfo = { + encoding: string | null, + idleTime: number | null, + /** IDLETIME 因 maxmemory-policy 等不可用时的错误信息 */ + idleTimeError: string | null, + refcount: number | null, + freq: number | null, + /** FREQ 因 maxmemory-policy 等不可用时的错误信息 */ + freqError: string | null, +}; + +export type RedisPop = RedisPop_Serialize | RedisPop_Deserialize; + +export type RedisPop_Deserialize = { + key: RedisKey_Deserialize, + /** 操作模式(LPOP/RPOP/SPOP/ZPOPMIN/ZPOPMAX) */ + mode: string, + /** 弹出元素的展示格式 */ + valFmt: BytesFormat | null, +}; + +export type RedisPop_Serialize = { + key: RedisKey_Serialize, + /** 操作模式(LPOP/RPOP/SPOP/ZPOPMIN/ZPOPMAX) */ + mode: string, + /** 弹出元素的展示格式 */ + valFmt: BytesFormat | null, +}; + export type RedisSetParam = RedisSetParam_Serialize | RedisSetParam_Deserialize; export type RedisSetParam_Deserialize = { @@ -475,6 +610,52 @@ export type RedisSlowLog = { clientName: string, }; +export type RedisZsetRange = RedisZsetRange_Serialize | RedisZsetRange_Deserialize; + +export type RedisZsetRangeItem = { + value: string, + score: number | null, +}; + +export type RedisZsetRange_Deserialize = { + key: RedisKey_Deserialize, + /** true: ZREVRANGE (分数从高到低); false: ZRANGE (分数从低到高) */ + reverse: boolean, + /** 返回数量限制 */ + count: number, + /** 值解码格式 */ + valFmt: BytesFormat | null, +}; + +export type RedisZsetRange_Serialize = { + key: RedisKey_Serialize, + /** true: ZREVRANGE (分数从高到低); false: ZRANGE (分数从低到高) */ + reverse: boolean, + /** 返回数量限制 */ + count: number, + /** 值解码格式 */ + valFmt: BytesFormat | null, +}; + +export type RedisZsetRank = RedisZsetRank_Serialize | RedisZsetRank_Deserialize; + +export type RedisZsetRankResult = { + rank: number | null, + revRank: number | null, +}; + +export type RedisZsetRank_Deserialize = { + key: RedisKey_Deserialize, + member: string, + valFmt: BytesFormat | null, +}; + +export type RedisZsetRank_Serialize = { + key: RedisKey_Serialize, + member: string, + valFmt: BytesFormat | null, +}; + export type ScanCursor = { readyNodes: string[], nowNode: string, @@ -487,6 +668,8 @@ export type ScanParam = { match: string, type: string | null, cursor: ScanCursor | null, + /** 完全匹配:true 时后端 EXISTS;false 时 SCAN */ + exact: boolean, }; export type ScanResult = ScanResult_Serialize | ScanResult_Deserialize; diff --git a/src/types/vite-env.d.ts b/src/types/vite-env.d.ts index 5aba0756..048db1c0 100644 --- a/src/types/vite-env.d.ts +++ b/src/types/vite-env.d.ts @@ -31,6 +31,10 @@ declare global { codecExecTimeoutSec?: number /** Redis 命令读写超时(秒),同步至 Rust */ commandTimeout?: number + /** STRING 类型值全量加载安全阈值(MB) */ + valueByteLimitMB?: number + /** STRING 类型值超过安全阈值时的预览字节数 */ + valuePreviewBytes?: number } systemTheme: string systemLanguage: string diff --git a/src/utils/acl.test.ts b/src/utils/acl.test.ts index d6acead8..67c5c8cc 100644 --- a/src/utils/acl.test.ts +++ b/src/utils/acl.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vite-plus/test' import type { AclUserDetail } from '@/types/tauri-specta' import { diff --git a/src/utils/conn-compat.ts b/src/utils/conn-compat.ts new file mode 100644 index 00000000..a6c8bf9e --- /dev/null +++ b/src/utils/conn-compat.ts @@ -0,0 +1,70 @@ +import type { ConnConfig } from '@/types/tauri-specta' + +/** 本地 store / 旧版数据:字段可能缺失,或含已迁移的扁平哨兵字段 */ +export type ConnFromStore = { [K in keyof ConnConfig]?: ConnConfig[K] } & Record + +/** + * 连接数据兼容性处理(版本迁移) + * - v1.6.0: 补充哨兵模式属性,迁移 masterName/masterUsername/masterPassword → sentinelOption + * - v2.5.0: 补充 meta 属性,处理 group 字段 + * - v2.7.0: 补充 SSH 属性 + */ +export function checkConnList(connList: ConnFromStore[]): void { + connList.forEach(conn => { + // v1.6.0 兼容旧版本,补充哨兵模式属性; + // v2.7.0 属性移动到sentinelOption中 + if (!('sentinel' in conn) || typeof conn.sentinel != 'boolean') conn.sentinel = false + if (!conn.sentinelOption) + conn.sentinelOption = { masterName: '', masterUsername: '', masterPassword: '' } + const so = conn.sentinelOption + + const legacyMasterName = conn['masterName'] + const legacyMasterUsername = conn['masterUsername'] + const legacyMasterPassword = conn['masterPassword'] + if (typeof legacyMasterName === 'string' && !so.masterName) so.masterName = legacyMasterName + if (typeof legacyMasterUsername === 'string' && !so.masterUsername) + so.masterUsername = legacyMasterUsername + if (typeof legacyMasterPassword === 'string' && !so.masterPassword) + so.masterPassword = legacyMasterPassword + if ('masterName' in conn) delete conn.masterName + if ('masterUsername' in conn) delete conn.masterUsername + if ('masterPassword' in conn) delete conn.masterPassword + + // v2.5.0 兼容旧版本,补充meta属性 + if (!('meta' in conn) || typeof conn.meta !== 'object' || conn.meta === null) conn.meta = {} + const meta = conn.meta as Record + const group = meta['group'] + if (group !== undefined && typeof group !== 'string') delete meta['group'] + else if (typeof group === 'string') meta['group'] = group.trim() + + // 命令映射:meta.commandMap 为 { 原命令小写: 映射名 } + const commandMap = meta['commandMap'] + if (commandMap !== undefined) { + if (!commandMap || typeof commandMap !== 'object' || Array.isArray(commandMap)) { + delete meta['commandMap'] + } else { + const cleaned: Record = {} + for (const [k, v] of Object.entries(commandMap as Record)) { + const cmd = typeof k === 'string' ? k.trim().toLowerCase() : '' + const mapped = typeof v === 'string' ? v.trim() : '' + if (cmd && mapped) cleaned[cmd] = mapped + } + if (Object.keys(cleaned).length) meta['commandMap'] = cleaned + else delete meta['commandMap'] + } + } + + // v2.7.0 兼容旧版本,补充SSH属性 + if (!('ssh' in conn) || typeof conn.ssh != 'boolean') conn.ssh = false + if (!conn.sshOption) + conn.sshOption = { + host: '', + port: 22, + loginType: 'pwd', // pwd 用户名/密码, pkfile 私钥文件 + username: '', + password: '', + pkfile: '', // 私钥文件 + passphrase: '', // 私钥密码 + } + }) +} diff --git a/src/utils/conn.ts b/src/utils/conn.ts index 9bb72657..e1ad0988 100644 --- a/src/utils/conn.ts +++ b/src/utils/conn.ts @@ -222,6 +222,31 @@ export function mergeConnGroupsFromList(connList: UiConn[], connGroups: string[] } } +/** 导入前已存在的分组名:connGroups 登记项 + 既有连接的 meta.group */ +export function collectKnownConnGroupNames(connGroups: string[], connList: UiConn[]): Set { + const known = new Set(connGroups.map(normalizeGroupName).filter(Boolean)) + for (const c of connList) { + const g = getConnGroup(c) + if (g) known.add(g) + } + return known +} + +/** 导入批次中新出现的分组设为折叠,避免 ConnGroup 对未登记键默认展开 */ +export function collapseImportedConnGroups( + imported: UiConn[], + knownGroups: Set, + expanded: Record, +): void { + for (const c of imported) { + const g = getConnGroup(c) + if (g && !knownGroups.has(g)) { + expanded[g] = false + knownGroups.add(g) + } + } +} + /** 重命名分组:同步 conn.meta、connGroups 与折叠状态键名 */ export function renameConnGroup( connList: UiConn[], diff --git a/src/utils/format.ts b/src/utils/format.ts index 1b2d70ac..045c6af5 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -244,10 +244,17 @@ export async function testCodec( // #region 视图格式与 wire 转换 /** 键重命名等基础字节视图;KeyRename、FieldAdd */ -export const BYTES_FORMAT = ['UTF8', 'Hex', 'Base64'] as const +export const BYTES_FORMAT = ['UTF8', 'Hex', 'Binary', 'Base64'] as const /** 前端值/键展示格式;RedisValue 数据编码下拉 */ -export type ViewBytesFormat = 'utf8' | 'hex' | 'base64' | 'msgpack' | 'strjson' | `custom:${string}` +export type ViewBytesFormat = + | 'utf8' + | 'hex' + | 'binary' + | 'base64' + | 'msgpack' + | 'strjson' + | `custom:${string}` export const CUSTOM_FORMAT_PREFIX = 'custom:' as const @@ -342,7 +349,7 @@ export function needsJsonNormalize(view: ViewBytesFormat): boolean { export function meFormatViewValue(wire: string, view: ViewBytesFormat): string { if (!wire || view === 'utf8') return wire if (view === 'base64') return wire - if (view === 'hex') return meFormatBytes(wire, view) + if (view === 'hex' || view === 'binary') return meFormatBytes(wire, view) if (view === 'msgpack') return meMsgpackBase64ToJson(wire) if (view === 'strjson') return meStrJsonWireToDisplay(wire) if (isCustomView(view)) { @@ -364,7 +371,7 @@ export async function meFormatViewValueAsync(wire: string, view: ViewBytesFormat export function meViewToWire(text: string, view: ViewBytesFormat): string { if (!text || view === 'utf8') return text if (view === 'base64') return text - if (view === 'hex') return meToBase64(text, view) + if (view === 'hex' || view === 'binary') return meToBase64(text, view) if (view === 'msgpack') return meJsonToMsgpackBase64(text) if (view === 'strjson') return meDisplayToStrJsonWire(text) if (isCustomView(view)) { @@ -386,6 +393,7 @@ export async function meViewToWireAsync(text: string, view: ViewBytesFormat): Pr export function meFormatBytes(base64: string, bytesFormat: string): string { if (bytesFormat === 'base64') return base64 if (bytesFormat === 'hex') return base64ToHex(base64) + if (bytesFormat === 'binary') return base64ToBinary(base64) return 'Unknown bytesFormat: ' + bytesFormat } @@ -393,6 +401,7 @@ export function meFormatBytes(base64: string, bytesFormat: string): string { export function meToBase64(bytes: string, encoding: string): string { if (encoding === 'base64') return bytes if (encoding === 'hex') return hexToBase64(bytes) + if (encoding === 'binary') return binaryToBase64(bytes) return 'Unknown encoding: ' + encoding } @@ -404,6 +413,14 @@ function base64ToHex(base64: string): string { .join('') } +function base64ToBinary(base64: string): string { + if (!base64) return '' + const binary = atob(base64) + return Array.from(binary) + .map(char => char.charCodeAt(0).toString(2).padStart(8, '0')) + .join('') +} + function hexToBase64(hex: string): string { if (!hex) return '' if (hex.length % 2 !== 0) { @@ -424,6 +441,26 @@ function hexToBase64(hex: string): string { return btoa(binary) } +function binaryToBase64(binary: string): string { + if (!binary) return '' + if (binary.length % 8 !== 0) { + throw new Error(t('util.invalidBinaryString')) + } + if (!/^[01]+$/.test(binary)) { + throw new Error(t('util.invalidBinaryCharacter')) + } + const bytes: number[] = [] + for (let i = 0; i < binary.length; i += 8) { + const byte = Number.parseInt(binary.slice(i, i + 8), 2) + if (Number.isNaN(byte)) { + throw new Error(t('util.invalidBinaryCharacter')) + } + bytes.push(byte) + } + const binaryStr = bytes.map(b => String.fromCharCode(b)).join('') + return btoa(binaryStr) +} + function base64ToUint8Array(base64: string): Uint8Array { const binary = atob(base64) const bytes = new Uint8Array(binary.length) diff --git a/src/utils/redis-glob.ts b/src/utils/redis-glob.ts new file mode 100644 index 00000000..a6d17c07 --- /dev/null +++ b/src/utils/redis-glob.ts @@ -0,0 +1,62 @@ +/** Redis SCAN MATCH 通配符:* ? [ */ +export function isRedisGlob(pattern: string): boolean { + return /[*?[]/.test(pattern) +} + +/** 转义 minimatch 特殊字符,精确模式按字面键名过滤 */ +export function escapeMinimatchLiteral(s: string): string { + return s.replace(/[\\?*[\]{}()!@+]/g, '\\$&') +} + +/** 本地 minimatch 过滤(与 KeyMain / RedisValue 表格筛选一致) */ +export const MINIMATCH_SCAN_OPTS = { + nobrace: true, + noglobstar: true, + noext: true, + nocase: true, +} as const + +/** + * 未 Enter 重扫时的本地过滤 pattern:exact 转义字面,否则用服务端 match。 + */ +export function buildLocalFilterPattern(keyword: string, exact: boolean, match: string): string { + const key = keyword.trim() + if (!key) return '' + if (exact) return escapeMinimatchLiteral(key) + return match +} + +/** 与后端 scan_0_batch_count 一致:pattern 去 * 后 ≤1 字符 COUNT=1000,否则 10000 */ +export function computeScanBatchSize(match: string): number { + const stripped = match.replace(/\*/g, '') + return stripped.length <= 1 ? 1000 : 10000 +} + +/** 扫描进度环:按批次估算,finished 时 100% */ +export function computeScanProgress( + batchCount: number, + batchSize: number, + totalEstimate: number, + finished: boolean, +): number { + if (finished) return 100 + if (batchCount === 0) return 0 + if (totalEstimate > 0) { + return Math.min(99, Math.round(((batchCount * batchSize) / totalEstimate) * 100)) + } + return Math.min(99, batchCount * 5) +} + +/** + * 构建 SCAN 模式(目录扫描 loadFolder 时固定为 keyword:*) + * + * 关闭完全匹配:含 glob(* ? [)则原样,否则前后补 * + * 开启完全匹配:原样传给后端 EXISTS 判断(含 * 也按字面键名) + */ +export function buildScanPattern(keyword: string, exact: boolean, loadFolder = false): string { + if (loadFolder) return `${keyword}:*` + if (exact) return keyword + if (!keyword) return '*' + if (isRedisGlob(keyword)) return keyword + return `*${keyword}*` +} diff --git a/src/utils/settings-defaults.ts b/src/utils/settings-defaults.ts new file mode 100644 index 00000000..39af1c95 --- /dev/null +++ b/src/utils/settings-defaults.ts @@ -0,0 +1,30 @@ +/** 设置项默认值(唯一数据源,修改时请同步检查 Setting.vue 和 tauri.ts) */ +export const defaultSettings = { + language: 'system', + theme: 'system', + uiFont: [] as string[], + codeFont: [] as string[], + autoUpdate: true, + + // 扩展设置 + keyScanCount: 1000, + fieldScanCount: 100, + keyShow: 'tree', + keySort: 'count', + keyHeight: 20, + fieldShow: 'auto', // 'table' 始终表格 | 'auto' 默认表格、记住手动切换 + fieldShowView: 'table', // auto 模式下上次手动选择的 json/table,持久化供切换连接/键沿用 + // 首页连接分组(见 src/utils/conn.ts) + connShow: 'flat', // 'flat' | 'group' + connGroups: [] as string[], // 分组名有序列表 + connGroupExpanded: {} as Record, // 分组折叠状态,键为分组名(''=默认分组) + // 自定义 Codec(STRING 值编解码,见 plans/custom-formatter.md) + customCodecs: [] as { name: string; command: string }[], + codecExecTimeoutSec: 5, + // Redis 命令读写超时(秒),同步至 Rust AppSettings + commandTimeout: 30, + // STRING 类型值全量加载安全阈值(MB) + valueByteLimitMB: 1, + // STRING 类型值超过安全阈值时的预览字节数 + valuePreviewBytes: 1000, +} diff --git a/src/utils/util.ts b/src/utils/util.ts index 19abeabc..f6eb5e6e 100644 --- a/src/utils/util.ts +++ b/src/utils/util.ts @@ -359,6 +359,13 @@ const humanUnits = [ { threshold: 1024 ** 4, symbol: 'T' }, ] as const +/** MEMORY USAGE 不可用时的 String 键内存粗估:键名 + 值字节 + Redis 对象/SDS 固定开销 */ +export function estimateStringMemory(key: string, valueByteLen: number): number { + const keyBytes = new TextEncoder().encode(key).length + const OVERHEAD = 56 + return keyBytes + valueByteLen + OVERHEAD +} + export function meHumanSize(size: number, zeroShow = '0B', fractionDigits = 2): string { if (!size) return zeroShow || '' diff --git a/src/views/AppMain.vue b/src/views/AppMain.vue index 73c84327..962677e7 100644 --- a/src/views/AppMain.vue +++ b/src/views/AppMain.vue @@ -26,7 +26,12 @@ import { type UiConn, } from '@/types/me-interface' import type { ConnConfig, RedisKey_Deserialize } from '@/types/tauri-specta' -import { isConnMinimalMode, mergeConnGroupsFromList } from '@/utils/conn' +import { + collapseImportedConnGroups, + collectKnownConnGroupNames, + isConnMinimalMode, + mergeConnGroupsFromList, +} from '@/utils/conn' import { clearKeyTypeCacheForConn } from '@/utils/key-type-cache' import { mergeImportedConnList } from '@/utils/rdm' import { @@ -239,9 +244,15 @@ function onGlobalConnHotkey(e: KeyboardEvent): void { } function onConnImported(impConnList: UiConn[]): void { - share.connList = mergeImportedConnList(share.connList, impConnList) if (!Array.isArray(meTauri.settings.connGroups)) meTauri.settings.connGroups = [] + const knownGroups = collectKnownConnGroupNames(meTauri.settings.connGroups, share.connList) + share.connList = mergeImportedConnList(share.connList, impConnList) mergeConnGroupsFromList(share.connList, meTauri.settings.connGroups) + collapseImportedConnGroups( + impConnList, + knownGroups, + meTauri.settings.connGroupExpanded as Record, + ) meOk(t('conn.importOk')) } diff --git a/src/views/KeyHeader.vue b/src/views/KeyHeader.vue index c1a7085e..9348661d 100644 --- a/src/views/KeyHeader.vue +++ b/src/views/KeyHeader.vue @@ -1,33 +1,32 @@ 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 @@ + + + + + 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 + } +} @@ -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(() => { @@ -920,6 +1515,31 @@ onUnmounted(() => {
+ +

+ {{ + t('redisValue.valueTruncatedDesc', { + size: meHumanSize(redisValue?.length ?? 0), + limit: meHumanSize(VALUE_BYTE_LIMIT), + preview: VALUE_PREVIEW_BYTES, + }) + }} +

+
+ + {{ t('redisValue.valueTruncatedDismiss') }} + + + {{ t('redisValue.valueTruncatedLoadAll') }} + +
+
{ style="flex-direction: column; height: 100%" v-else @click="onFieldPanelOutsideClick"> -
- -
- -
+
+ + + -
+
+ + - + clearable /> +
+ +
+ @keyup.enter="restartFieldScan()" + v-model.trim="listIndexMin" + :placeholder="t('redisValue.listIndexMin')" + clearable /> + - +
-
+
+ + HTTL + + + {{ streamDescAsc ? t('redisValue.listSortAsc') : t('redisValue.listSortDesc') }} + { v-if="streamType"> Groups + + {{ t('redisValue.allHashKeys') }} + + + {{ t('redisValue.allHashValues') }} + + + {{ listDescAsc ? t('redisValue.listSortAsc') : t('redisValue.listSortDesc') }} + + + {{ t('redisValue.zsetRange') }} + + + + {{ t('redisValue.fieldCommands') }} + + + {{ t('redisValue.insertRow') }} @@ -975,15 +1700,19 @@ onUnmounted(() => {
+ @row-click="rowClick" + @row-dblclick="rowDblClick"> + { + -