Files
opsxdb/src/SettingsModal.tsx
T
logan 1513f4bc2d feat(mcp): 设置 → MCP 子页 + 策略 API + 官网/文档接入
借鉴 DBX:安装检测(Node/npm/包/Go 二进制)、只读与 allow_dangerous、
连接白名单、多客户端配置片段;GET/PUT /api/ai/mcp-policy、/mcp-install;
文档 /docs/mcp 与落地页 AI/MCP 块;对照 docs/16 勾选设置项、列仍未完清单。
2026-10-15 14:13:20 +08:00

338 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
const WECHAT_GROUP_URL = "https://docs.qq.com/doc/DTFpxcndud0xPd1hX";
const DRIVER_STORE_URL = "https://gitee.com/attacker/opsxdb-driver";
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 }) {
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]" style={{ color: "var(--ops-muted)" }}>{hint}</div>}
</div>
);
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
className="h-4.5 w-9 rounded-full border transition"
style={{
height: 18,
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 ? 22 : 3, boxShadow: "0 1px 2px rgba(0,0,0,.3)" }}
/>
</button>
);
}
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: Array<{ v: T; label: string }>; onChange: (v: T) => void }) {
return (
<div className="ops-seg flex">
{options.map((o) => (
<button
key={String(o.v)}
type="button"
className={value === o.v ? "ops-tree-item-active" : ""}
style={{ fontSize: "var(--ops-text-xs)" }}
onClick={() => onChange(o.v)}
>
{o.label}
</button>
))}
</div>
);
}
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");
const theme = useWorkspaceStore((s) => s.theme);
const showAi = useWorkspaceStore((s) => s.showAi);
const stopOnError = useWorkspaceStore((s) => s.stopOnError);
const execTimeoutMs = useWorkspaceStore((s) => s.execTimeoutMs);
const resultRatio = useWorkspaceStore((s) => s.resultRatio);
const set = useWorkspaceStore((s) => s.set);
const notify = useWorkspaceStore((s) => s.notify);
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(() => {
reloadDrivers();
void health().then((h) => setVersion(String((h as { version?: string }).version ?? ""))).catch(() => {});
}, []);
const resetLayout = () => {
set("leftWidth", 260);
set("aiWidth", 280);
set("resultRatio", 0.38);
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 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)}
/>
{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 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: "浅色" },
{ v: "system" as const, label: "随系统" },
]}
onChange={(v) => set("theme", v)}
/>
</Row>
<Row label="显示 AI 面板">
<Toggle checked={showAi} onChange={(v) => set("showAi", v)} />
</Row>
<Row label="面板布局">
<button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" onClick={resetLayout}>
恢复默认
</button>
</Row>
</>
)}
{active === "execution" && (
<>
<Row label="默认执行超时" hint="单语句执行上限,超时由服务端强制中断">
<Seg
value={execTimeoutMs}
options={[
{ v: 0, label: "无" },
{ v: 10000, label: "10s" },
{ v: 30000, label: "30s" },
{ v: 60000, label: "60s" },
{ v: 300000, label: "5min" },
]}
onChange={(v) => set("execTimeoutMs", v)}
/>
</Row>
<Row label="多语句遇错即停">
<Toggle checked={stopOnError} onChange={(v) => set("stopOnError", v)} />
</Row>
<Row label="危险语句确认" hint="DROP/TRUNCATE/无 WHERE 删除等由服务端权威拦截,执行前需二次确认(不可关闭)">
<span className="text-[11px]" style={{ color: "var(--ops-accent)" }}>始终开启</span>
</Row>
</>
)}
{active === "results" && (
<>
<Row label={`结果面板占比 ${Math.round(resultRatio * 100)}%`}>
<input
type="range"
min={25}
max={60}
value={Math.round(resultRatio * 100)}
onChange={(e) => set("resultRatio", Number(e.target.value) / 100)}
style={{ accentColor: "var(--ops-accent)", width: 120 }}
/>
</Row>
<Row label="行数上限" hint="查询默认 1000 行、硬上限 10000 行(超出截断提示),由服务端强制执行">
<span className="text-[11px]" style={{ color: "var(--ops-muted)" }}>1000 / 10000</span>
</Row>
</>
)}
{active === "drivers" && (
<>
<div className="space-y-1.5">
{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>
<span style={{ color: d.allInstalled ? "var(--ops-accent)" : "var(--ops-warning)" }}>
{d.allInstalled ? "已安装" : d.license === "proprietary" ? "需自备 jar" : "未安装"}
</span>
</div>
))}
</div>
<Row label="驱动管理">
<button
type="button"
className="ops-btn-secondary h-6 px-2 text-[11px]"
onClick={() => setShowDriverManager(true)}
>
去管理
</button>
</Row>
</>
)}
{active === "shortcuts" && (
<div className="space-y-1.5">
{[
[`${MOD} + Enter`, "执行当前语句或选区"],
[`${MOD} + T`, "新建查询"],
[`${MOD} + W`, "关闭查询"],
[`${MOD} + 1-9`, "切换到第 N 个查询"],
["Ctrl + Tab", "循环切换查询"],
[`${MOD} + ⇧ + F`, "聚焦对象树过滤框"],
[`${MOD} + B`, "显示/隐藏 AI"],
[`${MOD} + /`, "注释 / 取消注释"],
[`${MOD} + ⇧ + /`, "快捷键帮助"],
["双击表名", "打开前 100 行"],
["双击单元格", "进入行编辑"],
].map(([k, v]) => (
<div key={k} className="flex justify-between gap-3 text-[12px]">
<span className="font-mono" style={{ color: "var(--ops-accent)" }}>{k}</span>
<span style={{ color: "var(--ops-muted)" }}>{v}</span>
</div>
))}
</div>
)}
{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(); }} />}
</>
);
}