Files
opsxdb/packages/ui/src/RedisKeyPane.tsx
T
logan a9510235bb
CI / Go server (push) Canceled after 0s
CI / Web + core packages (push) Canceled after 0s
CI / Desktop (Tauri) cargo check (push) Canceled after 0s
feat: 18 引擎驱动全家桶 + 驱动管理/离线包 + 设置中心 + 工作台交互打磨
驱动接入(全部本机真实例验收):
- Elasticsearch 原生 SQL API、InfluxDB 原生 HTTP+Flux(官方 JDBC 付费许可,实测弃用)
- ZooKeeper/MongoDB JVM agent(连接时自动下载官方驱动 jar,sha256 钉版本到 ~/.opsxdb/drivers/)
- 达梦 JDBC agent 桥就绪(待厂商 jar+实例);TiDB 真实例验收
- 协议族代码审查放行 PolarDB/TDSQL/OceanBase/Kingbase/GaussDB/Oracle
- 驱动能力模型:按驱动开关对象树分组/右键菜单/行编辑,NoSQL 树拍平(对象直挂库节点)

驱动管理:
- 驱动管理弹框(状态/预装/离线包导入/JDK 检测)+ /api/drivers/* 三接口
- opsxdb-driver 驱动商店仓库(清单/fetch.sh/pack.sh 离线包/import.sh)
- 连接时懒加载:DMG 零 Java 产物,首连自动下载(实测 ZK 九件套 1.35s),离线包自动回退

UI/交互:
- 设置中心(⚙):常规/执行/结果/驱动/快捷键/关于,左导航+右面板(对标 opsxshell)
- 实例级展开箭头(行首 SVG chevron,替代字体回退失真的 Unicode 三角)
- 树内嵌套渲染修复:对象树挂在激活连接下;工具栏窄窗口不断行(按钮 nowrap+整颗换行)
- 侧栏连接行精简(图标+连接名)、SQLite SSH 守卫、Oracle 死参数清理

质量:
- Go 协议层/离线回退单测、E2E 门控测试(ES/ZK/Influx/Mongo/TiDB/JDBC 桥)
- 驱动注册表单一事实来源 connection-types.json,README/BACKLOG/AGENTS 同步
2026-09-22 01:07:46 +08:00

880 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { execSql, getRedisKey } from "./api";
import { ensureSession, withSessionRetry } from "./ObjectTree";
import { OpsModal } from "./OpsModal";
import {
displayRedisValue,
formatRedisTtl,
redisCmd,
redisTypeColor,
tryPrettyJson,
tsLabel,
} from "./redis";
import { useWorkspaceStore } from "./store";
type ValueMode = "table" | "json";
/** 表格首屏渲染行数,超出走「加载更多」 */
const INITIAL_VISIBLE = 100;
const PAGE_STEP = 200;
/** 单元格单行摘要:JSON 压回单行、空白折叠、超长截断 —— 表格保持紧凑,完整 pretty 留给查看弹层 */
function cellSummary(raw: string, pretty: string | null): { text: string; more: boolean } {
const s = pretty ? JSON.stringify(JSON.parse(pretty)) : raw;
const one = s.replace(/\s+/g, " ").trim();
if (one.length > 160) return { text: `${one.slice(0, 160)}…`, more: true };
return { text: one, more: false };
}
type LineEdit = {
field: string;
value: string;
score: string;
isNew: boolean;
oldField?: string;
index?: number;
};
async function copyText(text: string) {
await navigator.clipboard.writeText(text);
}
function cellString(v: unknown): string {
if (v == null) return "";
return String(v);
}
export function RedisKeyPane() {
const result = useWorkspaceStore((s) => s.result);
const resultMeta = useWorkspaceStore((s) => s.resultMeta);
const connections = useWorkspaceStore((s) => s.connections);
const activeConnectionId = useWorkspaceStore((s) => s.activeConnectionId);
const notify = useWorkspaceStore((s) => s.notify);
const notifyError = useWorkspaceStore((s) => s.notifyError);
const askConfirm = useWorkspaceStore((s) => s.askConfirm);
const set = useWorkspaceStore((s) => s.set);
const conn = connections.find((c) => c.id === activeConnectionId);
const keyName = resultMeta?.table ?? "";
const dbName = resultMeta?.database ?? conn?.database ?? "0";
const type = resultMeta?.redisType || "string";
const [ttlDraft, setTtlDraft] = useState(String(resultMeta?.redisTtl ?? -1));
const [keyDraft, setKeyDraft] = useState(keyName);
const [mode, setMode] = useState<ValueMode>(type === "string" ? "json" : "table");
const [filter, setFilter] = useState("");
const [busy, setBusy] = useState(false);
const [lineEdit, setLineEdit] = useState<LineEdit | null>(null);
/** 行内 JSON 放大查看(pretty 全文) */
const [cellViewer, setCellViewer] = useState<string | null>(null);
/** string 的 JSON 视图可编辑草稿(pretty);保存时校验并压缩回写 */
const [prettyDraft, setPrettyDraft] = useState<string | null>(null);
/** 列排序:null=默认序;dir 1 升 / -1 降 */
const [sort, setSort] = useState<{ col: string; dir: 1 | -1 } | null>(null);
/** 双击单元格就地编辑:i=行号(filtered 内)、col=列 */
const [inline, setInline] = useState<{ i: number; col: string; draft: string } | null>(null);
/** 表格分批渲染:首屏条数与当前已显示条数(宽/大键不全量铺 DOM) */
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE);
const [stringDraft, setStringDraft] = useState(() => cellString(result?.rows?.[0]?.value));
useEffect(() => {
setKeyDraft(keyName);
setTtlDraft(String(resultMeta?.redisTtl ?? -1));
setStringDraft(cellString(result?.rows?.[0]?.value));
setFilter("");
setMode(type === "string" ? "json" : "table");
setVisibleCount(INITIAL_VISIBLE);
setSort(null);
setInline(null);
}, [keyName, type, resultMeta?.redisTtl, result]);
const rows = result?.rows ?? [];
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return rows;
return rows.filter((r) => Object.values(r).some((v) => String(v ?? "").toLowerCase().includes(q)));
}, [rows, filter]);
const sorted = useMemo(() => {
if (!sort) return filtered;
const { col, dir } = sort;
return [...filtered].sort((a, b) => {
const av = a[col];
const bv = b[col];
if (col === "score") return ((Number(av) || 0) - (Number(bv) || 0)) * dir;
return String(av ?? "").localeCompare(String(bv ?? ""), undefined, { numeric: true }) * dir;
});
}, [filtered, sort]);
const jsonText = useMemo(() => {
if (type === "string") {
const raw = cellString(rows[0]?.value);
return tryPrettyJson(raw) || raw;
}
if (type === "hash") {
const obj: Record<string, unknown> = {};
for (const r of rows) {
const f = cellString(r.field);
const v = r.value;
const parsed = tryPrettyJson(v);
obj[f] = parsed ? JSON.parse(parsed) : v;
}
return JSON.stringify(obj, null, 2);
}
return JSON.stringify(rows, null, 2);
}, [rows, type]);
// string 的 JSON 草稿随数据刷新同步(保存/重载后回到干净态)
useEffect(() => {
if (type === "string") setPrettyDraft(jsonText);
}, [jsonText, type]);
const reload = async () => {
if (!conn || !keyName) return;
setBusy(true);
try {
const res = await withSessionRetry(conn, (sid) => getRedisKey(conn, dbName, keyName, { sessionId: sid, limit: 500 }));
const qr = {
columns: res.columns ?? [],
rows: res.rows ?? [],
rowCount: res.rowCount ?? 0,
timeMs: res.timeMs ?? 0,
error: res.error,
truncated: res.truncated,
limit: res.limit,
};
set("result", qr);
set("resultMeta", {
database: dbName,
table: keyName,
pkCols: [],
redisType: res.type,
redisTtl: res.ttl,
});
setTtlDraft(String(res.ttl ?? -1));
setKeyDraft(keyName);
if (res.type === "string") setStringDraft(cellString(res.rows?.[0]?.value));
set("status", `${keyName} · ${res.type} · TTL ${formatRedisTtl(res.ttl)}`);
} catch (e) {
notifyError(e, "toast");
} finally {
setBusy(false);
}
};
const runCmd = async (sql: string, okMsg?: string, opts?: { reload?: boolean }) => {
if (!conn) return false;
setBusy(true);
try {
const res = await withSessionRetry(conn, (sid) => execSql(conn, sql, { sessionId: sid, database: dbName }));
if (res.error) throw new Error(res.error);
if (okMsg) notify({ kind: "success", title: okMsg, message: keyName, mode: "toast", durationMs: 1400 });
if (opts?.reload !== false) await reload();
return true;
} catch (e) {
notifyError(e, "modal");
return false;
} finally {
setBusy(false);
}
};
const saveTtl = async () => {
const n = Number(ttlDraft);
if (!Number.isFinite(n)) {
notify({ kind: "warning", title: "TTL", message: "请输入秒数,-1 表示永久", mode: "toast" });
return;
}
if (n < 0) await runCmd(redisCmd("PERSIST", keyName), "已设为永久");
else await runCmd(redisCmd("EXPIRE", keyName, String(Math.floor(n))), "已更新 TTL");
};
const rename = async () => {
const next = keyDraft.trim();
if (!next || next === keyName) return;
const ok = await runCmd(redisCmd("RENAME", keyName, next), "已重命名");
if (ok) {
set("resultMeta", {
database: dbName,
table: next,
pkCols: [],
redisType: type,
redisTtl: resultMeta?.redisTtl,
});
setKeyDraft(next);
}
};
/** 服务端游标续载:追加行而不是整键替换(hash/set=SCAN 游标;list/zset=偏移) */
const loadMore = async () => {
if (!conn || !keyName) return;
const cur = result?.nextCursor ?? 0;
if (!cur) return;
setBusy(true);
try {
const res = await withSessionRetry(conn, (sid) => getRedisKey(conn, dbName, keyName, { sessionId: sid, limit: result?.limit || 500, cursor: cur }));
if (res.error) throw new Error(res.error);
const merged = [...(result?.rows ?? []), ...(res.rows ?? [])];
set("result", {
columns: result?.columns ?? res.columns ?? [],
rows: merged,
rowCount: merged.length,
timeMs: result?.timeMs ?? res.timeMs ?? 0,
truncated: res.truncated,
nextCursor: res.nextCursor,
limit: result?.limit ?? res.limit,
});
setVisibleCount((v) => v + PAGE_STEP);
} catch (e) {
notifyError(e, "toast");
} finally {
setBusy(false);
}
};
/** 双击单元格就地编辑提交:按类型/列生成与「编辑」弹窗一致的命令 */
const commitInline = async (row: Record<string, unknown>, col: string, draft: string) => {
let sql = "";
if (type === "hash") {
if (col === "field") {
const old = cellString(row.field);
if (!draft.trim()) {
notify({ kind: "warning", title: "Hash", message: "Field 必填", mode: "toast" });
return;
}
if (draft !== old) sql = `${redisCmd("HDEL", keyName, old)}\n${redisCmd("HSET", keyName, draft, cellString(row.value))}`;
else return;
} else {
if (draft === cellString(row.value)) return;
sql = redisCmd("HSET", keyName, cellString(row.field), draft);
}
} else if (type === "zset") {
if (col === "member") {
const old = cellString(row.member);
if (draft === old) return;
sql = `${redisCmd("ZREM", keyName, old)}\n${redisCmd("ZADD", keyName, cellString(row.score), draft)}`;
} else {
if (String(draft) === cellString(row.score)) return;
if (Number.isNaN(Number(draft))) {
notify({ kind: "warning", title: "Score", message: "必须是数字", mode: "toast" });
return;
}
sql = redisCmd("ZADD", keyName, String(draft), cellString(row.member));
}
} else if (type === "set") {
const old = cellString(row.member ?? row.value);
if (draft === old) return;
sql = `${redisCmd("SREM", keyName, old)}\n${redisCmd("SADD", keyName, draft)}`;
} else if (type === "list") {
if (draft === cellString(row.value)) return;
sql = redisCmd("LSET", keyName, cellString(row.index ?? 0), draft);
}
if (!sql) return;
setInline(null);
for (const p of sql.split("\n")) {
const ok = await runCmd(p, "已更新");
if (!ok) break;
}
};
const toggleSort = (col: string) => {
setSort((s0) => (!s0 || s0.col !== col ? { col, dir: 1 } : s0.dir === 1 ? { col, dir: -1 } : null));
};
const sortTh = (col: string, label: string) => (
<button
type="button"
className="inline-flex items-center gap-0.5 hover:underline"
onClick={() => toggleSort(col)}
title="点击排序:升 → 降 → 默认"
>
{label}
{sort?.col === col ? <span className="text-[9px]">{sort.dir === 1 ? "▲" : "▼"}</span> : null}
</button>
);
/** 就地编辑输入框:Enter 提交 / Esc 取消 */
const InlineInput = ({ row, i, col, value }: { row: Record<string, unknown>; i: number; col: string; value: string }) => (
<input
autoFocus
className="ops-input h-6 w-full px-1 font-mono text-[11px]"
value={inline?.i === i && inline.col === col ? inline.draft : value}
onChange={(e) => setInline({ i, col, draft: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void commitInline(row, col, inline?.draft ?? value);
} else if (e.key === "Escape") setInline(null);
}}
onBlur={() => setInline(null)}
/>
);
const saveString = async () => {
let val = stringDraft;
if (type === "string" && mode === "json") {
// JSON 视图直接编辑:保存前校验并压回单行,避免把 pretty 缩进写进 Redis
const draft = prettyDraft ?? jsonText;
try {
val = JSON.stringify(JSON.parse(draft));
} catch (e) {
notify({
kind: "error",
title: "JSON 解析失败",
message: e instanceof Error ? e.message : String(e),
mode: "toast",
durationMs: 2600,
});
return;
}
}
await runCmd(redisCmd("SET", keyName, val), "已保存");
};
const deleteKey = async () => {
const ok = await askConfirm({
title: "删除键",
message: `确认删除 Redis 键「${keyName}」?此操作不可恢复。`,
okLabel: "删除",
danger: true,
});
if (!ok) return;
const done = await runCmd(redisCmd("DEL", keyName), "已删除", { reload: false });
if (done) {
set("result", null);
set("resultMeta", { database: dbName, pkCols: [] });
}
};
const openAdd = () => {
setLineEdit({
field: type === "hash" ? "" : type === "zset" ? "" : "",
value: "",
score: "0",
isNew: true,
});
};
const openEditRow = (row: Record<string, unknown>, index: number) => {
if (type === "hash") {
setLineEdit({
field: cellString(row.field),
value: cellString(row.value),
score: "0",
isNew: false,
oldField: cellString(row.field),
});
return;
}
if (type === "list") {
setLineEdit({ field: "", value: cellString(row.value), score: "0", isNew: false, index });
return;
}
if (type === "set") {
setLineEdit({
field: "",
value: cellString(row.member ?? row.value),
score: "0",
isNew: false,
oldField: cellString(row.member ?? row.value),
});
return;
}
if (type === "zset") {
setLineEdit({
field: cellString(row.member),
value: cellString(row.member),
score: cellString(row.score),
isNew: false,
oldField: cellString(row.member),
});
}
};
const commitLine = async () => {
if (!lineEdit) return;
let sql = "";
if (type === "hash") {
if (!lineEdit.field.trim()) {
notify({ kind: "warning", title: "Hash", message: "Field 必填", mode: "toast" });
return;
}
if (!lineEdit.isNew && lineEdit.oldField && lineEdit.oldField !== lineEdit.field) {
sql = `${redisCmd("HDEL", keyName, lineEdit.oldField)}\n${redisCmd("HSET", keyName, lineEdit.field, lineEdit.value)}`;
} else {
sql = redisCmd("HSET", keyName, lineEdit.field, lineEdit.value);
}
} else if (type === "list") {
if (lineEdit.isNew) sql = redisCmd("RPUSH", keyName, lineEdit.value);
else sql = redisCmd("LSET", keyName, String(lineEdit.index ?? 0), lineEdit.value);
} else if (type === "set") {
if (lineEdit.isNew) sql = redisCmd("SADD", keyName, lineEdit.value);
else if (lineEdit.oldField && lineEdit.oldField !== lineEdit.value) {
sql = `${redisCmd("SREM", keyName, lineEdit.oldField)}\n${redisCmd("SADD", keyName, lineEdit.value)}`;
} else sql = redisCmd("SADD", keyName, lineEdit.value);
} else if (type === "zset") {
const member = lineEdit.value || lineEdit.field;
if (lineEdit.isNew) sql = redisCmd("ZADD", keyName, lineEdit.score || "0", member);
else if (lineEdit.oldField && lineEdit.oldField !== member) {
sql = `${redisCmd("ZREM", keyName, lineEdit.oldField)}\n${redisCmd("ZADD", keyName, lineEdit.score || "0", member)}`;
} else sql = redisCmd("ZADD", keyName, lineEdit.score || "0", member);
}
if (!sql) return;
const parts = sql.split("\n");
setLineEdit(null);
for (const p of parts) {
const ok = await runCmd(p, lineEdit.isNew ? "已新增" : "已更新");
if (!ok) break;
}
};
const deleteLine = async (row: Record<string, unknown>, index: number) => {
const ok = await askConfirm({
title: "删除元素",
message: "确认删除这一行?",
okLabel: "删除",
danger: true,
});
if (!ok) return;
if (type === "hash") await runCmd(redisCmd("HDEL", keyName, cellString(row.field)), "已删除字段");
else if (type === "set") await runCmd(redisCmd("SREM", keyName, cellString(row.member ?? row.value)), "已删除成员");
else if (type === "zset") await runCmd(redisCmd("ZREM", keyName, cellString(row.member)), "已删除成员");
else if (type === "list") {
const val = cellString(row.value);
await runCmd(redisCmd("LREM", keyName, "1", val), "已删除元素");
void index;
}
};
/** 单行重建命令:复制后可直接在别处回放(zset→ZADD score member 等) */
const rowCommand = (row: Record<string, unknown>): string => {
switch (type) {
case "hash":
return redisCmd("HSET", keyName, cellString(row.field), cellString(row.value));
case "zset":
return redisCmd("ZADD", keyName, cellString(row.score), cellString(row.member));
case "set":
return redisCmd("SADD", keyName, cellString(row.member ?? row.value));
case "list":
return redisCmd("RPUSH", keyName, cellString(row.value));
default:
return cellString(row.value ?? row.member ?? "");
}
};
const dumpClipboard = async () => {
const lines: string[] = [];
if (type === "string") lines.push(redisCmd("SET", keyName, cellString(rows[0]?.value)));
else if (type === "hash") {
for (const r of rows) lines.push(redisCmd("HSET", keyName, cellString(r.field), cellString(r.value)));
} else if (type === "list") {
for (const r of rows) lines.push(redisCmd("RPUSH", keyName, cellString(r.value)));
} else if (type === "set") {
for (const r of rows) lines.push(redisCmd("SADD", keyName, cellString(r.member ?? r.value)));
} else if (type === "zset") {
for (const r of rows) lines.push(redisCmd("ZADD", keyName, cellString(r.score), cellString(r.member)));
}
const ttl = resultMeta?.redisTtl ?? -1;
if (ttl >= 0) lines.push(redisCmd("EXPIRE", keyName, String(ttl)));
await copyText(lines.join("\n") || redisCmd("GET", keyName));
notify({ kind: "success", title: "已复制命令", message: `${lines.length} 条`, mode: "toast", durationMs: 1400 });
};
const addLabel =
type === "hash" ? "新增字段" : type === "list" ? "追加元素" : type === "zset" ? "新增成员" : type === "set" ? "新增成员" : null;
const colTitle =
type === "hash" ? `ID(共 ${filtered.length})` : type === "list" ? "Index" : type === "zset" ? "#" : "#";
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b px-2 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
<span
className="rounded px-2 py-0.5 text-[11px] font-semibold uppercase"
style={{ background: `${redisTypeColor(type)}22`, color: redisTypeColor(type) }}
>
{type}
</span>
<input
className="ops-input h-7 min-w-0 flex-1 font-mono text-[12px]"
value={keyDraft}
title="回车重命名"
onChange={(e) => setKeyDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void rename();
}}
/>
<div className="flex h-7 overflow-hidden rounded border" style={{ borderColor: "var(--ops-border)" }}>
<span className="flex items-center px-2 text-[10px]" style={{ background: "var(--ops-surface-2)", color: "var(--ops-muted)" }}>
TTL
</span>
<input
className="w-20 bg-transparent px-2 text-[12px] outline-none"
value={ttlDraft}
title="秒;-1 永久。回车保存"
onChange={(e) => setTtlDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void saveTtl();
}}
/>
</div>
<button type="button" className="ops-btn-secondary h-7 px-2 text-[11px]" disabled={busy} onClick={() => void saveTtl()}>
保存 TTL
</button>
<button type="button" className="ops-btn-ghost h-7 px-2 text-[11px]" disabled={busy} title="刷新" onClick={() => void reload()}>
刷新
</button>
<button type="button" className="ops-btn-ghost h-7 px-2 text-[11px]" title="复制还原命令" onClick={() => void dumpClipboard()}>
导出命令
</button>
<button
type="button"
className="ops-btn-ghost h-7 px-2 text-[11px]"
style={{ color: "var(--ops-danger)" }}
disabled={busy}
onClick={() => void deleteKey()}
>
删除
</button>
</div>
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-2 py-1" style={{ borderColor: "var(--ops-border)" }}>
<div className="flex items-center gap-2">
{addLabel && (
<button type="button" className="ops-btn-primary h-7 px-2 text-[11px]" disabled={busy} onClick={openAdd}>
{addLabel}
</button>
)}
{type === "string" && (
<button
type="button"
className="ops-btn-primary h-7 px-2 text-[11px]"
disabled={
busy ||
(mode === "json"
? (prettyDraft ?? jsonText) === jsonText
: stringDraft === cellString(rows[0]?.value))
}
onClick={() => void saveString()}
>
保存值
</button>
)}
<div className="ops-seg">
<button type="button" className={mode === "table" ? "ops-seg-active" : undefined} onClick={() => setMode("table")}>
{type === "string" ? "原文" : "结构"}
</button>
<button type="button" className={mode === "json" ? "ops-seg-active" : undefined} onClick={() => setMode("json")}>
JSON
</button>
</div>
</div>
{type !== "string" && (
<input
className="ops-input h-7 w-48 text-[11px]"
placeholder="搜索字段或值"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
)}
</div>
{result?.truncated && (
<div
className="shrink-0 px-2 py-1 text-[11px]"
style={{ color: "var(--ops-warning)", background: "color-mix(in srgb, var(--ops-warning) 10%, transparent)" }}
>
{(result?.nextCursor ?? 0) > 0
? `已分页加载前 ${rows.length} 行,底部可继续加载。`
: `已截断,最多 ${result.limit || 500} 行。用过滤或命令缩小范围。`}
</div>
)}
<div className="min-h-0 flex-1 overflow-auto">
{type === "string" || mode === "json" ? (
type === "string" && mode === "table" ? (
<textarea
className="ops-input min-h-full w-full resize-none rounded-none border-0 p-3 font-mono text-[12px] leading-relaxed"
value={stringDraft}
spellCheck={false}
onChange={(e) => setStringDraft(e.target.value)}
/>
) : type === "string" ? (
<textarea
className="ops-input min-h-full w-full resize-none rounded-none border-0 p-3 font-mono text-[12px] leading-relaxed"
value={mode === "json" ? prettyDraft ?? jsonText : stringDraft}
spellCheck={false}
onChange={(e) => {
if (mode === "json") setPrettyDraft(e.target.value);
else setStringDraft(e.target.value);
}}
placeholder="JSON 视图可直接编辑;保存时自动校验并压缩回写"
/>
) : (
<pre className="m-0 whitespace-pre-wrap break-all p-3 font-mono text-[12px] leading-relaxed">{jsonText}</pre>
)
) : (
<table className="ops-grid" style={{ tableLayout: "fixed", width: "100%" }}>
<thead>
<tr>
<th style={{ width: 72 }}>{colTitle}</th>
{type === "hash" && <th style={{ width: "28%" }}>{sortTh("field", "Key")}</th>}
{type === "zset" && <th style={{ width: "56%" }}>{sortTh("member", "Member")}</th>}
{type === "zset" && <th style={{ width: 120 }}>{sortTh("score", "Score")}</th>}
{type !== "zset" && <th>{type === "list" ? "Value" : sortTh("value", "Value")}</th>}
<th style={{ width: 108 }} />
</tr>
</thead>
<tbody>
{sorted.slice(0, visibleCount).map((row, i) => {
const valueRaw = type === "hash" ? row.value : type === "zset" ? row.member : row.member ?? row.value;
const shown = displayRedisValue(valueRaw);
const valuePretty = tryPrettyJson(cellString(valueRaw));
const summary = cellSummary(cellString(valueRaw), valuePretty);
const scoreTs = type === "zset" ? tsLabel(Number(row.score)) : null;
return (
<tr key={i}>
<td className="ops-rownum">{type === "list" ? cellString(row.index ?? i) : i + 1}</td>
{type === "hash" && (
<td className="font-mono" title={cellString(row.field)}>
{inline?.i === i && inline.col === "field" ? (
<InlineInput row={row} i={i} col="field" value={cellString(row.field)} />
) : (
<span
className="cursor-text"
title="双击重命名"
onDoubleClick={() => setInline({ i, col: "field", draft: cellString(row.field) })}
>
{cellString(row.field)}
</span>
)}
</td>
)}
{type === "zset" && (
<td className="font-mono">
{valuePretty ? (
<button
type="button"
className="m-0 flex w-full items-baseline gap-1 p-0 text-left"
style={{ cursor: "zoom-in" }}
title="点击查看完整 JSON"
onClick={() => setCellViewer(valuePretty)}
>
<span className="min-w-0 flex-1 truncate font-mono text-[11px] leading-snug">{summary.text}</span>
{summary.more && (
<span className="shrink-0 text-[10px]" style={{ color: "var(--ops-accent)" }}>
更多
</span>
)}
</button>
) : inline?.i === i && inline.col === "member" ? (
<InlineInput row={row} i={i} col="member" value={cellString(row.member)} />
) : (
<span
className="cursor-text"
title="双击编辑"
onDoubleClick={() => setInline({ i, col: "member", draft: cellString(row.member) })}
>
{cellString(row.member)}
</span>
)}
</td>
)}
{type === "zset" && (
<td className="font-mono" title={scoreTs ?? "双击改分"}>
{inline?.i === i && inline.col === "score" ? (
<InlineInput row={row} i={i} col="score" value={cellString(row.score)} />
) : (
<div
className="cursor-text"
onDoubleClick={() => setInline({ i, col: "score", draft: cellString(row.score) })}
>
{cellString(row.score)}
{scoreTs && <div className="text-[10px] leading-3 opacity-60">{scoreTs}</div>}
</div>
)}
</td>
)}
{type !== "zset" && (
<td title={valuePretty ? "点击查看完整 JSON" : shown.pretty ?? cellString(valueRaw)}>
{valuePretty || cellString(valueRaw).length > 180 ? (
<button
type="button"
className="m-0 flex w-full items-baseline gap-1 p-0 text-left"
style={{ cursor: "zoom-in" }}
onClick={() => setCellViewer(valuePretty ?? cellString(valueRaw))}
>
<span className="min-w-0 flex-1 truncate font-mono text-[11px] leading-snug">{summary.text}</span>
{summary.more && (
<span className="shrink-0 text-[10px]" style={{ color: "var(--ops-accent)" }}>
更多
</span>
)}
</button>
) : inline?.i === i && inline.col === "value" ? (
<InlineInput row={row} i={i} col="value" value={cellString(valueRaw)} />
) : (
<pre
className="m-0 max-h-24 cursor-text overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-snug"
title="双击编辑"
onDoubleClick={() => setInline({ i, col: "value", draft: cellString(valueRaw) })}
>
{shown.text}
</pre>
)}
</td>
)}
<td>
<div className="flex items-center gap-0.5">
<button
type="button"
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[10px]"
title="复制值(纯文本)"
onClick={() => {
const val = cellString(valueRaw);
void copyText(val).then(() =>
notify({ kind: "success", title: "已复制值", message: "", mode: "toast", durationMs: 1200 }),
);
}}
>
⧉
</button>
<button
type="button"
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[10px]"
title={`复制重建命令(${type === "zset" ? "ZADD key score member" : type === "hash" ? "HSET key field value" : type === "set" ? "SADD key member" : "RPUSH key value"})`}
onClick={() => {
const cmd = rowCommand(row);
void copyText(cmd).then(() =>
notify({ kind: "success", title: "已复制命令", message: cmd, mode: "toast", durationMs: 1600 }),
);
}}
>
⌘
</button>
<button
type="button"
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[10px]"
title={valuePretty ? "查看 JSON" : "查看文本"}
onClick={() => setCellViewer(valuePretty ?? cellString(valueRaw))}
>
{valuePretty ? "{}" : "☰"}
</button>
<button type="button" className="ops-btn-ghost px-1 text-[11px]" title="编辑" onClick={() => openEditRow(row, i)}>
✎
</button>
<button
type="button"
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[11px]"
style={{ color: "var(--ops-danger)" }}
title="删除此行"
onClick={() => void deleteLine(row, i)}
>
×
</button>
</div>
</td>
</tr>
);
})}
{filtered.length === 0 && (
<tr>
<td colSpan={4} className="px-3 py-6 text-center" style={{ color: "var(--ops-muted)" }}>
{rows.length === 0 ? "空值" : "无匹配"}
</td>
</tr>
)}
{(result?.nextCursor ?? 0) > 0 ? (
<tr>
<td colSpan={4} className="px-3 py-2 text-center">
<button
type="button"
className="ops-btn-ghost text-[11px]"
disabled={busy}
onClick={() => void loadMore()}
>
加载更多(服务端续载 · 已加载 {rows.length} 行)
</button>
</td>
</tr>
) : filtered.length > visibleCount ? (
<tr>
<td colSpan={4} className="px-3 py-2 text-center">
<button
type="button"
className="ops-btn-ghost text-[11px]"
onClick={() => setVisibleCount((v) => v + PAGE_STEP)}
>
加载更多(已显示 {visibleCount} / 共 {filtered.length} 行)
</button>
</td>
</tr>
) : null}
</tbody>
</table>
)}
</div>
{lineEdit && (
<OpsModal
title={lineEdit.isNew ? addLabel || "新增" : "编辑"}
variant="confirm"
okLabel="保存"
onOk={() => void commitLine()}
onCancel={() => setLineEdit(null)}
>
<div className="space-y-2">
{type === "hash" && (
<label className="block text-[12px]">
<span style={{ color: "var(--ops-muted)" }}>Field</span>
<input
className="ops-input mt-1 w-full font-mono"
value={lineEdit.field}
onChange={(e) => setLineEdit({ ...lineEdit, field: e.target.value })}
/>
</label>
)}
{type === "zset" && (
<label className="block text-[12px]">
<span style={{ color: "var(--ops-muted)" }}>Score</span>
<input
className="ops-input mt-1 w-full font-mono"
value={lineEdit.score}
onChange={(e) => setLineEdit({ ...lineEdit, score: e.target.value })}
/>
</label>
)}
<label className="block text-[12px]">
<span style={{ color: "var(--ops-muted)" }}>{type === "zset" ? "Member" : "Value"}</span>
<textarea
className="ops-input mt-1 min-h-[120px] w-full font-mono text-[12px]"
value={lineEdit.value}
spellCheck={false}
onChange={(e) => setLineEdit({ ...lineEdit, value: e.target.value })}
/>
</label>
</div>
</OpsModal>
)}
{cellViewer != null && (
<OpsModal
title="JSON 查看"
variant="confirm"
okLabel="复制 JSON"
onOk={() => {
void copyText(cellViewer).then(() =>
notify({ kind: "success", title: "已复制 JSON", message: "", mode: "toast", durationMs: 1200 }),
);
setCellViewer(null);
}}
onCancel={() => setCellViewer(null)}
>
<pre
className="m-0 max-h-[56vh] overflow-auto whitespace-pre-wrap break-all rounded border p-2 font-mono text-[11px] leading-snug"
style={{ borderColor: "var(--ops-border)", background: "var(--ops-bg-soft, transparent)" }}
>
{cellViewer}
</pre>
</OpsModal>
)}
</div>
);
}