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["notify"]; }) { const d = driverById(c.driver); return (
{!c.isDefault && ( )}
); } function SectionLabel({ children }: { children: ReactNode }) { return (
{children}
); } 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(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) => { 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 (
连接
选择或新建数据库连接
{/* R2-F5:连接配置导入/导出 */} {connections.length > 0 && ( )} void onImportFile(e)} /> {connections.length > 0 && ( )}
{connections.length > 0 && (
setSearch(e.target.value)} aria-label="搜索连接" /> {search && ( )}
)} {connections.length === 0 ? (

还没有连接

新建一个连接,保存后会出现在这里。

) : filtered.length === 0 ? (

没有匹配的连接

换个关键词试试,或清除搜索查看全部。

) : (
{recent.length > 0 && ( <> 最近 {recent.map((c) => ( ))} )} 全部{needle ? ` · ${allList.length}` : ""} {allList.map((c) => ( ))}
)} {showDrivers && setShowDrivers(false)} />}
); }