346 lines
13 KiB
TypeScript
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 });
|
|
};
|
|
|
|
/** 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 });
|
|
} 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>
|
|
);
|
|
}
|