借鉴 DBX:安装检测(Node/npm/包/Go 二进制)、只读与 allow_dangerous、 连接白名单、多客户端配置片段;GET/PUT /api/ai/mcp-policy、/mcp-install; 文档 /docs/mcp 与落地页 AI/MCP 块;对照 docs/16 勾选设置项、列仍未完清单。
338 lines
16 KiB
TypeScript
338 lines
16 KiB
TypeScript
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(); }} />}
|
||
</>
|
||
);
|
||
}
|