feat: OpsxDB 桌面数据库客户端首版入库
含工作台/连库多引擎字段与 SSH·SSL、结果编辑、Redis 键浏览、 Tauri 桌面与 docs/落地页;排除本地构建产物与 sidecar 二进制。
This commit is contained in:
commit
f8740d27f7
240 files changed
+21625
No files matched your search
@@ -0,0 +1,233 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { driverById } from "./catalog";
|
||||
import { DriverIcon } from "./DriverIcon";
|
||||
import {
|
||||
DEFAULT_CONN_GROUP,
|
||||
useWorkspaceStore,
|
||||
type SavedConnection,
|
||||
} from "./store";
|
||||
|
||||
function cx(...xs: Array<string | false | null | undefined>) {
|
||||
return xs.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
type DropHint = { groupId: string; index: number } | null;
|
||||
|
||||
export function ConnectionSidebar({
|
||||
onSelect,
|
||||
onDelete,
|
||||
}: {
|
||||
onSelect: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const connections = useWorkspaceStore((s) => s.connections);
|
||||
const groups = useWorkspaceStore((s) => s.connGroups);
|
||||
const expanded = useWorkspaceStore((s) => s.expandedConnGroups);
|
||||
const activeId = useWorkspaceStore((s) => s.activeConnectionId);
|
||||
const addConnGroup = useWorkspaceStore((s) => s.addConnGroup);
|
||||
const renameConnGroup = useWorkspaceStore((s) => s.renameConnGroup);
|
||||
const removeConnGroup = useWorkspaceStore((s) => s.removeConnGroup);
|
||||
const moveConnection = useWorkspaceStore((s) => s.moveConnection);
|
||||
const toggleConnGroup = useWorkspaceStore((s) => s.toggleConnGroup);
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const editConnection = useWorkspaceStore((s) => s.editConnection);
|
||||
|
||||
const [dragId, setDragId] = useState<string | null>(null);
|
||||
const [dropHint, setDropHint] = useState<DropHint>(null);
|
||||
const dragIdRef = useRef<string | null>(null);
|
||||
|
||||
const byGroup = useMemo(() => {
|
||||
const map = new Map<string, SavedConnection[]>();
|
||||
for (const g of groups) map.set(g.id, []);
|
||||
for (const c of connections) {
|
||||
const gid = c.groupId || DEFAULT_CONN_GROUP;
|
||||
if (!map.has(gid)) map.set(gid, []);
|
||||
map.get(gid)!.push(c);
|
||||
}
|
||||
for (const list of map.values()) {
|
||||
list.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
}
|
||||
return map;
|
||||
}, [connections, groups]);
|
||||
|
||||
const onAddGroup = () => {
|
||||
const name = window.prompt("分组名称", "新分组");
|
||||
if (name == null) return;
|
||||
const id = addConnGroup(name);
|
||||
notify({ kind: "info", title: "分组", message: `已创建「${name.trim() || "新分组"}」`, mode: "toast" });
|
||||
void id;
|
||||
};
|
||||
|
||||
const onRenameGroup = (id: string, cur: string) => {
|
||||
if (id === DEFAULT_CONN_GROUP) return;
|
||||
const name = window.prompt("重命名分组", cur);
|
||||
if (name == null || !name.trim()) return;
|
||||
renameConnGroup(id, name);
|
||||
};
|
||||
|
||||
const onRemoveGroup = (id: string, name: string) => {
|
||||
if (id === DEFAULT_CONN_GROUP) return;
|
||||
if (!window.confirm(`删除分组「${name}」?连接将移回默认。`)) return;
|
||||
removeConnGroup(id);
|
||||
};
|
||||
|
||||
const setDrop = (groupId: string, index: number) => {
|
||||
const id = dragIdRef.current;
|
||||
if (!id) return;
|
||||
setDropHint({ groupId, index });
|
||||
};
|
||||
|
||||
const commitDrop = () => {
|
||||
const id = dragIdRef.current;
|
||||
const hint = dropHint;
|
||||
dragIdRef.current = null;
|
||||
setDragId(null);
|
||||
setDropHint(null);
|
||||
if (!id || !hint) return;
|
||||
moveConnection(id, hint.groupId, hint.index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex max-h-[42%] min-h-[120px] flex-col border-b" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<div className="ops-section-label justify-between border-b">
|
||||
<span>连接</span>
|
||||
<button type="button" className="normal-case tracking-normal hover:underline" style={{ color: "var(--ops-accent)" }} onClick={onAddGroup} title="新建分组">
|
||||
+ 分组
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-1">
|
||||
{connections.length === 0 && (
|
||||
<div className="px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
暂无连接
|
||||
</div>
|
||||
)}
|
||||
{groups.map((g) => {
|
||||
const list = byGroup.get(g.id) ?? [];
|
||||
const open = expanded.includes(g.id);
|
||||
return (
|
||||
<div
|
||||
key={g.id}
|
||||
className="mb-0.5"
|
||||
data-group-id={g.id}
|
||||
onDragOver={(e) => {
|
||||
if (!dragIdRef.current) return;
|
||||
e.preventDefault();
|
||||
setDrop(g.id, list.length);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
commitDrop();
|
||||
}}
|
||||
>
|
||||
<div className="ops-tree-item group/g cursor-pointer" onClick={() => toggleConnGroup(g.id)}>
|
||||
<span className="ops-tree-chevron w-3 shrink-0 text-center text-[10px] opacity-50">{open ? "▾" : "▸"}</span>
|
||||
<span
|
||||
className="mr-1 inline-block h-3 w-3 shrink-0 rounded-[2px] border"
|
||||
style={{ borderColor: "var(--ops-muted)", background: "color-mix(in srgb, var(--ops-muted) 25%, transparent)" }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{g.name}</span>
|
||||
<span className="text-[10px] opacity-40">{list.length}</span>
|
||||
{g.id !== DEFAULT_CONN_GROUP && (
|
||||
<span className="ml-1 hidden gap-0.5 group-hover/g:inline-flex" onClick={(e) => e.stopPropagation()}>
|
||||
<button type="button" className="px-0.5 text-[10px] opacity-60 hover:opacity-100" title="重命名" onClick={() => onRenameGroup(g.id, g.name)}>
|
||||
✎
|
||||
</button>
|
||||
<button type="button" className="px-0.5 text-[10px] opacity-60 hover:opacity-100" style={{ color: "var(--ops-danger)" }} title="删除分组" onClick={() => onRemoveGroup(g.id, g.name)}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="ml-1 border-l pl-0.5" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
{list.length === 0 && (
|
||||
<div
|
||||
className={cx("ops-drop-slot mx-0.5 my-0.5 rounded px-1.5 py-1 text-[10px]", dropHint?.groupId === g.id ? "ops-drop-slot-active" : "")}
|
||||
style={{ color: "var(--ops-muted)" }}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDrop(g.id, 0);
|
||||
}}
|
||||
>
|
||||
拖到此处
|
||||
</div>
|
||||
)}
|
||||
{list.map((c, i) => (
|
||||
<div key={c.id}>
|
||||
{dropHint?.groupId === g.id && dropHint.index === i && <div className="ops-drop-line" />}
|
||||
<div
|
||||
className={cx(
|
||||
"group flex items-center gap-0.5 rounded",
|
||||
activeId === c.id ? "ops-tree-item-active" : "",
|
||||
dragId === c.id ? "opacity-45" : "",
|
||||
)}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
dragIdRef.current = c.id;
|
||||
setDragId(c.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", c.id);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
dragIdRef.current = null;
|
||||
setDragId(null);
|
||||
setDropHint(null);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!dragIdRef.current || dragIdRef.current === c.id) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const before = e.clientY < rect.top + rect.height / 2;
|
||||
setDrop(g.id, before ? i : i + 1);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
commitDrop();
|
||||
}}
|
||||
>
|
||||
<button type="button" className="ops-tree-item min-w-0 flex-1" onClick={() => onSelect(c.id)}>
|
||||
{c.color && (
|
||||
<span className="h-3 w-1 shrink-0 rounded-sm" style={{ background: c.color }} />
|
||||
)}
|
||||
<DriverIcon driver={c.driver} size={14} />
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="mr-1 text-[10px] opacity-50">{driverById(c.driver)?.name}</span>
|
||||
{c.name}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="hidden px-0.5 text-[10px] opacity-50 group-hover:inline hover:opacity-100"
|
||||
title="编辑"
|
||||
onClick={() => editConnection(c)}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mr-0.5 hidden px-1 text-[11px] opacity-50 group-hover:inline hover:opacity-100"
|
||||
style={{ color: "var(--ops-danger)" }}
|
||||
onClick={() => onDelete(c.id)}
|
||||
title="删除连接"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{dropHint?.groupId === g.id && dropHint.index === list.length && i === list.length - 1 && (
|
||||
<div className="ops-drop-line" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user