Files
opsxdb/src/ConnectionList.tsx
T
logan 2c0bfafd8d 同步完整源码至远程:扁平化重构现状 + 未入库源文件
- 补入库:server/internal/db/dump.go、redis 分页/扫描预算测试、opsxdb-mcp main_test、
  packages/opsxdb-mcp package-lock、docs 审计与对比文档等 37 个新文件
- 含结构扁平化进行中的 80 处修改与 155 处旧结构删除(server/ src/ src-tauri/ 为当前有效源)
- 构建产物(bin/target/dist)与签名私钥仍按规则排除
2026-10-16 03:47:33 +08:00

346 lines
13 KiB
TypeScript

import { useMemo, useRef, useState, type ChangeEvent, type ReactNode } from "react";
import { driverById } from "./catalog";
import { DriverIcon } from "./DriverIcon";
import { DriverManager } from "./DriverManager";
import { useWorkspaceStore, type ConnectionGroup, type SavedConnection, DEFAULT_CONN_GROUP } from "./store";
function hostSummary(c: SavedConnection) {
const d = driverById(c.driver);
if (c.driver === "sqlite") {
return c.database || "本地文件";
}
const host = c.host || "127.0.0.1";
const port = c.port ? `:${c.port}` : "";
const db = c.database ? ` / ${c.database}` : "";
return `${host}${port}${db}`;
}
function matchesQuery(c: SavedConnection, needle: string): boolean {
if (!needle) return true;
const d = driverById(c.driver);
const hay = [c.name, c.host || "", c.driver, d?.name ?? "", c.database || "", String(c.port || "")]
.join(" ")
.toLowerCase();
return hay.includes(needle);
}
function ConnRow({
c,
onOpen,
onDelete,
setDefaultConnection,
editConnection,
notify,
}: {
c: SavedConnection;
onOpen: (c: SavedConnection) => void;
onDelete: (c: SavedConnection) => void;
setDefaultConnection: (id: string) => void;
editConnection: (c: SavedConnection) => void;
notify: ReturnType<typeof useWorkspaceStore.getState>["notify"];
}) {
const d = driverById(c.driver);
return (
<div className="ops-conn-row group">
<button type="button" className="flex min-w-0 flex-1 items-center gap-3 text-left" onClick={() => onOpen(c)}>
<DriverIcon driver={c.driver} size={22} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 truncate text-[12.5px] font-medium">
<span className="truncate">{c.name}</span>
{c.isDefault && (
<span
className="shrink-0 rounded px-1 text-[10px] font-semibold"
style={{
color: "var(--ops-accent)",
background: "color-mix(in srgb, var(--ops-accent) 14%, transparent)",
}}
>
默认
</span>
)}
</div>
<div className="truncate text-[11px]" style={{ color: "var(--ops-muted)" }}>
{d?.name ?? c.driver} · {hostSummary(c)}
</div>
</div>
</button>
<div className="flex shrink-0 items-center gap-1 opacity-0 transition group-hover:opacity-100">
{!c.isDefault && (
<button
type="button"
className="ops-btn-ghost text-[11px]"
onClick={() => {
setDefaultConnection(c.id);
notify({ kind: "success", title: "默认连接", message: `已设「${c.name}」为默认。`, mode: "toast" });
}}
>
设默认
</button>
)}
<button type="button" className="ops-btn-ghost text-[11px]" onClick={() => editConnection(c)}>
编辑
</button>
<button
type="button"
className="ops-btn-ghost text-[11px]"
style={{ color: "var(--ops-danger)" }}
onClick={() => void onDelete(c)}
>
删除
</button>
</div>
</div>
);
}
function SectionLabel({ children }: { children: ReactNode }) {
return (
<div
className="sticky top-0 z-[1] px-3 py-1 text-[10px] font-semibold uppercase tracking-wide"
style={{
color: "var(--ops-muted)",
background: "color-mix(in srgb, var(--ops-surface) 92%, transparent)",
borderBottom: "1px solid var(--ops-border)",
}}
>
{children}
</div>
);
}
const RECENT_N = 5;
export function ConnectionList() {
const connections = useWorkspaceStore((s) => s.connections);
const openPicker = useWorkspaceStore((s) => s.openPicker);
const editConnection = useWorkspaceStore((s) => s.editConnection);
const removeConnection = useWorkspaceStore((s) => s.removeConnection);
const setDefaultConnection = useWorkspaceStore((s) => s.setDefaultConnection);
const askConfirm = useWorkspaceStore((s) => s.askConfirm);
const set = useWorkspaceStore((s) => s.set);
const notify = useWorkspaceStore((s) => s.notify);
const notifyError = useWorkspaceStore((s) => s.notifyError);
const [search, setSearch] = useState("");
const [showDrivers, setShowDrivers] = useState(false);
/** R2-F5:导入文件选择 */
const importFileRef = useRef<HTMLInputElement>(null);
const needle = search.trim().toLowerCase();
const filtered = useMemo(
() =>
[...connections]
.filter((c) => matchesQuery(c, needle))
.sort((a, b) => {
if (a.isDefault && !b.isDefault) return -1;
if (!a.isDefault && b.isDefault) return 1;
return (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0);
}),
[connections, needle],
);
const recent = useMemo(() => {
const ids = new Set(filtered.map((c) => c.id));
return [...connections]
.filter((c) => (c.lastUsedAt ?? 0) > 0 && ids.has(c.id))
.sort((a, b) => (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0))
.slice(0, RECENT_N);
}, [connections, filtered]);
/** 「全部」list: filtered, with recent still shown (pinned section above). */
const allList = filtered;
const onDelete = async (c: SavedConnection) => {
const ok = await askConfirm({
title: "删除连接",
message: `确认删除「${c.name}」?此操作不可撤销。`,
okLabel: "删除",
danger: true,
});
if (!ok) return;
removeConnection(c.id);
notify({ kind: "success", title: "已删除", message: `「${c.name}」已移除。`, mode: "toast" });
};
const onOpen = (c: SavedConnection) => {
set("activeConnectionId", c.id);
set("view", "workbench");
set("status", `已选择 ${c.name}`);
};
/** R2-F5:导出全部连接 —— 原样导出存储形态(含分组结构与加密态 secrets),文件头带版本 */
const exportAll = () => {
const st = useWorkspaceStore.getState();
const payload = {
exportedAt: new Date().toISOString(),
version: 1,
connections: st.connections,
connGroups: st.connGroups,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `opsxdb-connections-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(a.href);
notify({ kind: "success", title: "已导出", message: `${st.connections.length} 条连接`, mode: "toast", durationMs: 1600, feedback: "visible" });
};
/** R2-F5:导入连接 —— 校验数组结构;id 冲突重新生成后追加;持久化走 store 现有 action */
const onImportFile = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
try {
const text = await file.text();
const parsed = JSON.parse(text) as { connections?: unknown; connGroups?: unknown } | unknown[];
const arr = Array.isArray(parsed) ? parsed : (parsed as { connections?: unknown }).connections;
if (!Array.isArray(arr)) throw new Error("不是有效的连接导出文件(缺少 connections 数组)");
const st = useWorkspaceStore.getState();
const existingIds = new Set(st.connections.map((c) => c.id));
const groupIds = new Set(st.connGroups.map((g) => g.id));
let n = 0;
for (const raw of arr) {
if (!raw || typeof raw !== "object") continue;
const item = { ...(raw as SavedConnection) };
if (!item.name || !item.driver) continue; // 跳过非法条目
if (!item.id || existingIds.has(item.id)) item.id = crypto.randomUUID();
existingIds.add(item.id);
if (!item.groupId || !groupIds.has(item.groupId)) item.groupId = DEFAULT_CONN_GROUP;
st.upsertConnection(item);
n++;
}
if (!n) throw new Error("文件中没有可导入的连接");
if (!Array.isArray(parsed) && Array.isArray((parsed as { connGroups?: unknown }).connGroups)) {
st.importConnGroups((parsed as { connGroups: ConnectionGroup[] }).connGroups);
}
notify({ kind: "success", title: "导入完成", message: `已导入 ${n} 条连接`, mode: "toast", durationMs: 2000, feedback: "visible" });
} catch (err) {
notifyError(err, "modal");
}
};
const rowProps = { onOpen, onDelete, setDefaultConnection, editConnection, notify };
return (
<div className="ops-shell flex h-screen flex-col">
<header className="ops-chrome-header px-3">
<div className="min-w-0">
<div className="truncate text-[14px] font-semibold leading-tight">连接</div>
<div className="text-[11px]" style={{ color: "var(--ops-muted)" }}>
选择或新建数据库连接
</div>
</div>
<div className="ml-auto flex flex-wrap items-center justify-end gap-1.5">
<button
type="button"
className="ops-btn-ghost grid h-6 w-6 place-items-center p-0 text-[13px]"
title="驱动管理(agent 驱动:MongoDB / ZooKeeper / 达梦)"
aria-label="驱动管理"
onClick={() => setShowDrivers(true)}
>
⚙
</button>
{/* R2-F5:连接配置导入/导出 */}
{connections.length > 0 && (
<button
type="button"
className="ops-btn-ghost grid h-6 w-6 place-items-center p-0 text-[13px]"
title="导出全部连接(JSON,含分组与密码存储形态)"
aria-label="导出全部连接"
onClick={exportAll}
>
⤓
</button>
)}
<button
type="button"
className="ops-btn-ghost grid h-6 w-6 place-items-center p-0 text-[13px]"
title="导入连接(JSON)"
aria-label="导入连接"
onClick={() => importFileRef.current?.click()}
>
⤒
</button>
<input
ref={importFileRef}
type="file"
accept=".json,application/json"
className="hidden"
aria-label="选择连接导出文件"
onChange={(e) => void onImportFile(e)}
/>
<button type="button" className="ops-btn-primary px-3" onClick={() => openPicker()} title="新建数据库连接">
新建
</button>
{connections.length > 0 && (
<button type="button" className="ops-btn-secondary px-3" onClick={() => set("view", "workbench")} title="返回工作台">
工作台
</button>
)}
</div>
</header>
{connections.length > 0 && (
<div
className="flex shrink-0 items-center gap-2 border-b px-3"
style={{ height: 36, borderColor: "var(--ops-border)", background: "var(--ops-surface)" }}
>
<input
type="search"
className="h-7 w-full max-w-md rounded border px-2 text-[12px] outline-none"
style={{
borderColor: "var(--ops-border)",
background: "var(--ops-bg)",
color: "var(--ops-text)",
}}
placeholder="搜索名称 / 主机 / 驱动…"
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="搜索连接"
/>
{search && (
<button type="button" className="ops-btn-ghost shrink-0 text-[11px]" onClick={() => setSearch("")}>
清除
</button>
)}
</div>
)}
{connections.length === 0 ? (
<div className="ops-empty">
<h2>还没有连接</h2>
<p>新建一个连接,保存后会出现在这里。</p>
<button type="button" className="ops-btn-primary px-3" onClick={() => openPicker()} title="新建数据库连接">
新建
</button>
</div>
) : filtered.length === 0 ? (
<div className="ops-empty">
<h2>没有匹配的连接</h2>
<p>换个关键词试试,或清除搜索查看全部。</p>
<button type="button" className="ops-btn-secondary" onClick={() => setSearch("")}>
清除搜索
</button>
</div>
) : (
<div className="min-h-0 flex-1 overflow-auto">
{recent.length > 0 && (
<>
<SectionLabel>最近</SectionLabel>
{recent.map((c) => (
<ConnRow key={`recent-${c.id}`} c={c} {...rowProps} />
))}
</>
)}
<SectionLabel>全部{needle ? ` · ${allList.length}` : ""}</SectionLabel>
{allList.map((c) => (
<ConnRow key={c.id} c={c} {...rowProps} />
))}
</div>
)}
{showDrivers && <DriverManager onClose={() => setShowDrivers(false)} />}
</div>
);
}