驱动接入(全部本机真实例验收): - 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 同步
880 lines
36 KiB
TypeScript
880 lines
36 KiB
TypeScript
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>
|
||
);
|
||
}
|