feat: OpsxDB 桌面数据库客户端首版入库

含工作台/连库多引擎字段与 SSH·SSL、结果编辑、Redis 键浏览、
Tauri 桌面与 docs/落地页;排除本地构建产物与 sidecar 二进制。
This commit is contained in:
logan committed 2026-09-19 22:43:45 +08:00
commit f8740d27f7
240 files changed
+21625

No files matched your search

+249
View File
@@ -0,0 +1,249 @@
import { useMemo, useState, type ReactNode } from "react";
import { driverById } from "./catalog";
import { DriverIcon } from "./DriverIcon";
import { useWorkspaceStore, type SavedConnection } 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 [search, setSearch] = useState("");
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}`);
};
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 items-center gap-1.5">
<button type="button" className="ops-btn-primary" onClick={() => openPicker()}>
新建连接
</button>
{connections.length > 0 && (
<button type="button" className="ops-btn-secondary" onClick={() => set("view", "workbench")}>
进入工作台
</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, transparent)",
color: "var(--ops-fg, inherit)",
}}
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" onClick={() => openPicker()}>
新建连接
</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>
)}
</div>
);
}