feat(mcp): 设置 → MCP 子页 + 策略 API + 官网/文档接入
借鉴 DBX:安装检测(Node/npm/包/Go 二进制)、只读与 allow_dangerous、 连接白名单、多客户端配置片段;GET/PUT /api/ai/mcp-policy、/mcp-install; 文档 /docs/mcp 与落地页 AI/MCP 块;对照 docs/16 勾选设置项、列仍未完清单。
This commit is contained in:
1 parent
5416e86379
commit
1513f4bc2d
20 files changed
+1567
-98
No files matched your search
@@ -0,0 +1,397 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Check, Copy, ExternalLink, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
getMcpInstall,
|
||||
getMcpPolicy,
|
||||
saveMcpPolicy,
|
||||
type McpInstallInfo,
|
||||
type McpPolicy,
|
||||
} from "./api";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
import { openExternal } from "./openExternal";
|
||||
|
||||
const MCP_DOCS_URL = "http://127.0.0.1:8788/docs/mcp";
|
||||
const NPM_PKG = "@rlwj-lab/opsxdb-mcp";
|
||||
|
||||
type ClientTab = "cursor" | "claude-code" | "vscode" | "windsurf" | "codex" | "claude-desktop";
|
||||
|
||||
const CLIENT_TABS: Array<{ id: ClientTab; label: string }> = [
|
||||
{ id: "cursor", label: "Cursor" },
|
||||
{ id: "claude-code", label: "Claude Code" },
|
||||
{ id: "vscode", label: "VS Code" },
|
||||
{ id: "windsurf", label: "Windsurf" },
|
||||
{ id: "codex", label: "Codex" },
|
||||
{ id: "claude-desktop", label: "Claude Desktop" },
|
||||
];
|
||||
|
||||
function buildClientSnippet(tab: ClientTab, goBin: string, readOnly: boolean): { path: string; json: string } {
|
||||
const env: Record<string, string> = {};
|
||||
if (goBin) env.OPSXDB_MCP_BIN = goBin;
|
||||
// 权限以 ~/.opsxdb/mcp.json 为准;客户端 env 不放宽。
|
||||
void readOnly;
|
||||
|
||||
const npxServer = {
|
||||
command: "npx",
|
||||
args: ["-y", NPM_PKG],
|
||||
...(Object.keys(env).length ? { env } : {}),
|
||||
};
|
||||
const absServer = goBin
|
||||
? { command: goBin, args: [] as string[] }
|
||||
: npxServer;
|
||||
|
||||
switch (tab) {
|
||||
case "claude-code":
|
||||
return {
|
||||
path: "项目根目录 .mcp.json",
|
||||
json: JSON.stringify({ mcpServers: { opsxdb: npxServer } }, null, 2),
|
||||
};
|
||||
case "cursor":
|
||||
return {
|
||||
path: "~/.cursor/mcp.json 或项目 .cursor/mcp.json",
|
||||
json: JSON.stringify({ mcpServers: { opsxdb: npxServer } }, null, 2),
|
||||
};
|
||||
case "vscode":
|
||||
return {
|
||||
path: ".vscode/mcp.json",
|
||||
json: JSON.stringify(
|
||||
{
|
||||
servers: {
|
||||
opsxdb: {
|
||||
type: "stdio",
|
||||
command: npxServer.command,
|
||||
args: npxServer.args,
|
||||
...(npxServer.env ? { env: npxServer.env } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
};
|
||||
case "windsurf":
|
||||
return {
|
||||
path: "~/.codeium/windsurf/mcp_config.json",
|
||||
json: JSON.stringify({ mcpServers: { opsxdb: npxServer } }, null, 2),
|
||||
};
|
||||
case "codex":
|
||||
return {
|
||||
path: "~/.codex/config.toml(MCP 段)或对应 mcp.json",
|
||||
json: JSON.stringify({ mcpServers: { opsxdb: absServer } }, null, 2),
|
||||
};
|
||||
case "claude-desktop":
|
||||
return {
|
||||
path: "Claude Desktop → Developer → Edit Config",
|
||||
json: JSON.stringify({ mcpServers: { opsxdb: npxServer } }, null, 2),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const map: Record<string, { label: string; color: string; bg: string }> = {
|
||||
ready: { label: "已就绪", color: "var(--ops-accent)", bg: "color-mix(in srgb, var(--ops-accent) 14%, transparent)" },
|
||||
partial: { label: "部分就绪", color: "var(--ops-warning, #d97706)", bg: "color-mix(in srgb, #d97706 14%, transparent)" },
|
||||
not_installed: { label: "未安装", color: "var(--ops-warning, #d97706)", bg: "color-mix(in srgb, #d97706 14%, transparent)" },
|
||||
};
|
||||
const m = map[status] ?? map.not_installed;
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium"
|
||||
style={{ color: m.color, background: m.bg }}
|
||||
>
|
||||
{m.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children, hint }: { label: string; children: React.ReactNode; hint?: string }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-[12.5px]" style={{ color: "var(--ops-text)" }}>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
{hint && <div className="text-[11px] leading-relaxed" style={{ color: "var(--ops-muted)" }}>{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
className="rounded-full border transition disabled:opacity-50"
|
||||
style={{
|
||||
height: 18,
|
||||
width: 36,
|
||||
borderColor: "var(--ops-border)",
|
||||
background: checked ? "var(--ops-accent)" : "var(--ops-surface-2)",
|
||||
}}
|
||||
onClick={() => onChange(!checked)}
|
||||
>
|
||||
<span
|
||||
className="block rounded-full bg-white transition-all"
|
||||
style={{ width: 12, height: 12, marginLeft: checked ? 20 : 3, boxShadow: "0 1px 2px rgba(0,0,0,.3)" }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 设置 → MCP:安装检测、只读/危险 SQL、白名单、多客户端配置片段 */
|
||||
export function McpSettings() {
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const connections = useWorkspaceStore((s) => s.connections);
|
||||
|
||||
const [install, setInstall] = useState<McpInstallInfo | null>(null);
|
||||
const [policy, setPolicy] = useState<McpPolicy | null>(null);
|
||||
const [policyPath, setPolicyPath] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [clientTab, setClientTab] = useState<ClientTab>("cursor");
|
||||
const [copied, setCopied] = useState<"install" | "snippet" | "">("");
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
const reload = useCallback(() => setTick((n) => n + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
Promise.all([getMcpInstall(), getMcpPolicy()])
|
||||
.then(([inst, pol]) => {
|
||||
if (!alive) return;
|
||||
setInstall(inst);
|
||||
setPolicy(pol.policy);
|
||||
setPolicyPath(pol.path || "");
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setError(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [tick]);
|
||||
|
||||
const readOnly = !policy || policy.mode !== "read_write";
|
||||
const allowDangerous = !!policy?.allow_dangerous;
|
||||
const allowed = new Set(policy?.allowed_connection_ids ?? []);
|
||||
|
||||
const snippet = useMemo(
|
||||
() => buildClientSnippet(clientTab, install?.goBinaryPath || "", readOnly),
|
||||
[clientTab, install?.goBinaryPath, readOnly],
|
||||
);
|
||||
|
||||
const persist = async (next: McpPolicy) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await saveMcpPolicy(next);
|
||||
setPolicy(res.policy);
|
||||
setPolicyPath(res.path || policyPath);
|
||||
notify({ kind: "success", title: "MCP", message: "策略已保存", mode: "toast", durationMs: 1400 });
|
||||
} catch (e) {
|
||||
notify({ kind: "error", title: "MCP", message: e instanceof Error ? e.message : String(e), mode: "modal" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyText = async (text: string, which: "install" | "snippet") => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(which);
|
||||
setTimeout(() => setCopied(""), 1600);
|
||||
notify({ kind: "success", title: "已复制", message: which === "install" ? "安装命令" : "配置片段", mode: "toast", durationMs: 1200 });
|
||||
} catch {
|
||||
notify({ kind: "error", title: "复制失败", message: "请手动选中复制", mode: "toast", durationMs: 2000 });
|
||||
}
|
||||
};
|
||||
|
||||
const toggleConn = (id: string) => {
|
||||
if (!policy) return;
|
||||
const set = new Set(policy.allowed_connection_ids ?? []);
|
||||
if (set.has(id)) set.delete(id);
|
||||
else set.add(id);
|
||||
void persist({ ...policy, allowed_connection_ids: Array.from(set) });
|
||||
};
|
||||
|
||||
if (loading) return <p role="status" className="text-xs" style={{ color: "var(--ops-muted)" }}>正在检测 MCP 环境…</p>;
|
||||
if (error) {
|
||||
return (
|
||||
<div role="alert" className="space-y-2 text-xs">
|
||||
<p>{error}</p>
|
||||
<button type="button" className="ops-btn-secondary" onClick={reload}>重试</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const installCmd = install?.installCommand || `npm install -g ${NPM_PKG}@latest`;
|
||||
|
||||
return (
|
||||
<div className="space-y-5 text-[12.5px]" style={{ color: "var(--ops-text)" }}>
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-[13px] font-semibold">MCP Server</h3>
|
||||
<StatusBadge status={install?.status || "not_installed"} />
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost ml-auto inline-flex h-6 items-center gap-1 px-2 text-[11px]"
|
||||
onClick={() => void openExternal(MCP_DOCS_URL)}
|
||||
title="打开 MCP 文档"
|
||||
>
|
||||
MCP 指南 <ExternalLink className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Version grid */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
["当前版本", install?.currentVersion || (install?.goBinaryFound ? "native" : "未检测到全局安装")],
|
||||
["最新版本", install?.latestVersion ? `v${install.latestVersion}` : "—"],
|
||||
["Node.js", install?.nodeVersion || "未检测到"],
|
||||
["npm", install?.npmAvailable ? (install.npmVersion || "可用") : "不可用"],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="rounded-lg border px-2.5 py-2" style={{ borderColor: "var(--ops-border-soft)", background: "var(--ops-surface-2)" }}>
|
||||
<div className="text-[10px] uppercase tracking-wide" style={{ color: "var(--ops-muted)" }}>{k}</div>
|
||||
<div className="mt-0.5 truncate font-mono text-[11.5px]">{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{install?.goBinaryPath && (
|
||||
<p className="text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
Go 二进制:<span className="font-mono">{install.goBinaryPath}</span>
|
||||
</p>
|
||||
)}
|
||||
{install?.hint && (
|
||||
<p className="text-[11px] leading-relaxed" style={{ color: "var(--ops-muted)" }}>{install.hint}</p>
|
||||
)}
|
||||
|
||||
{/* Install command */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[12px] font-medium">安装命令</div>
|
||||
<div className="flex gap-1.5">
|
||||
<code
|
||||
className="min-w-0 flex-1 overflow-x-auto rounded border px-2 py-1.5 font-mono text-[11px]"
|
||||
style={{ borderColor: "var(--ops-border)", background: "var(--ops-surface-2)" }}
|
||||
>
|
||||
{installCmd}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-secondary inline-flex h-8 w-8 shrink-0 items-center justify-center"
|
||||
aria-label="复制安装命令"
|
||||
onClick={() => void copyText(installCmd, "install")}
|
||||
>
|
||||
{copied === "install" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed" style={{ color: "var(--ops-muted)" }}>
|
||||
npm 包是 Node 启动器;还需本机 Go 二进制(<code className="font-mono">go build -o bin/opsxdb-mcp ./cmd/opsxdb-mcp</code>),
|
||||
并在客户端配置里设置 <code className="font-mono">OPSXDB_MCP_BIN</code>。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className="space-y-3 border-t pt-3" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
<Row
|
||||
label="只读模式"
|
||||
hint="开启后 MCP/Agent 仅允许查询与元数据读取(mcp.json mode=read_only)。关闭后变为 read_write,写操作仍须确认。"
|
||||
>
|
||||
<Toggle
|
||||
checked={readOnly}
|
||||
disabled={saving || !policy}
|
||||
onChange={(v) => {
|
||||
if (!policy) return;
|
||||
void persist({ ...policy, mode: v ? "read_only" : "read_write", allow_dangerous: v ? false : policy.allow_dangerous });
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
label="允许危险 SQL"
|
||||
hint="开启后,在非只读模式下经确认可执行 DROP / TRUNCATE / ALTER / 无 WHERE 的 UPDATE·DELETE 等。默认关闭。"
|
||||
>
|
||||
<Toggle
|
||||
checked={allowDangerous}
|
||||
disabled={saving || !policy || readOnly}
|
||||
onChange={(v) => {
|
||||
if (!policy) return;
|
||||
void persist({ ...policy, allow_dangerous: v });
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
{/* Whitelist */}
|
||||
<div className="space-y-2 border-t pt-3" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
<div className="text-[12px] font-medium">连接白名单</div>
|
||||
<p className="text-[11px] leading-relaxed" style={{ color: "var(--ops-muted)" }}>
|
||||
空白名单时 MCP/Agent <strong>拒绝全部连接</strong>。勾选后写入 <code className="font-mono">~/.opsxdb/mcp.json</code>
|
||||
{policyPath ? `(${policyPath})` : ""}。
|
||||
</p>
|
||||
{connections.length === 0 ? (
|
||||
<p className="text-[11px]" style={{ color: "var(--ops-muted)" }}>暂无已保存连接。请先在侧栏新建连接。</p>
|
||||
) : (
|
||||
<div className="max-h-36 space-y-1 overflow-y-auto rounded border p-2" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
{connections.map((c) => (
|
||||
<label key={c.id} className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 hover:bg-[color-mix(in_srgb,var(--ops-accent)_8%,transparent)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowed.has(c.id)}
|
||||
disabled={saving}
|
||||
onChange={() => toggleConn(c.id)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{c.name || c.id}</span>
|
||||
<span className="shrink-0 font-mono text-[10px]" style={{ color: "var(--ops-muted)" }}>{c.driver}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Client configs */}
|
||||
<div className="space-y-2 border-t pt-3" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
<div className="text-[12px] font-medium">MCP 配置</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{CLIENT_TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={`rounded px-2 py-1 text-[11px] ${clientTab === t.id ? "ops-tree-item-active" : "ops-btn-ghost"}`}
|
||||
onClick={() => setClientTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px]" style={{ color: "var(--ops-muted)" }}>写入位置:{snippet.path}</p>
|
||||
<div className="relative">
|
||||
<pre
|
||||
className="max-h-48 overflow-auto rounded border p-2 font-mono text-[10.5px] leading-relaxed"
|
||||
style={{ borderColor: "var(--ops-border)", background: "var(--ops-surface-2)" }}
|
||||
>
|
||||
{snippet.json}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-secondary absolute right-2 top-2 inline-flex h-7 items-center gap-1 px-2 text-[11px]"
|
||||
onClick={() => void copyText(snippet.json, "snippet")}
|
||||
>
|
||||
{copied === "snippet" ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 border-t pt-3" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
<button type="button" className="ops-btn-secondary inline-flex h-7 items-center gap-1 px-2.5 text-[11px]" onClick={reload} disabled={loading}>
|
||||
<RefreshCw className="h-3 w-3" /> 重新检查
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+135
-77
@@ -1,20 +1,44 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ExternalLink,
|
||||
Info,
|
||||
Keyboard,
|
||||
Monitor,
|
||||
Puzzle,
|
||||
Server,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Table,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { driverStatus, health, type DriverStatusItem } from "./api";
|
||||
import { OpsModal } from "./OpsModal";
|
||||
import { AiSettings } from "./AiPanel";
|
||||
import { McpSettings } from "./McpSettings";
|
||||
import { DriverManager } from "./DriverManager";
|
||||
import appPackage from "../package.json";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
import { openExternal } from "./openExternal";
|
||||
|
||||
const IS_MAC = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
|
||||
const MOD = IS_MAC ? "⌘" : "Ctrl";
|
||||
|
||||
type TabId = "general" | "execution" | "results" | "drivers" | "shortcuts" | "about";
|
||||
const WECHAT_GROUP_URL = "https://docs.qq.com/doc/DTFpxcndud0xPd1hX";
|
||||
const DRIVER_STORE_URL = "https://gitee.com/attacker/opsxdb-driver";
|
||||
|
||||
const TABS: Array<{ id: TabId; icon: string; label: string; desc: string }> = [
|
||||
{ id: "general", icon: "🎨", label: "常规", desc: "主题与界面" },
|
||||
{ id: "execution", icon: "⚡", label: "执行", desc: "超时与多语句" },
|
||||
{ id: "results", icon: "📊", label: "结果", desc: "面板与分页" },
|
||||
{ id: "drivers", icon: "🧩", label: "驱动", desc: "agent 驱动状态" },
|
||||
{ id: "shortcuts", icon: "⌨️", label: "快捷键", desc: "键盘操作一览" },
|
||||
{ id: "about", icon: "ℹ️", label: "关于", desc: "版本与数据目录" },
|
||||
type TabId = "general" | "execution" | "results" | "ai" | "mcp" | "security" | "drivers" | "shortcuts" | "about";
|
||||
|
||||
const TABS: Array<{ id: TabId; icon: LucideIcon; label: string; desc: string }> = [
|
||||
{ id: "general", icon: Monitor, label: "常规", desc: "主题与界面" },
|
||||
{ id: "execution", icon: Zap, label: "执行", desc: "超时与多语句" },
|
||||
{ id: "results", icon: Table, label: "结果", desc: "面板与分页" },
|
||||
{ id: "ai", icon: Sparkles, label: "AI 助手", desc: "模型、API Key 与连接测试" },
|
||||
{ id: "mcp", icon: Server, label: "MCP", desc: "安装检测、只读与客户端配置" },
|
||||
{ id: "security", icon: Shield, label: "安全与数据", desc: "本机存储与执行保护" },
|
||||
{ id: "drivers", icon: Puzzle, label: "驱动", desc: "agent 驱动状态" },
|
||||
{ id: "shortcuts", icon: Keyboard, label: "快捷键", desc: "键盘操作一览" },
|
||||
{ id: "about", icon: Info, label: "关于", desc: "版本与资源" },
|
||||
];
|
||||
|
||||
function Row({ label, children, hint }: { label: string; children: React.ReactNode; hint?: string }) {
|
||||
@@ -69,6 +93,22 @@ function Seg<T extends string | number>({ value, options, onChange }: { value: T
|
||||
);
|
||||
}
|
||||
|
||||
function AboutLink({ label, description, onClick }: { label: string; description: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors hover:bg-[color-mix(in_srgb,var(--ops-accent)_8%,transparent)]"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12.5px] font-medium" style={{ color: "var(--ops-text)" }}>{label}</div>
|
||||
<div className="mt-0.5 text-[11px] leading-relaxed" style={{ color: "var(--ops-muted)" }}>{description}</div>
|
||||
</div>
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0" style={{ color: "var(--ops-muted)" }} aria-hidden strokeWidth={1.5} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 设置弹框:左分类导航 + 右面板(对标 opsxshell / DBeaver Preferences) */
|
||||
export function SettingsModal({ onClose }: { onClose: () => void }) {
|
||||
const [tab, setTab] = useState<TabId>("general");
|
||||
@@ -79,14 +119,20 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
|
||||
const resultRatio = useWorkspaceStore((s) => s.resultRatio);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const openConnectionList = useWorkspaceStore((s) => s.openConnectionList);
|
||||
const [showDriverManager, setShowDriverManager] = useState(false);
|
||||
const [driverError, setDriverError] = useState("");
|
||||
|
||||
const [drivers, setDrivers] = useState<DriverStatusItem[] | null>(null);
|
||||
const [navSearch, setNavSearch] = useState("");
|
||||
const [version, setVersion] = useState<string>("");
|
||||
|
||||
const reloadDrivers = () => {
|
||||
setDriverError("");
|
||||
setDrivers(null);
|
||||
void driverStatus().then(setDrivers).catch(() => setDriverError("驱动状态加载失败,请确认本机服务已启动。"));
|
||||
};
|
||||
useEffect(() => {
|
||||
void driverStatus().then(setDrivers).catch(() => setDrivers([]));
|
||||
reloadDrivers();
|
||||
void health().then((h) => setVersion(String((h as { version?: string }).version ?? ""))).catch(() => {});
|
||||
}, []);
|
||||
|
||||
@@ -97,59 +143,74 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
|
||||
notify({ kind: "success", title: "已重置", message: "面板布局恢复默认", mode: "toast", durationMs: 1400 });
|
||||
};
|
||||
|
||||
const visibleTabs = TABS.filter((t) => (t.label + t.desc + (t.id === "general" ? "主题 深色 浅色 布局" : "") + (t.id === "mcp" ? "mcp cursor claude npm 只读 白名单" : "") + (t.id === "ai" ? "deepseek 模型 agent" : "")).toLowerCase().includes(navSearch.trim().toLowerCase()));
|
||||
const activeTab = visibleTabs.find((t) => t.id === tab) ?? visibleTabs[0];
|
||||
const active = activeTab?.id;
|
||||
|
||||
return (
|
||||
<OpsModal variant="info" title="设置" onOk={onClose}>
|
||||
<div className="flex" style={{ minWidth: 560, minHeight: 340 }}>
|
||||
{/* 左导航:搜索 + 图标主标题 + 副文案(对齐 opsxshell 质感) */}
|
||||
<nav className="w-52 shrink-0 border-r pr-1" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
<>
|
||||
<OpsModal wide variant="info" title="设置" okLabel="完成" onOk={onClose}>
|
||||
<div className="flex" style={{ height: "min(520px, 65vh)", minHeight: 260 }}>
|
||||
{/* 左导航:搜索 + 线框图标 + 主标题/副文案(对齐 opsxshell) */}
|
||||
<nav aria-label="设置分类" className="w-44 shrink-0 overflow-y-auto border-r pr-3" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
<input
|
||||
className="ops-input mb-2 h-7 w-full text-[12px]"
|
||||
placeholder="搜索设置…"
|
||||
aria-label="搜索设置"
|
||||
value={navSearch}
|
||||
onChange={(e) => setNavSearch(e.target.value)}
|
||||
/>
|
||||
{TABS.filter((t) => {
|
||||
const q = navSearch.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return (t.label + t.desc).toLowerCase().includes(q);
|
||||
}).map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded px-2 py-2 text-left transition ${
|
||||
tab === t.id
|
||||
? "bg-[color-mix(in_srgb,var(--ops-accent)_12%,transparent)]"
|
||||
: "opacity-75 hover:opacity-100"
|
||||
}`}
|
||||
style={tab === t.id ? { boxShadow: "inset 2px 0 0 var(--ops-accent)" } : undefined}
|
||||
onClick={() => setTab(t.id)}
|
||||
>
|
||||
<span className="w-4 text-center text-[13px]" style={tab === t.id ? { color: "var(--ops-accent)" } : { color: "var(--ops-muted)" }}>
|
||||
{t.icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[12px] font-medium">{t.label}</span>
|
||||
<span className="block text-[10px]" style={{ color: "var(--ops-muted)" }}>{t.desc}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{visibleTabs.map((t) => {
|
||||
const Icon = t.icon;
|
||||
const selected = active === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded px-2 py-2 text-left transition ${
|
||||
selected
|
||||
? "bg-[color-mix(in_srgb,var(--ops-accent)_12%,transparent)]"
|
||||
: "opacity-75 hover:opacity-100"
|
||||
}`}
|
||||
style={selected ? { boxShadow: "inset 2px 0 0 var(--ops-accent)" } : undefined}
|
||||
aria-current={selected ? "page" : undefined}
|
||||
onClick={() => setTab(t.id)}
|
||||
>
|
||||
<span className="grid h-4 w-4 shrink-0 place-items-center" style={{ color: selected ? "var(--ops-accent)" : "var(--ops-muted)" }}>
|
||||
<Icon className="h-4 w-4" strokeWidth={1.5} aria-hidden />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[12px] font-medium">{t.label}</span>
|
||||
<span className="block text-[10px]" style={{ color: "var(--ops-muted)" }}>{t.desc}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 右面板 */}
|
||||
<div className="min-w-0 flex-1 space-y-4 pl-4">
|
||||
{tab === "general" && (
|
||||
<div className="min-w-0 flex-1 space-y-4 overflow-y-auto pl-4 pr-1">
|
||||
{activeTab ? <div className="border-b pb-3" style={{ borderColor: "var(--ops-border-soft)" }}><h2 className="text-base font-semibold">{activeTab.label}</h2></div> : <div className="py-12 text-center"><p>没有匹配的设置</p><button className="ops-btn-secondary mt-3" onClick={() => setNavSearch("")}>清空搜索</button></div>}
|
||||
{active === "ai" && <AiSettings onClose={() => setTab("general")} />}
|
||||
{active === "mcp" && <McpSettings />}
|
||||
{active === "security" && <div className="space-y-4 text-xs leading-relaxed"><Row label="危险语句确认" hint="DROP、TRUNCATE 和无 WHERE 删除等操作,执行前需确认。"><span>始终开启</span></Row><Row label="连接与查询" hint="连接信息、查询标签与界面偏好保存在本机。连接密码目前仅作混淆保存,并非系统钥匙串加密。"><span>本机存储</span></Row><Row label="AI 数据" hint="使用 AI 时,输入的问题与选取的结构上下文会发送至所配置的 AI 服务。API Key 保存后不回显明文。"><span>按需发送</span></Row></div>}
|
||||
{active === "general" && (
|
||||
<>
|
||||
<Row label="主题">
|
||||
<Seg
|
||||
value={theme}
|
||||
options={[{ v: "dark" as const, label: "深色" }, { v: "light" as const, label: "浅色" }]}
|
||||
options={[
|
||||
{ v: "dark" as const, label: "深色" },
|
||||
{ v: "light" as const, label: "浅色" },
|
||||
{ v: "system" as const, label: "随系统" },
|
||||
]}
|
||||
onChange={(v) => set("theme", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="显示 AI 面板" hint="工作台右侧的 AI 助手面板">
|
||||
<Row label="显示 AI 面板">
|
||||
<Toggle checked={showAi} onChange={(v) => set("showAi", v)} />
|
||||
</Row>
|
||||
<Row label="面板布局" hint="侧栏宽度、AI 面板宽度、结果面板占比">
|
||||
<Row label="面板布局">
|
||||
<button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" onClick={resetLayout}>
|
||||
恢复默认
|
||||
</button>
|
||||
@@ -157,7 +218,7 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "execution" && (
|
||||
{active === "execution" && (
|
||||
<>
|
||||
<Row label="默认执行超时" hint="单语句执行上限,超时由服务端强制中断">
|
||||
<Seg
|
||||
@@ -172,7 +233,7 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
|
||||
onChange={(v) => set("execTimeoutMs", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="多语句遇错即停" hint="一次执行多条时,某条失败即停止后续">
|
||||
<Row label="多语句遇错即停">
|
||||
<Toggle checked={stopOnError} onChange={(v) => set("stopOnError", v)} />
|
||||
</Row>
|
||||
<Row label="危险语句确认" hint="DROP/TRUNCATE/无 WHERE 删除等由服务端权威拦截,执行前需二次确认(不可关闭)">
|
||||
@@ -181,9 +242,9 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "results" && (
|
||||
{active === "results" && (
|
||||
<>
|
||||
<Row label={`结果面板占比 ${Math.round(resultRatio * 100)}%`} hint="编辑器与结果面板的高度分配">
|
||||
<Row label={`结果面板占比 ${Math.round(resultRatio * 100)}%`}>
|
||||
<input
|
||||
type="range"
|
||||
min={25}
|
||||
@@ -199,10 +260,12 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "drivers" && (
|
||||
{active === "drivers" && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
{drivers === null && <div className="text-[11px]" style={{ color: "var(--ops-muted)" }}>加载中…</div>}
|
||||
{driverError && <div role="alert" className="text-xs">{driverError}<button className="ops-btn-secondary ml-2" onClick={reloadDrivers}>重试</button></div>}
|
||||
{drivers?.length === 0 && <p className="text-xs">暂无需要管理的驱动。</p>}
|
||||
{!driverError && drivers === null && <div className="text-[11px]" style={{ color: "var(--ops-muted)" }}>加载中…</div>}
|
||||
{drivers?.map((d) => (
|
||||
<div key={d.id} className="flex items-center justify-between rounded border px-2 py-1.5 text-[11.5px]" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
<span>{d.id}</span>
|
||||
@@ -212,15 +275,11 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Row label="驱动管理" hint="预装下载、离线包导入、JDK 检测">
|
||||
<Row label="驱动管理">
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-secondary h-6 px-2 text-[11px]"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
openConnectionList();
|
||||
notify({ kind: "info", title: "驱动管理", message: "连接列表页右上角 ⚙ 打开驱动管理", mode: "toast", durationMs: 2600 });
|
||||
}}
|
||||
onClick={() => setShowDriverManager(true)}
|
||||
>
|
||||
去管理
|
||||
</button>
|
||||
@@ -228,7 +287,7 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "shortcuts" && (
|
||||
{active === "shortcuts" && (
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
[`${MOD} + Enter`, "执行当前语句或选区"],
|
||||
@@ -238,7 +297,8 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
|
||||
["Ctrl + Tab", "循环切换查询"],
|
||||
[`${MOD} + ⇧ + F`, "聚焦对象树过滤框"],
|
||||
[`${MOD} + B`, "显示/隐藏 AI"],
|
||||
[`${MOD} + /`, "快捷键帮助"],
|
||||
[`${MOD} + /`, "注释 / 取消注释"],
|
||||
[`${MOD} + ⇧ + /`, "快捷键帮助"],
|
||||
["双击表名", "打开前 100 行"],
|
||||
["双击单元格", "进入行编辑"],
|
||||
].map(([k, v]) => (
|
||||
@@ -250,30 +310,28 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "about" && (
|
||||
<div className="space-y-2 text-[12px]" style={{ color: "var(--ops-text)" }}>
|
||||
<div className="flex justify-between"><span style={{ color: "var(--ops-muted)" }}>版本</span><span className="font-mono">{version || "—"}</span></div>
|
||||
<div className="flex justify-between"><span style={{ color: "var(--ops-muted)" }}>形态</span><span>桌面客户端(Tauri 2)</span></div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span style={{ color: "var(--ops-muted)" }}>驱动目录</span>
|
||||
<span className="truncate font-mono" title="~/.opsxdb/drivers">~/.opsxdb/drivers</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span style={{ color: "var(--ops-muted)" }}>驱动商店</span>
|
||||
<a
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
href="https://gitee.com/attacker/opsxdb-driver"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
gitee.com/attacker/opsxdb-driver
|
||||
</a>
|
||||
{active === "about" && (
|
||||
<div className="max-w-xl space-y-5 text-[12px]" style={{ color: "var(--ops-text)" }}>
|
||||
<p className="px-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
<span className="font-mono">OpsxDB {appPackage.version}</span>
|
||||
<span aria-hidden="true"> · </span>
|
||||
桌面客户端
|
||||
<span aria-hidden="true"> · </span>
|
||||
本机服务 {version || "未连接"}
|
||||
</p>
|
||||
<div>
|
||||
<div className="mb-2 px-1 text-[11px] font-medium" style={{ color: "var(--ops-muted)" }}>资源</div>
|
||||
<div className="divide-y divide-[color:var(--ops-border-soft)] overflow-hidden rounded-lg border" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
<AboutLink label="交流群" description="问题反馈与版本动态" onClick={() => void openExternal(WECHAT_GROUP_URL)} />
|
||||
<AboutLink label="驱动商店" description="查看可用驱动与安装说明" onClick={() => void openExternal(DRIVER_STORE_URL)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</OpsModal>
|
||||
{showDriverManager && <DriverManager onClose={() => { setShowDriverManager(false); reloadDrivers(); }} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+50
@@ -684,14 +684,64 @@ export type McpStatus = {
|
||||
mode: string;
|
||||
whitelistCount: number;
|
||||
writesEnabled: boolean;
|
||||
allowDangerous?: boolean;
|
||||
active: boolean;
|
||||
recent?: { time: string; tool: string; ok: boolean; summary?: string; error?: string; source?: string }[];
|
||||
policyPath?: string;
|
||||
};
|
||||
|
||||
export type McpPolicy = {
|
||||
allowed_connection_ids: string[];
|
||||
mode: "read_only" | "read_write" | string;
|
||||
allow_dangerous?: boolean;
|
||||
default_limit?: number;
|
||||
max_limit?: number;
|
||||
};
|
||||
|
||||
export type McpInstallInfo = {
|
||||
status: "ready" | "partial" | "not_installed" | string;
|
||||
installed: boolean;
|
||||
currentVersion?: string;
|
||||
latestVersion?: string;
|
||||
nodeVersion?: string;
|
||||
npmAvailable: boolean;
|
||||
npmVersion?: string;
|
||||
npmPackageInstalled: boolean;
|
||||
npmPackageVersion?: string;
|
||||
goBinaryFound: boolean;
|
||||
goBinaryPath?: string;
|
||||
installCommand: string;
|
||||
npxCommand?: string;
|
||||
policyPath?: string;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
export async function getMcpStatus(signal?: AbortSignal) {
|
||||
return get<McpStatus>("/api/ai/mcp-status", signal);
|
||||
}
|
||||
|
||||
export async function getMcpPolicy(signal?: AbortSignal) {
|
||||
return get<{ policy: McpPolicy; path?: string }>("/api/ai/mcp-policy", signal);
|
||||
}
|
||||
|
||||
export async function saveMcpPolicy(policy: McpPolicy, signal?: AbortSignal) {
|
||||
const res = await fetch(`${API}/api/ai/mcp-policy`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify(policy),
|
||||
signal,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error((data as { error?: string }).error || res.statusText || "request failed");
|
||||
}
|
||||
return data as { ok: boolean; policy: McpPolicy; path?: string };
|
||||
}
|
||||
|
||||
export async function getMcpInstall(signal?: AbortSignal) {
|
||||
return get<McpInstallInfo>("/api/ai/mcp-install", signal);
|
||||
}
|
||||
|
||||
export async function confirmAiAgentWrite(payload: {
|
||||
connectionId: string;
|
||||
sql?: string;
|
||||
|
||||
Reference in new issue
Block a user