同步完整源码至远程:扁平化重构现状 + 未入库源文件
- 补入库:server/internal/db/dump.go、redis 分页/扫描预算测试、opsxdb-mcp main_test、 packages/opsxdb-mcp package-lock、docs 审计与对比文档等 37 个新文件 - 含结构扁平化进行中的 80 处修改与 155 处旧结构删除(server/ src/ src-tauri/ 为当前有效源) - 构建产物(bin/target/dist)与签名私钥仍按规则排除
This commit is contained in:
1 parent
5c0be49217
commit
2c0bfafd8d
269 files changed
+10372
-16435
No files matched your search
+261
-254
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ArrowUp, Eraser, Loader2, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import { ArrowUp, Database, Eraser, Loader2, Pencil, Plus, Square, Trash2 } from "lucide-react";
|
||||
import {
|
||||
callAi,
|
||||
callAiAgentStream,
|
||||
@@ -7,18 +7,22 @@ import {
|
||||
getAiConfig,
|
||||
getMcpStatus,
|
||||
saveAiConfig,
|
||||
syncConnectionsToDisk,
|
||||
schemaContextFor,
|
||||
type AiAction,
|
||||
type AiAgentStepExt,
|
||||
type McpStatus,
|
||||
} from "./api";
|
||||
import { AI_PRESETS, findAiPreset, type AiAuthStyle, type AiProtocol } from "./aiPresets";
|
||||
import { aiEnterAction } from "./aiComposerKeys";
|
||||
import { useWorkspaceStore, type AiAgentStepView, type AiChatMode,
|
||||
type AiMessage, type QueryResult } from "./store";
|
||||
import { editorDatabase } from "./redisContext";
|
||||
import { OpsModal } from "./OpsModal";
|
||||
|
||||
const ACTIONS: { id: AiAction; label: string }[] = [
|
||||
{ id: "generate", label: "生成" },
|
||||
{ id: "ask", label: "自由提问" },
|
||||
{ id: "generate", label: "生成 SQL" },
|
||||
{ id: "explain", label: "解释" },
|
||||
{ id: "optimize", label: "优化" },
|
||||
{ id: "fix", label: "修复" },
|
||||
@@ -42,21 +46,9 @@ function splitContent(content: string): ContentSeg[] {
|
||||
return segs.length ? segs : [{ kind: "text", text: content }];
|
||||
}
|
||||
|
||||
/** R2-F7:等宽代码块 + 顶部「复制」「插入编辑器」小按钮 */
|
||||
function CodeBlock({ code }: { code: string }) {
|
||||
/** AI 代码建议只保留写入当前 SQL 窗口的入口。 */
|
||||
function CodeBlock({ code, connectionId, database }: { code: string; connectionId?: string; database?: string }) {
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const notifyError = useWorkspaceStore((s) => s.notifyError);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => () => { if (copiedTimer.current) clearTimeout(copiedTimer.current); }, []);
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
if (copiedTimer.current) clearTimeout(copiedTimer.current);
|
||||
copiedTimer.current = setTimeout(() => setCopied(false), 1200);
|
||||
} catch (error) { notifyError(error, "toast"); }
|
||||
};
|
||||
const insertToEditor = () => {
|
||||
const st = useWorkspaceStore.getState();
|
||||
const tab = st.tabs.find((t) => t.id === st.activeTabId);
|
||||
@@ -64,17 +56,19 @@ function CodeBlock({ code }: { code: string }) {
|
||||
st.updateActiveSql(cur.trim() ? `${cur.replace(/\s+$/, "")}\n\n${code}` : code);
|
||||
notify({ kind: "success", title: "已插入编辑器", message: "SQL 已追加到当前查询", mode: "toast", durationMs: 1400 });
|
||||
};
|
||||
const activeConnectionId = useWorkspaceStore((s) => s.activeConnectionId);
|
||||
const activeTabId = useWorkspaceStore((s) => s.activeTabId);
|
||||
const activeTab = useWorkspaceStore((s) => s.tabs.find((t) => t.id === activeTabId));
|
||||
const activeConnection = useWorkspaceStore((s) => s.connections.find((c) => c.id === activeConnectionId));
|
||||
const sameContext = !connectionId || connectionId === activeConnectionId && (!database || editorDatabase(activeConnection, activeTab) === database);
|
||||
return (
|
||||
<div className="my-1 overflow-hidden rounded border" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
<div
|
||||
className="flex items-center justify-end gap-1.5 border-b px-1.5 py-0.5"
|
||||
style={{ borderColor: "var(--ops-border-soft)" }}
|
||||
>
|
||||
<button type="button" className="text-[10px] hover:underline" style={{ color: "var(--ops-accent)" }} onClick={() => void copy()}>
|
||||
{copied ? "已复制 ✓" : "复制"}
|
||||
</button>
|
||||
<button type="button" className="text-[10px] hover:underline" style={{ color: "var(--ops-accent)" }} onClick={insertToEditor}>
|
||||
插入编辑器
|
||||
<button type="button" className="text-[10px] hover:underline disabled:opacity-50" style={{ color: "var(--ops-accent)" }} disabled={!sameContext} title={sameContext ? "追加到当前 SQL 窗口" : "请先切回生成建议时的连接和库"} onClick={insertToEditor}>
|
||||
复制到 SQL 窗口
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
@@ -87,7 +81,7 @@ function CodeBlock({ code }: { code: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function MessageContent({ content, streaming }: { content: string; streaming?: boolean }) {
|
||||
function MessageContent({ content, streaming, connectionId, database }: { content: string; streaming?: boolean; connectionId?: string; database?: string }) {
|
||||
const segs = splitContent(content);
|
||||
const cursor = streaming ? <span className="ops-ai-cursor">▌</span> : null;
|
||||
if (segs.length === 1 && segs[0].kind === "text") {
|
||||
@@ -100,7 +94,7 @@ function MessageContent({ content, streaming }: { content: string; streaming?: b
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{segs.map((s, i) => (s.kind === "code" ? <CodeBlock key={i} code={s.text} /> : <span key={i}>{s.text}</span>))}
|
||||
{segs.map((s, i) => (s.kind === "code" ? <CodeBlock key={i} code={s.text} connectionId={connectionId} database={database} /> : <span key={i}>{s.text}</span>))}
|
||||
{cursor}
|
||||
</>
|
||||
);
|
||||
@@ -169,24 +163,40 @@ function AgentSteps({ steps }: { steps: AiAgentStepView[] }) {
|
||||
function DangerConfirmBar({
|
||||
sql,
|
||||
connectionId,
|
||||
connectionName,
|
||||
database,
|
||||
kind,
|
||||
confirmBind,
|
||||
runId,
|
||||
ready,
|
||||
currentContext,
|
||||
onDone,
|
||||
}: {
|
||||
sql: string;
|
||||
connectionId?: string;
|
||||
connectionName?: string;
|
||||
database?: string;
|
||||
kind: "query" | "redis";
|
||||
confirmBind?: string;
|
||||
runId?: string;
|
||||
ready: boolean;
|
||||
currentContext: boolean;
|
||||
onDone: (ok: boolean, msg?: string) => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const presented = useRef(false);
|
||||
useEffect(() => {
|
||||
if (ready && currentContext && !presented.current) {
|
||||
presented.current = true;
|
||||
setOpen(true);
|
||||
}
|
||||
}, [ready, currentContext]);
|
||||
const run = async () => {
|
||||
if (!connectionId || busy) return;
|
||||
if (!connectionId || busy || !ready || !currentContext) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await confirmAiAgentWrite({
|
||||
connectionId,
|
||||
@@ -199,36 +209,67 @@ function DangerConfirmBar({
|
||||
});
|
||||
onDone(true);
|
||||
} catch (e) {
|
||||
onDone(false, e instanceof Error ? e.message : String(e));
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="mt-1.5 rounded border px-2 py-1.5 text-[11px]"
|
||||
className="mt-2 rounded-lg border p-3 text-xs"
|
||||
style={{ borderColor: "color-mix(in srgb, var(--ops-danger, #ef4444) 45%, var(--ops-border))", background: "color-mix(in srgb, var(--ops-danger, #ef4444) 8%, transparent)" }}
|
||||
>
|
||||
<div className="mb-1" style={{ color: "var(--ops-text)" }}>
|
||||
写操作需确认(一次一刀)
|
||||
<div className="mb-2 font-medium" style={{ color: "var(--ops-text)" }}>
|
||||
待确认写操作 · {connectionName || "当前连接"}{database ? ` / ${kind === "redis" ? "DB" : ""}${database}` : ""}
|
||||
</div>
|
||||
<pre className="mb-1.5 max-h-16 overflow-auto whitespace-pre-wrap break-all font-mono text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||||
<pre className="mb-2 max-h-24 overflow-auto whitespace-pre-wrap break-all font-mono text-xs" style={{ color: "var(--ops-muted)" }}>
|
||||
{sql}
|
||||
</pre>
|
||||
<div className="flex gap-1.5">
|
||||
<button type="button" className="ops-btn-ghost h-6 px-2 text-[11px]" disabled={busy} onClick={() => onDone(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="ops-btn-primary h-6 px-2 text-[11px]" style={{ background: "var(--ops-danger, #ef4444)" }} disabled={busy || !connectionId} onClick={() => void run()}>
|
||||
{busy ? "…" : "执行"}
|
||||
<button type="button" className="ops-btn-secondary h-7 px-2 text-xs" disabled={busy || !connectionId || !ready || !currentContext} onClick={() => setOpen(true)}>
|
||||
{busy ? "执行中…" : "确认操作"}
|
||||
</button>
|
||||
</div>
|
||||
{!currentContext && <p className="mt-2" style={{ color: "var(--ops-muted)" }}>切回该连接与数据库后确认。</p>}
|
||||
</div>
|
||||
{open && currentContext && <OpsModal
|
||||
title="确认数据库写操作"
|
||||
variant="confirm"
|
||||
danger
|
||||
okLabel={busy ? "执行中…" : "确认执行"}
|
||||
cancelLabel="取消操作"
|
||||
okDisabled={busy || !ready || !connectionId}
|
||||
onOk={() => void run()}
|
||||
onCancel={() => { if (!busy) { setOpen(false); onDone(false); } }}
|
||||
>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<span>连接:<strong>{connectionName || "当前连接"}</strong></span>
|
||||
<span>数据库:<strong>{kind === "redis" ? `DB${database || "0"}` : database || "连接默认库"}</strong></span>
|
||||
</div>
|
||||
<pre className="max-h-[35vh] overflow-auto whitespace-pre-wrap break-all rounded-lg border p-3 font-mono text-xs" style={{ borderColor: "var(--ops-border)", background: "var(--ops-surface-2)" }}>{sql}</pre>
|
||||
<p className="text-xs" style={{ color: "var(--ops-muted)" }}>仅执行本次显示的命令。删除或结构变更可能无法撤销。</p>
|
||||
{error && <p role="alert" className="text-xs" style={{ color: "var(--ops-danger)" }}>{error}</p>}
|
||||
</div>
|
||||
</OpsModal>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** C4:AI 设置(预设 Provider / Base URL / API Key / 模型 + 保存/测试),配置持久化在服务端 ~/.opsxdb/ai.json */
|
||||
export function AiSettings({ onClose }: { onClose: () => void }) {
|
||||
export type AiSettingsHandle = { save: () => Promise<boolean>; test: () => Promise<void> };
|
||||
export type AiSettingsStatus = {
|
||||
busy: boolean;
|
||||
testing: boolean;
|
||||
canSave: boolean;
|
||||
canTest: boolean;
|
||||
result: { ok: boolean; text: string } | null;
|
||||
};
|
||||
export const AiSettings = forwardRef<AiSettingsHandle, { onStatus: (status: AiSettingsStatus) => void }>(function AiSettings({ onStatus }, ref) {
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const [provider, setProvider] = useState("stub");
|
||||
@@ -243,6 +284,13 @@ export function AiSettings({ onClose }: { onClose: () => void }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [reload, setReload] = useState(0);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
useEffect(() => {
|
||||
onStatus({ busy: busy || testing, testing, canSave: !loading && !loadError && !busy && !testing,
|
||||
canTest: !loading && !loadError && !busy && !testing && provider !== "stub", result: testResult });
|
||||
}, [onStatus, busy, testing, loading, loadError, provider, testResult]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -268,6 +316,7 @@ export function AiSettings({ onClose }: { onClose: () => void }) {
|
||||
|
||||
/** 选预设:自动填 Base URL / 协议 / 鉴权 / 默认模型;已手填的非预设值会被预设覆盖 */
|
||||
const applyPreset = (id: string) => {
|
||||
setTestResult(null);
|
||||
setProvider(id);
|
||||
if (id === "stub") return;
|
||||
const preset = findAiPreset(id);
|
||||
@@ -280,6 +329,7 @@ export function AiSettings({ onClose }: { onClose: () => void }) {
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (loading || loadError || busy) return false;
|
||||
if (provider !== "stub" && !baseURL.trim()) {
|
||||
notify({ kind: "error", title: "AI 配置", message: "请填写 Base URL,或选择一个预设。", mode: "modal" });
|
||||
return false;
|
||||
@@ -299,7 +349,6 @@ export function AiSettings({ onClose }: { onClose: () => void }) {
|
||||
if (model.trim()) set("aiModel", model.trim());
|
||||
setHasKey(hasKey || !!apiKey.trim() || authStyle === "none");
|
||||
setApiKey("");
|
||||
notify({ kind: "success", title: "AI 配置", message: "已保存", mode: "toast", durationMs: 1400 });
|
||||
return true;
|
||||
} catch (e) {
|
||||
notify({ kind: "error", title: "AI 配置", message: e instanceof Error ? e.message : String(e), mode: "modal" });
|
||||
@@ -310,23 +359,22 @@ export function AiSettings({ onClose }: { onClose: () => void }) {
|
||||
};
|
||||
|
||||
const test = async () => {
|
||||
if (!(await save())) return;
|
||||
setBusy(true);
|
||||
if (busy || testing || loading || loadError || provider === "stub") return;
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
if (!(await save())) return;
|
||||
setBusy(true);
|
||||
const res = await callAi({ action: "ask", prompt: "用一句话回答:连接正常", model: model.trim() || undefined });
|
||||
notify({
|
||||
kind: "success",
|
||||
title: "AI 连接正常",
|
||||
message: res.content.slice(0, 120) || "(空响应)",
|
||||
mode: "toast",
|
||||
durationMs: 4000,
|
||||
});
|
||||
setTestResult({ ok: true, text: res.content ? "连接正常,模型已响应" : "连接正常" });
|
||||
} catch (e) {
|
||||
notify({ kind: "error", title: "AI 连接失败", message: e instanceof Error ? e.message : String(e), mode: "modal" });
|
||||
setTestResult({ ok: false, text: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
useImperativeHandle(ref, () => ({ save, test }));
|
||||
|
||||
if (loading) return <p role="status" className="text-xs">正在读取 AI 配置…</p>;
|
||||
if (loadError) return <div role="alert" className="text-xs">{loadError}<button type="button" className="ops-btn-secondary mt-2" onClick={() => setReload((n) => n + 1)}>重试</button></div>;
|
||||
@@ -335,75 +383,42 @@ export function AiSettings({ onClose }: { onClose: () => void }) {
|
||||
const isLocalHttp = baseURL.trim().startsWith("http://");
|
||||
|
||||
return (
|
||||
<fieldset disabled={busy} className="min-w-0 space-y-1.5 border-b px-1.5 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
className="ops-input py-0.5 text-[11px]"
|
||||
value={provider}
|
||||
onChange={(e) => applyPreset(e.target.value)}
|
||||
aria-label="Provider 预设"
|
||||
>
|
||||
<option value="stub">stub(离线占位)</option>
|
||||
{AI_PRESETS.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}({p.desc})</option>
|
||||
))}
|
||||
<fieldset disabled={busy || testing} className="ops-ai-settings min-w-0" onChange={() => setTestResult(null)}>
|
||||
<label className="ops-ai-settings-field">供应商
|
||||
<select className="ops-input" value={provider} onChange={(e) => applyPreset(e.target.value)} aria-label="AI 供应商">
|
||||
<option value="stub">离线体验(仅 Ask)</option>
|
||||
{AI_PRESETS.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
<input
|
||||
className="ops-input min-w-0 flex-1 py-0.5 text-[11px]"
|
||||
placeholder="模型 ID"
|
||||
value={model}
|
||||
list="ai-model-options"
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
aria-label="模型"
|
||||
/>
|
||||
<datalist id="ai-model-options">
|
||||
{presetModels.map((m) => <option key={m} value={m} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
{provider !== "stub" && (
|
||||
<>
|
||||
<input
|
||||
className="ops-input w-full py-0.5 text-[11px]"
|
||||
placeholder="Base URL,如 https://api.deepseek.com/v1"
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
aria-label="Base URL"
|
||||
/>
|
||||
<input
|
||||
className="ops-input w-full py-0.5 text-[11px]"
|
||||
type="password"
|
||||
placeholder={hasKey || authStyle === "none" ? "API Key(已配置,留空不改动)" : "API Key"}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
aria-label="API Key"
|
||||
disabled={authStyle === "none"}
|
||||
/>
|
||||
{isLocalHttp && (
|
||||
<label className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowInsecureHttp}
|
||||
onChange={(e) => setAllowInsecureHttp(e.target.checked)}
|
||||
/>
|
||||
允许 HTTP(本地/内网服务,如 Ollama、vLLM;默认仅放行 HTTPS)
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
<button type="button" className="ops-btn-primary h-6 px-2 text-[11px]" disabled={busy} onClick={() => void save()}>
|
||||
保存
|
||||
</button>
|
||||
<button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" disabled={busy} onClick={() => void test()}>
|
||||
{busy ? "…" : "测试"}
|
||||
</button>
|
||||
<button type="button" className="ops-btn-ghost ml-auto h-6 px-2 text-[11px]" onClick={onClose}>
|
||||
收起
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
{provider !== "stub" && <>
|
||||
<label className="ops-ai-settings-field">模型
|
||||
<input className="ops-input" placeholder="选择或输入模型 ID" value={model} list="ai-model-options" onChange={(e) => setModel(e.target.value)} aria-label="模型" />
|
||||
<datalist id="ai-model-options">{presetModels.map((m) => <option key={m} value={m} />)}</datalist>
|
||||
</label>
|
||||
<label className="ops-ai-settings-field">API 密钥
|
||||
<input className="ops-input" type="password" autoComplete="off" placeholder={authStyle === "none" ? "无需填写" : hasKey ? "已保存,留空沿用" : "输入 API 密钥"} value={apiKey} onChange={(e) => setApiKey(e.target.value)} disabled={authStyle === "none"} aria-label="API 密钥" />
|
||||
</label>
|
||||
<label className="ops-ai-settings-field">接口地址
|
||||
<input className="ops-input" placeholder="https://api.example.com/v1" value={baseURL} onChange={(e) => setBaseURL(e.target.value)} aria-label="接口地址" />
|
||||
</label>
|
||||
<button type="button" className="ops-ai-settings-advanced" aria-expanded={advancedOpen} onClick={() => setAdvancedOpen(!advancedOpen)}>{advancedOpen ? "收起高级选项" : "高级选项"} <span aria-hidden>{advancedOpen ? "⌃" : "⌄"}</span></button>
|
||||
{advancedOpen && <div className="ops-ai-settings-extra">
|
||||
<label className="ops-ai-settings-field">接口协议
|
||||
<select className="ops-input" value={protocol} onChange={(e) => setProtocol(e.target.value as AiProtocol)}>
|
||||
<option value="openai-chat">OpenAI Chat</option><option value="openai-responses">OpenAI Responses</option><option value="anthropic">Anthropic</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="ops-ai-settings-field">认证方式
|
||||
<select className="ops-input" value={authStyle} onChange={(e) => setAuthStyle(e.target.value as AiAuthStyle)}>
|
||||
<option value="bearer">Bearer</option><option value="x-api-key">x-api-key</option><option value="none">无需认证</option>
|
||||
</select>
|
||||
</label>
|
||||
{isLocalHttp && <label className="ops-ai-settings-check"><input type="checkbox" checked={allowInsecureHttp} onChange={(e) => setAllowInsecureHttp(e.target.checked)} />允许本机/内网 HTTP</label>}
|
||||
</div>}
|
||||
</>}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function AiPanel() {
|
||||
const messages = useWorkspaceStore((s) => s.aiMessages);
|
||||
@@ -421,17 +436,19 @@ export function AiPanel() {
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const pushAi = useWorkspaceStore((s) => s.pushAi);
|
||||
const patchAiMessage = useWorkspaceStore((s) => s.patchAiMessage);
|
||||
const updateTabSqlIfUnchanged = useWorkspaceStore((s) => s.updateTabSqlIfUnchanged);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [mcp, setMcp] = useState<McpStatus | null>(null);
|
||||
const [mcpOpen, setMcpOpen] = useState(false);
|
||||
const [elapsedSec, setElapsedSec] = useState(0);
|
||||
const [editingQueue, setEditingQueue] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
/** 世代号:停止/立即发送时 bump,避免旧 finally 清 busy 或误续发排队 */
|
||||
const runGenRef = useRef(0);
|
||||
const skipAutoQueueRef = useRef(false);
|
||||
const queuedIntentRef = useRef<{ mode: AiChatMode; action: AiAction } | null>(null);
|
||||
const runQueuedRef = useRef<(text: string, intent: { mode: AiChatMode; action: AiAction }) => void>(() => {});
|
||||
const streamingIdRef = useRef<string | null>(null);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const composingRef = useRef(false);
|
||||
const compositionEndedAtRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -493,6 +510,21 @@ export function AiPanel() {
|
||||
const tab = tabs.find((t) => t.id === activeTabId);
|
||||
const sql = tab?.sql ?? "";
|
||||
const database = editorDatabase(conn, tab);
|
||||
const contextKey = `${conn?.id || ""}\u0000${database}`;
|
||||
const previousSessionMessage = [...messages].reverse().find((m) => m.sessionId === aiSessionId && m.role === "assistant");
|
||||
const sessionContextRef = useRef<string | null>(aiSessionId && previousSessionMessage?.connectionId === conn?.id && previousSessionMessage?.database === database ? contextKey : null);
|
||||
const isCurrentContext = (key: string) => {
|
||||
const state = useWorkspaceStore.getState();
|
||||
const currentConn = state.connections.find((c) => c.id === state.activeConnectionId);
|
||||
const currentTab = state.tabs.find((t) => t.id === state.activeTabId);
|
||||
return `${currentConn?.id || ""}\u0000${editorDatabase(currentConn, currentTab)}` === key;
|
||||
};
|
||||
useEffect(() => {
|
||||
if (sessionContextRef.current && sessionContextRef.current !== contextKey) {
|
||||
set("aiSessionId", null);
|
||||
sessionContextRef.current = null;
|
||||
}
|
||||
}, [contextKey, set]);
|
||||
const [action, setAction] = useState<AiAction>("generate");
|
||||
|
||||
const setMode = (mode: AiChatMode) => set("aiMode", mode);
|
||||
@@ -502,6 +534,10 @@ export function AiPanel() {
|
||||
skipAutoQueueRef.current = true;
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
if (streamingIdRef.current) {
|
||||
patchAiMessage(streamingIdRef.current, (m) => ({ ...m, content: m.content || "已停止", streaming: false, runOutcome: "cancelled" }));
|
||||
streamingIdRef.current = null;
|
||||
} else if (useWorkspaceStore.getState().aiBusy) pushAi("assistant", "已停止");
|
||||
set("aiBusy", false);
|
||||
// 下一 tick 恢复自动续发开关(给「立即」microtask 用)
|
||||
queueMicrotask(() => {
|
||||
@@ -513,11 +549,11 @@ export function AiPanel() {
|
||||
if (skipAutoQueueRef.current) return;
|
||||
const queued = useWorkspaceStore.getState().aiQueuedPrompt;
|
||||
if (!queued?.trim()) return;
|
||||
const intent = queuedIntentRef.current ?? { mode: useWorkspaceStore.getState().aiMode, action };
|
||||
queuedIntentRef.current = null;
|
||||
set("aiQueuedPrompt", null);
|
||||
const mode = useWorkspaceStore.getState().aiMode;
|
||||
queueMicrotask(() => {
|
||||
if (mode === "agent") void runAgent({ prompt: queued.trim() });
|
||||
else void runAsk(action, queued.trim());
|
||||
runQueuedRef.current(queued.trim(), intent);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -530,11 +566,9 @@ export function AiPanel() {
|
||||
if (action === "ask" || action === "generate") {
|
||||
pushAi("user", text || ACTIONS.find((a) => a.id === action)?.label || action);
|
||||
} else {
|
||||
pushAi("user", `${ACTIONS.find((a) => a.id === action)?.label || action}`);
|
||||
}
|
||||
if (prompt !== undefined || action === "ask" || action === "generate") {
|
||||
if (!prompt) set("aiAsk", "");
|
||||
pushAi("user", `${ACTIONS.find((a) => a.id === action)?.label || action}${text ? ` · ${text}` : ""}`);
|
||||
}
|
||||
if (!prompt) set("aiAsk", "");
|
||||
const ac = new AbortController();
|
||||
abortRef.current = ac;
|
||||
try {
|
||||
@@ -553,8 +587,7 @@ export function AiPanel() {
|
||||
ac.signal,
|
||||
);
|
||||
if (runGenRef.current !== gen) return;
|
||||
pushAi("assistant", res.content);
|
||||
if (res.sql && tab) updateTabSqlIfUnchanged(tab.id, sql, tab.connectionId, tab.database, res.sql);
|
||||
pushAi("assistant", res.content, [], { sqlSuggestion: res.sql || undefined, connectionId: conn.id, connectionName: conn.name, database });
|
||||
} catch (e) {
|
||||
if (runGenRef.current !== gen) return;
|
||||
if ((e as Error)?.name === "AbortError") {
|
||||
@@ -580,18 +613,23 @@ export function AiPanel() {
|
||||
const text = (opts?.prompt ?? ask).trim();
|
||||
const isContinue = !!opts?.continue;
|
||||
if (useWorkspaceStore.getState().aiBusy) return;
|
||||
if (!isContinue && !text) return;
|
||||
if (!conn || (!isContinue && !text)) return;
|
||||
const gen = ++runGenRef.current;
|
||||
set("aiBusy", true);
|
||||
if (!opts?.skipUserBubble) {
|
||||
pushAi("user", isContinue && !text ? "加预算继续" : text);
|
||||
}
|
||||
if (!opts?.prompt) set("aiAsk", "");
|
||||
const asstId = pushAi("assistant", "", [], { streaming: true });
|
||||
const asstId = pushAi("assistant", "", [], { streaming: true, connectionId: conn.id, connectionName: conn.name, database });
|
||||
streamingIdRef.current = asstId;
|
||||
const ac = new AbortController();
|
||||
abortRef.current = ac;
|
||||
const sessionId = useWorkspaceStore.getState().aiSessionId || undefined;
|
||||
const sessionId = sessionContextRef.current === contextKey ? useWorkspaceStore.getState().aiSessionId || undefined : undefined;
|
||||
try {
|
||||
const connections = useWorkspaceStore.getState().connections;
|
||||
if (!connections.some((c) => c.id === conn.id)) throw new Error("当前连接已删除,请重新选择连接");
|
||||
await syncConnectionsToDisk(connections);
|
||||
if (ac.signal.aborted) throw new DOMException("已停止", "AbortError");
|
||||
const res = await callAiAgentStream(
|
||||
{
|
||||
prompt: text,
|
||||
@@ -635,8 +673,11 @@ export function AiPanel() {
|
||||
incompleteReason: final.incompleteReason,
|
||||
stepsBudget: final.stepsBudget,
|
||||
});
|
||||
if (final.sessionId) set("aiSessionId", final.sessionId);
|
||||
if (final.sql && tab) updateTabSqlIfUnchanged(tab.id, sql, tab.connectionId, tab.database, final.sql);
|
||||
if (final.sessionId && isCurrentContext(contextKey)) {
|
||||
sessionContextRef.current = contextKey;
|
||||
set("aiSessionId", final.sessionId);
|
||||
}
|
||||
if (final.sql) patchAiMessage(asstId, { sqlSuggestion: final.sql });
|
||||
},
|
||||
onIncomplete: (final) => {
|
||||
if (runGenRef.current !== gen) return;
|
||||
@@ -651,11 +692,17 @@ export function AiPanel() {
|
||||
incompleteReason: final.incompleteReason || "max_steps",
|
||||
stepsBudget: final.stepsBudget,
|
||||
});
|
||||
if (final.sessionId) set("aiSessionId", final.sessionId);
|
||||
if (final.sessionId && isCurrentContext(contextKey)) {
|
||||
sessionContextRef.current = contextKey;
|
||||
set("aiSessionId", final.sessionId);
|
||||
}
|
||||
},
|
||||
onSession: (meta) => {
|
||||
if (runGenRef.current !== gen) return;
|
||||
if (meta.sessionId) set("aiSessionId", meta.sessionId);
|
||||
if (meta.sessionId && isCurrentContext(contextKey)) {
|
||||
sessionContextRef.current = contextKey;
|
||||
set("aiSessionId", meta.sessionId);
|
||||
}
|
||||
patchAiMessage(asstId, (m) => ({
|
||||
...m,
|
||||
sessionId: meta.sessionId || m.sessionId,
|
||||
@@ -691,6 +738,7 @@ export function AiPanel() {
|
||||
} finally {
|
||||
if (runGenRef.current === gen) {
|
||||
abortRef.current = null;
|
||||
streamingIdRef.current = null;
|
||||
set("aiBusy", false);
|
||||
patchAiMessage(asstId, (m) => ({ ...m, streaming: false }));
|
||||
flushQueuedAfterIdle();
|
||||
@@ -698,15 +746,21 @@ export function AiPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
// 续发取当前渲染的编辑器上下文,避免旧请求闭包把消息发到先前的连接。
|
||||
runQueuedRef.current = (text, intent) => {
|
||||
if (intent.mode === "agent") void runAgent({ prompt: text });
|
||||
else void runAsk(intent.action, text);
|
||||
};
|
||||
|
||||
/** 发送或入队(对标 opsxshell / aiops 契约 14) */
|
||||
const send = () => {
|
||||
const text = ask.trim();
|
||||
if (aiMode === "agent") {
|
||||
if (!text) return;
|
||||
if (busy) {
|
||||
queuedIntentRef.current = { mode: aiMode, action };
|
||||
set("aiQueuedPrompt", text);
|
||||
set("aiAsk", "");
|
||||
setEditingQueue(false);
|
||||
return;
|
||||
}
|
||||
void runAgent();
|
||||
@@ -715,18 +769,18 @@ export function AiPanel() {
|
||||
// Ask:忙时同样单槽排队
|
||||
if (busy) {
|
||||
if (!text) return;
|
||||
queuedIntentRef.current = { mode: aiMode, action };
|
||||
set("aiQueuedPrompt", text);
|
||||
set("aiAsk", "");
|
||||
setEditingQueue(false);
|
||||
return;
|
||||
}
|
||||
void runAsk(action);
|
||||
};
|
||||
|
||||
const continueIncomplete = (m: AiMessage) => {
|
||||
if (busy) return;
|
||||
if (busy || m.connectionId !== conn?.id || m.database !== database) return;
|
||||
const sid = m.sessionId || aiSessionId;
|
||||
if (sid) set("aiSessionId", sid);
|
||||
if (sid) { sessionContextRef.current = contextKey; set("aiSessionId", sid); }
|
||||
void runAgent({
|
||||
prompt: "",
|
||||
continue: true,
|
||||
@@ -739,13 +793,12 @@ export function AiPanel() {
|
||||
const flushQueueNow = () => {
|
||||
const q = useWorkspaceStore.getState().aiQueuedPrompt;
|
||||
if (!q?.trim()) return;
|
||||
const intent = queuedIntentRef.current ?? { mode: useWorkspaceStore.getState().aiMode, action };
|
||||
queuedIntentRef.current = null;
|
||||
set("aiQueuedPrompt", null);
|
||||
setEditingQueue(false);
|
||||
stopAgent();
|
||||
const mode = useWorkspaceStore.getState().aiMode;
|
||||
queueMicrotask(() => {
|
||||
if (mode === "agent") void runAgent({ prompt: q.trim() });
|
||||
else void runAsk(action, q.trim());
|
||||
runQueuedRef.current(q.trim(), intent);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -755,13 +808,13 @@ export function AiPanel() {
|
||||
if (!q) return;
|
||||
set("aiAsk", q);
|
||||
set("aiQueuedPrompt", null);
|
||||
setEditingQueue(true);
|
||||
queuedIntentRef.current = null;
|
||||
};
|
||||
|
||||
/** 撤销 / 删除排队 */
|
||||
const discardQueue = () => {
|
||||
set("aiQueuedPrompt", null);
|
||||
setEditingQueue(false);
|
||||
queuedIntentRef.current = null;
|
||||
};
|
||||
|
||||
/** 新会话:丢弃轨迹与 sessionId,可重新开跑 */
|
||||
@@ -769,9 +822,10 @@ export function AiPanel() {
|
||||
stopAgent();
|
||||
set("aiMessages", []);
|
||||
set("aiSessionId", null);
|
||||
sessionContextRef.current = null;
|
||||
set("aiQueuedPrompt", null);
|
||||
queuedIntentRef.current = null;
|
||||
set("aiAsk", "");
|
||||
setEditingQueue(false);
|
||||
};
|
||||
|
||||
/** 清屏:只清可见消息与排队,草稿保留;sessionId 一并丢掉以免隐式续跑旧轨迹 */
|
||||
@@ -779,16 +833,19 @@ export function AiPanel() {
|
||||
if (busy) stopAgent();
|
||||
set("aiMessages", []);
|
||||
set("aiSessionId", null);
|
||||
sessionContextRef.current = null;
|
||||
set("aiQueuedPrompt", null);
|
||||
setEditingQueue(false);
|
||||
queuedIntentRef.current = null;
|
||||
};
|
||||
|
||||
const pendingConfirm = (() => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i];
|
||||
if (m.role !== "assistant" || !m.steps) continue;
|
||||
const step = [...m.steps].reverse().find((s) => s.needConfirm && s.confirmSql);
|
||||
if (step) return { messageId: m.id, step };
|
||||
for (let stepIndex = m.steps.length - 1; stepIndex >= 0; stepIndex--) {
|
||||
const step = m.steps[stepIndex];
|
||||
if (step.needConfirm && step.confirmSql) return { messageId: m.id, step, stepIndex };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
@@ -798,11 +855,11 @@ export function AiPanel() {
|
||||
? "继续输入以覆盖排队内容…"
|
||||
: "继续输入以排队后续修改"
|
||||
: aiMode === "agent"
|
||||
? "让 Agent 查库…例如:demo 有哪些表?users 前 5 行"
|
||||
: "描述你想查询什么…";
|
||||
? "让 Agent 查询或分析当前数据库…"
|
||||
: action === "generate" ? "描述你想查询的数据,例如:统计本月订单…" : "输入问题,或结合当前 SQL 提问…";
|
||||
|
||||
return (
|
||||
<aside className="ops-panel flex h-full w-full min-w-0 flex-col border-l">
|
||||
<aside className="ops-ai-panel ops-panel flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden border-l">
|
||||
<div className="ops-section-label justify-between border-b gap-1">
|
||||
<span className="inline-flex items-center gap-1.5 min-w-0">
|
||||
<span className="truncate">AI 会话</span>
|
||||
@@ -841,15 +898,6 @@ export function AiPanel() {
|
||||
<Eraser className="h-3 w-3" aria-hidden />
|
||||
清屏
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[12px]"
|
||||
title="AI 设置"
|
||||
aria-label="AI 设置"
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-close-faint"
|
||||
@@ -886,43 +934,33 @@ export function AiPanel() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showSettings && <AiSettings onClose={() => setShowSettings(false)} />}
|
||||
|
||||
<div ref={listRef} className="min-h-0 flex-1 space-y-1.5 overflow-auto p-1.5">
|
||||
<div ref={listRef} className="ops-ai-transcript min-h-0 flex-1 space-y-2 overflow-auto px-3 py-3">
|
||||
{messages.length === 0 && !busy && (
|
||||
<div className="grid h-full place-items-center px-2 text-center text-[12px]" style={{ color: "var(--ops-muted)" }}>
|
||||
{aiMode === "agent"
|
||||
? "Agent 会真查白名单里的库。试试:列出 demo 有哪些表"
|
||||
: "聊聊你想查什么,我来写 SQL"}
|
||||
<div className="ops-ai-empty">
|
||||
<div className="ops-ai-empty-title">从当前数据库开始</div>
|
||||
<p>{aiMode === "agent" ? "让 Agent 查看表结构与数据,并给出分析。" : "描述你的问题,或用自然语言生成 SQL。"}</p>
|
||||
<button type="button" onClick={() => set("aiAsk", aiMode === "agent" ? "列出当前库的表,并说明它们的用途" : "查询最近 7 天的数据变化")}>
|
||||
{aiMode === "agent" ? "试试:了解当前库" : "试试:最近 7 天的数据变化"} <ArrowUp className="h-3 w-3" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="rounded border px-2 py-1.5 text-[12px] whitespace-pre-wrap"
|
||||
style={
|
||||
m.role === "user"
|
||||
? {
|
||||
borderColor: "color-mix(in srgb, var(--ops-accent) 35%, var(--ops-border))",
|
||||
background: "color-mix(in srgb, var(--ops-accent) 8%, transparent)",
|
||||
}
|
||||
: {
|
||||
borderColor: "var(--ops-border)",
|
||||
background: "var(--ops-surface-2)",
|
||||
}
|
||||
}
|
||||
className={`ops-ai-message ${m.role === "user" ? "ops-ai-message-user" : "ops-ai-message-assistant"}`}
|
||||
>
|
||||
<div className="mb-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||||
{m.role === "user" ? "你" : "AI"} · {m.at}
|
||||
</div>
|
||||
<MessageContent content={m.content} streaming={m.streaming} />
|
||||
<MessageContent content={m.content} streaming={m.streaming} connectionId={m.connectionId} database={m.database} />
|
||||
{m.role === "assistant" && m.sqlSuggestion && !m.content.includes("```") ? <CodeBlock code={m.sqlSuggestion} connectionId={m.connectionId} database={m.database} /> : null}
|
||||
{m.role === "assistant" && m.steps && m.steps.length > 0 ? <AgentSteps steps={m.steps} /> : null}
|
||||
{m.role === "assistant" && m.runOutcome === "incomplete" && m.resumable && !m.streaming ? (
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-secondary h-6 px-2 text-[11px]"
|
||||
disabled={busy}
|
||||
disabled={busy || m.connectionId !== conn?.id || m.database !== database}
|
||||
onClick={() => continueIncomplete(m)}
|
||||
>
|
||||
加预算继续
|
||||
@@ -935,18 +973,22 @@ export function AiPanel() {
|
||||
) : null}
|
||||
{pendingConfirm && pendingConfirm.messageId === m.id && pendingConfirm.step.confirmSql ? (
|
||||
<DangerConfirmBar
|
||||
key={`${m.id}:${pendingConfirm.stepIndex}`}
|
||||
sql={pendingConfirm.step.confirmSql}
|
||||
connectionId={conn?.id}
|
||||
database={database || undefined}
|
||||
connectionId={m.connectionId}
|
||||
connectionName={m.connectionName}
|
||||
database={m.database || undefined}
|
||||
kind={pendingConfirm.step.tool?.includes("redis") ? "redis" : "query"}
|
||||
confirmBind={pendingConfirm.step.confirmBind}
|
||||
runId={m.runId}
|
||||
ready={!busy && !m.streaming}
|
||||
currentContext={m.connectionId === conn?.id && m.database === database}
|
||||
onDone={(ok, msg) => {
|
||||
if (ok) {
|
||||
patchAiMessage(m.id, (cur) => ({
|
||||
...cur,
|
||||
steps: (cur.steps || []).map((s) =>
|
||||
s.needConfirm ? { ...s, needConfirm: false, ok: true, summary: "已确认执行" } : s,
|
||||
steps: (cur.steps || []).map((s, i) =>
|
||||
i === pendingConfirm.stepIndex ? { ...s, needConfirm: false, ok: true, summary: "已确认执行" } : s,
|
||||
),
|
||||
content: cur.content + (cur.content ? "\n" : "") + "(已确认执行写操作)",
|
||||
}));
|
||||
@@ -962,7 +1004,7 @@ export function AiPanel() {
|
||||
} else {
|
||||
patchAiMessage(m.id, (cur) => ({
|
||||
...cur,
|
||||
steps: (cur.steps || []).map((s) => (s.needConfirm ? { ...s, needConfirm: false, summary: "已取消" } : s)),
|
||||
steps: (cur.steps || []).map((s, i) => (i === pendingConfirm.stepIndex ? { ...s, needConfirm: false, summary: "已取消" } : s)),
|
||||
}));
|
||||
}
|
||||
}}
|
||||
@@ -981,9 +1023,7 @@ export function AiPanel() {
|
||||
{aiQueuedPrompt ? (
|
||||
<div className="ops-ai-queue-card" role="status">
|
||||
<div className="ops-ai-queue-card-body" title={aiQueuedPrompt}>
|
||||
<span className="ops-ai-queue-grip" aria-hidden>
|
||||
⠿
|
||||
</span>
|
||||
<span className="ops-ai-queue-label">待发送 · {queuedIntentRef.current?.mode === "agent" ? "Agent" : queuedIntentRef.current?.action === "ask" ? "提问" : ACTIONS.find((a) => a.id === queuedIntentRef.current?.action)?.label || "Ask"}</span>
|
||||
<span className="ops-ai-queue-text">{aiQueuedPrompt}</span>
|
||||
</div>
|
||||
<div className="ops-ai-queue-actions">
|
||||
@@ -1003,79 +1043,46 @@ export function AiPanel() {
|
||||
</div>
|
||||
|
||||
<div className="ops-ai-composer">
|
||||
<div className="ops-ai-mode" role="group" aria-label="Ask 或 Agent">
|
||||
<button type="button" aria-pressed={aiMode === "ask"} className={aiMode === "ask" ? "ops-ai-mode-active" : undefined} onClick={() => setMode("ask")}>
|
||||
Ask
|
||||
</button>
|
||||
<button type="button" aria-pressed={aiMode === "agent"} className={aiMode === "agent" ? "ops-ai-mode-active" : undefined} onClick={() => setMode("agent")}>
|
||||
Agent
|
||||
</button>
|
||||
<div className="ops-ai-context" title="跟随当前 SQL 编辑器的连接与数据库">
|
||||
<Database className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span className="truncate">{conn?.name || "未选择连接"}</span>
|
||||
{conn && <><span className="ops-ai-context-separator">/</span><strong className="truncate">{conn.driver.toLowerCase() === "redis" ? `DB${database}` : database || "未选择数据库"}</strong></>}
|
||||
{aiMode === "agent" && <span className="ops-ai-context-policy">{mcp?.writesEnabled ? "写入需确认" : "只读"}</span>}
|
||||
</div>
|
||||
<div className="ops-ai-context" title="跟随 SQL 编辑器当前数据库">
|
||||
{conn?.name || "未选择连接"} › {conn?.driver.toLowerCase() === "redis" ? `DB${database}` : database || "未选择数据库"}
|
||||
{aiMode === "agent" ? (mcp?.writesEnabled ? " · 可写需确认" : " · 只读工具") : ""}
|
||||
{aiSessionId ? ` · ${aiSessionId.slice(0, 12)}…` : ""}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<textarea
|
||||
rows={3}
|
||||
className="ops-input min-w-0 flex-1 resize-none"
|
||||
placeholder={placeholder}
|
||||
value={ask}
|
||||
onChange={(e) => set("aiAsk", e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{busy ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<button type="button" className="ops-btn-secondary inline-flex items-center justify-center gap-1" onClick={stopAgent} title="停止当前运行">
|
||||
停止
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-primary inline-flex items-center justify-center gap-1"
|
||||
disabled={!ask.trim()}
|
||||
onClick={send}
|
||||
title={ask.trim() ? "加入排队(单槽,覆盖)" : "输入后可排队"}
|
||||
>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
排队
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className="ops-btn-primary" disabled={aiMode === "agent" && !ask.trim()} onClick={send}>
|
||||
发送
|
||||
</button>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="ops-ai-textarea"
|
||||
aria-label="输入 AI 消息"
|
||||
placeholder={placeholder}
|
||||
value={ask}
|
||||
onChange={(e) => set("aiAsk", e.target.value)}
|
||||
onCompositionStart={() => { composingRef.current = true; compositionEndedAtRef.current = 0; }}
|
||||
onCompositionEnd={() => { composingRef.current = false; compositionEndedAtRef.current = Date.now(); }}
|
||||
onKeyDown={(e) => {
|
||||
const action = aiEnterAction(e.nativeEvent, composingRef.current, Date.now() - compositionEndedAtRef.current < 180);
|
||||
if (action === "suppress") e.preventDefault();
|
||||
if (action === "send") {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="ops-ai-composer-footer">
|
||||
<select className="ops-ai-mode-select" aria-label="AI 模式" value={aiMode} onChange={(e) => setMode(e.target.value as AiChatMode)} title="Ask 给出建议;Agent 使用当前连接的工具">
|
||||
<option value="ask">Ask · 建议</option>
|
||||
<option value="agent">Agent · 操作</option>
|
||||
</select>
|
||||
{aiMode === "ask" && (
|
||||
<select className="ops-ai-action-select" aria-label="提问类型" value={action} onChange={(e) => setAction(e.target.value as AiAction)}>
|
||||
{ACTIONS.map((a) => <option key={a.id} value={a.id}>{a.label}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<div className="ops-ai-composer-spacer" />
|
||||
{busy && <button type="button" className="ops-ai-stop" onClick={stopAgent} title="停止当前运行"><Square className="h-3 w-3 fill-current" aria-hidden /> 停止</button>}
|
||||
<button type="button" className="ops-ai-send" disabled={!conn || (!ask.trim() && (busy || aiMode === "agent" || action === "ask" || action === "generate"))} onClick={send} title={busy ? "加入待发送队列(保留最近一条)" : "发送 · Enter"}>
|
||||
{busy ? "排队" : "发送"}<ArrowUp className="h-3.5 w-3.5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
{aiMode === "ask" ? (
|
||||
<div className="ops-ai-actions" role="group" aria-label="AI 操作">
|
||||
{ACTIONS.map((a) => (
|
||||
<button
|
||||
type="button"
|
||||
key={a.id}
|
||||
aria-pressed={action === a.id}
|
||||
className={action === a.id ? "ops-ai-action-active" : undefined}
|
||||
onClick={() => setAction(a.id)}
|
||||
disabled={busy}
|
||||
>
|
||||
{a.id === "generate" ? "生成 SQL" : a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||||
{busy
|
||||
? editingQueue
|
||||
? "编辑中:改完回车或点「排队」覆盖单槽。"
|
||||
: "工作中可继续输入排队(单槽);点排队卡「立即」可改方向。"
|
||||
: "Agent 与 MCP 同源工具;默认只读。写操作需确认。触顶可「加预算继续」。"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -148,7 +148,8 @@ export function ConnectionForm({ selected, form, setForm, dsn, setDsn, onApplyDs
|
||||
<textarea
|
||||
className="ops-input min-h-[64px] w-full py-1 text-[12.5px]"
|
||||
placeholder={"127.0.0.1:26379\n127.0.0.1:26380"}
|
||||
value={form.redisNodes ?? ""}
|
||||
title="种子及集群返回的节点地址都须能从本机访问;远程集群不要填写 127.0.0.1"
|
||||
value={form.redisNodes ?? ""}
|
||||
onChange={(e) => patch({ redisNodes: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
@@ -172,7 +173,8 @@ export function ConnectionForm({ selected, form, setForm, dsn, setDsn, onApplyDs
|
||||
<Row label="种子节点">
|
||||
<textarea
|
||||
className="ops-input min-h-[64px] w-full py-1 text-[12.5px]"
|
||||
placeholder={"127.0.0.1:7000\n127.0.0.1:7001"}
|
||||
placeholder={"redis-host:7000\nredis-host:7001"}
|
||||
title="种子及集群返回的节点地址都须能从本机访问;远程集群不要填写 127.0.0.1"
|
||||
value={form.redisNodes ?? ""}
|
||||
onChange={(e) => patch({ redisNodes: e.target.value })}
|
||||
/>
|
||||
|
||||
@@ -183,7 +183,7 @@ export function ConnectionList() {
|
||||
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 });
|
||||
notify({ kind: "success", title: "已导出", message: `${st.connections.length} 条连接`, mode: "toast", durationMs: 1600, feedback: "visible" });
|
||||
};
|
||||
|
||||
/** R2-F5:导入连接 —— 校验数组结构;id 冲突重新生成后追加;持久化走 store 现有 action */
|
||||
@@ -214,7 +214,7 @@ export function ConnectionList() {
|
||||
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 });
|
||||
notify({ kind: "success", title: "导入完成", message: `已导入 ${n} 条连接`, mode: "toast", durationMs: 2000, feedback: "visible" });
|
||||
} catch (err) {
|
||||
notifyError(err, "modal");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CATEGORIES, DRIVERS, driverById, isDriverReady, type DriverDef, type DriverId } from "./catalog";
|
||||
import { ConnectionForm } from "./ConnectionForm";
|
||||
import { ConnectionList } from "./ConnectionList";
|
||||
@@ -27,6 +27,24 @@ export function ConnectionPicker() {
|
||||
const notifyError = useWorkspaceStore((s) => s.notifyError);
|
||||
const [dsn, setDsn] = useState("");
|
||||
const [testMsg, setTestMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [saveMenuOpen, setSaveMenuOpen] = useState(false);
|
||||
const saveMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!saveMenuOpen) return;
|
||||
const dismiss = (event: PointerEvent) => {
|
||||
if (!saveMenuRef.current?.contains(event.target as Node)) setSaveMenuOpen(false);
|
||||
};
|
||||
const dismissOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setSaveMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", dismiss);
|
||||
document.addEventListener("keydown", dismissOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", dismiss);
|
||||
document.removeEventListener("keydown", dismissOnEscape);
|
||||
};
|
||||
}, [saveMenuOpen]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
@@ -139,7 +157,7 @@ export function ConnectionPicker() {
|
||||
return;
|
||||
}
|
||||
upsert(c);
|
||||
set("activeConnectionId", c.id);
|
||||
useWorkspaceStore.getState().selectConnection(c.id);
|
||||
set("view", "workbench");
|
||||
set("status", `已连接 ${c.name}`);
|
||||
};
|
||||
@@ -323,12 +341,31 @@ export function ConnectionPicker() {
|
||||
<button type="button" className="ops-btn-ghost" onClick={backToList}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="ops-btn-secondary" onClick={() => onSave()}>
|
||||
保存
|
||||
</button>
|
||||
<button type="button" className="ops-btn-primary" onClick={() => void onConnect()}>
|
||||
保存并连接
|
||||
</button>
|
||||
<div ref={saveMenuRef} className="relative flex items-stretch">
|
||||
<button type="button" className="ops-btn-primary rounded-r-none" onClick={() => void onConnect()}>
|
||||
保存并连接
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-primary rounded-l-none border-l px-2"
|
||||
style={{ borderLeftColor: "color-mix(in srgb, white 35%, transparent)" }}
|
||||
aria-label="更多保存方式"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={saveMenuOpen}
|
||||
onClick={() => setSaveMenuOpen((open) => !open)}
|
||||
>
|
||||
▾
|
||||
</button>
|
||||
{saveMenuOpen && (
|
||||
<div role="menu" className="absolute bottom-full right-0 z-20 mb-1 min-w-36 rounded border p-1 shadow-lg"
|
||||
style={{ background: "var(--ops-surface)", borderColor: "var(--ops-border)" }}>
|
||||
<button type="button" role="menuitem" className="ops-tree-item w-full rounded px-2 py-1.5 text-left text-[12px]"
|
||||
onClick={() => { setSaveMenuOpen(false); onSave(); }}>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+60
-52
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { closeSession } from "./api";
|
||||
import { closeSession, openSession } from "./api";
|
||||
import { driverById } from "./catalog";
|
||||
import { askInput } from "./dialogs";
|
||||
import { DriverIcon } from "./DriverIcon";
|
||||
import { ObjectTree } from "./ObjectTree";
|
||||
import { ContextMenu } from "./ContextMenu";
|
||||
import { TreeChevron } from "./TreeChevron";
|
||||
import { connectionEndpoint, connectionUri } from "./contextActions";
|
||||
import {
|
||||
DEFAULT_CONN_GROUP,
|
||||
useWorkspaceStore,
|
||||
@@ -34,7 +36,10 @@ export function ConnectionSidebar({
|
||||
const moveConnection = useWorkspaceStore((s) => s.moveConnection);
|
||||
const toggleConnGroup = useWorkspaceStore((s) => s.toggleConnGroup);
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const notifyError = useWorkspaceStore((s) => s.notifyError);
|
||||
const editConnection = useWorkspaceStore((s) => s.editConnection);
|
||||
const connState = useWorkspaceStore((s) => s.connState);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
|
||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; conn: SavedConnection } | null>(null);
|
||||
/** 断开连接:关会话 + 清树 + 清激活 */
|
||||
@@ -51,6 +56,26 @@ export function ConnectionSidebar({
|
||||
st.set("connState", "down");
|
||||
st.set("status", `已断开 ${c.name}`);
|
||||
}
|
||||
notify({ kind: "warning", title: "已断开", message: c.name, mode: "toast", durationMs: 1800 });
|
||||
};
|
||||
|
||||
const reconnect = async (c: SavedConnection) => {
|
||||
set("connState", "reconnecting");
|
||||
set("status", "重连中…");
|
||||
try {
|
||||
useWorkspaceStore.getState().setSession(c.id, null);
|
||||
const { sessionId } = await openSession(c);
|
||||
useWorkspaceStore.getState().setSession(c.id, sessionId);
|
||||
useWorkspaceStore.getState().selectConnection(c.id);
|
||||
set("connState", "ok");
|
||||
set("status", `已重连 ${c.name}` + (c.database ? ` · ${c.database}` : ""));
|
||||
notify({ kind: "success", title: "重连成功", message: c.name, mode: "toast", durationMs: 1600 });
|
||||
} catch (e) {
|
||||
set("connState", "down");
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
set("status", `重连失败:${msg}`);
|
||||
notifyError(e, "modal");
|
||||
}
|
||||
};
|
||||
|
||||
const [dragId, setDragId] = useState<string | null>(null);
|
||||
@@ -122,7 +147,7 @@ export function ConnectionSidebar({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
<span>连接</span>
|
||||
<button type="button" className="hover:underline" style={{ color: "var(--ops-accent)" }} onClick={onAddGroup} title="新建分组">
|
||||
@@ -255,16 +280,36 @@ export function ConnectionSidebar({
|
||||
<span className="h-3 w-1 shrink-0 rounded-sm" style={{ background: c.color }} />
|
||||
)}
|
||||
<DriverIcon driver={c.driver} size={14} />
|
||||
<span className="min-w-0 truncate" title={`${driverById(c.driver)?.name ?? c.driver} · ${c.name}`}>
|
||||
{c.name}
|
||||
<span className="min-w-0 flex-1 truncate" title={`${driverById(c.driver)?.name ?? c.driver} · ${c.name} · ${connectionEndpoint(c)}`}>
|
||||
<span className="truncate">{c.name}</span>
|
||||
{(c.host || c.redisNodes || c.database) && (
|
||||
<span className="ml-1 text-[10px] opacity-45">
|
||||
{c.host || c.redisNodes ? connectionEndpoint(c) : c.database}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className="ml-1 h-1.5 w-1.5 shrink-0 rounded-full"
|
||||
style={{
|
||||
background: activeId === c.id ? '#22c55e' : 'var(--ops-muted)',
|
||||
background:
|
||||
activeId === c.id
|
||||
? connState === "down"
|
||||
? "var(--ops-danger)"
|
||||
: connState === "reconnecting"
|
||||
? "var(--ops-warning)"
|
||||
: "#22c55e"
|
||||
: "var(--ops-muted)",
|
||||
opacity: activeId === c.id ? 1 : 0.4,
|
||||
}}
|
||||
title={activeId === c.id ? '已连接' : '未激活'}
|
||||
title={
|
||||
activeId === c.id
|
||||
? connState === "down"
|
||||
? "已断开"
|
||||
: connState === "reconnecting"
|
||||
? "重连中…"
|
||||
: "已连接"
|
||||
: "未激活"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
@@ -298,52 +343,15 @@ export function ConnectionSidebar({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{ctxMenu && (
|
||||
<div
|
||||
className="ops-ctx"
|
||||
style={{ position: "fixed", left: ctxMenu.x, top: ctxMenu.y, zIndex: 1000 }}
|
||||
>
|
||||
{activeId === ctxMenu.conn.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
disconnect(ctxMenu.conn);
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
断开连接
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelect(ctxMenu.conn.id);
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
连接
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editConnection(ctxMenu.conn);
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{ color: "var(--ops-danger)" }}
|
||||
onClick={() => {
|
||||
onDelete(ctxMenu.conn.id);
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{ctxMenu && <ContextMenu x={ctxMenu.x} y={ctxMenu.y} title={ctxMenu.conn.name} onClose={() => setCtxMenu(null)} items={[
|
||||
{ label: "连接", hidden: activeId === ctxMenu.conn.id, action: () => onSelect(ctxMenu.conn.id) },
|
||||
{ label: "断开连接", hidden: activeId !== ctxMenu.conn.id, action: () => disconnect(ctxMenu.conn) },
|
||||
{ label: "重新连接", action: () => void reconnect(ctxMenu.conn) },
|
||||
{ label: "编辑连接…", action: () => editConnection(ctxMenu.conn) },
|
||||
{ label: "复制连接地址(无凭据)", separator: true, action: () => { void navigator.clipboard.writeText(connectionUri(ctxMenu.conn)).catch((e) => notifyError(e, "toast")); } },
|
||||
{ label: "复制主机端口", action: () => { void navigator.clipboard.writeText(connectionEndpoint(ctxMenu.conn)).catch((e) => notifyError(e, "toast")); } },
|
||||
{ label: "删除连接…", separator: true, danger: true, action: () => onDelete(ctxMenu.conn.id) },
|
||||
]} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal, flushSync } from "react-dom";
|
||||
|
||||
export type MenuItem = {
|
||||
label: string;
|
||||
action: () => void;
|
||||
disabled?: boolean;
|
||||
hint?: string;
|
||||
shortcut?: string;
|
||||
separator?: boolean;
|
||||
hidden?: boolean;
|
||||
danger?: boolean;
|
||||
};
|
||||
|
||||
/** 桌面上下文菜单:视口约束、键盘导航、关闭后归还焦点。 */
|
||||
export function ContextMenu({ x, y, title, items, onClose }: {
|
||||
x: number; y: number; title: string; items: MenuItem[]; onClose: () => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const closeRef = useRef(onClose);
|
||||
closeRef.current = onClose;
|
||||
const [pos, setPos] = useState({ left: x, top: y });
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
const place = () => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
setPos({ left: Math.max(8, Math.min(x, window.innerWidth - rect.width - 8)), top: Math.max(8, Math.min(y, window.innerHeight - rect.height - 8)) });
|
||||
};
|
||||
place();
|
||||
el.querySelector<HTMLButtonElement>("button:not(:disabled)")?.focus();
|
||||
let restoreFocus = true;
|
||||
const outside = (e: PointerEvent) => { if (!el.contains(e.target as Node)) { restoreFocus = false; closeRef.current(); } };
|
||||
const blur = () => closeRef.current();
|
||||
window.addEventListener("pointerdown", outside);
|
||||
window.addEventListener("blur", blur);
|
||||
window.addEventListener("resize", place);
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", outside);
|
||||
window.removeEventListener("blur", blur);
|
||||
window.removeEventListener("resize", place);
|
||||
if (restoreFocus && previous?.isConnected) previous.focus();
|
||||
};
|
||||
}, [x, y]);
|
||||
return createPortal(
|
||||
<div ref={ref} role="menu" aria-label={title} className="ops-ctx ops-action-menu" style={pos}
|
||||
onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); onClose(); return; }
|
||||
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return;
|
||||
e.preventDefault();
|
||||
const buttons = Array.from(ref.current!.querySelectorAll<HTMLButtonElement>("button:not(:disabled)"));
|
||||
const i = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
||||
const next = e.key === "Home" ? 0 : e.key === "End" ? buttons.length - 1 : (i + (e.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length;
|
||||
buttons[next]?.focus();
|
||||
}}>
|
||||
<div className="ops-menu-caption">{title}</div>
|
||||
{items.filter((item) => !item.hidden).map((item, i) => <div key={`${item.label}-${i}`} role="none">
|
||||
{item.separator && <div role="separator" className="ops-ctx-sep" />}
|
||||
<button role="menuitem" type="button" disabled={item.disabled} title={item.hint}
|
||||
style={item.danger ? { color: "var(--ops-danger)" } : undefined}
|
||||
onClick={() => { flushSync(onClose); item.action(); }}>
|
||||
<span>{item.label}</span>{item.shortcut && <kbd>{item.shortcut}</kbd>}
|
||||
</button>
|
||||
</div>)}
|
||||
</div>, document.body,
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,6 @@ export function DdlModal({ conn, database, table, onClose }: Props) {
|
||||
|
||||
const copy = async () => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
notify({ kind: "success", title: "已复制", message: `${database}.${table} DDL`, mode: "toast", durationMs: 1400 });
|
||||
};
|
||||
|
||||
/** 插入编辑器:DDL 追加到当前激活 Tab 的 SQL 末尾 */
|
||||
|
||||
+19
-16
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { driverImportOffline, driverInstall, driverStatus, type DriverStatusItem } from "./api";
|
||||
import { driverById } from "./catalog";
|
||||
import { DriverIcon } from "./DriverIcon";
|
||||
import { OpsModal } from "./OpsModal";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
@@ -18,21 +17,23 @@ function fmtSize(n: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 驱动管理弹框:agent 驱动(MongoDB / ZooKeeper / 达梦)的运行时状态与维护入口。
|
||||
* agent 驱动(MongoDB / ZooKeeper / 达梦)的运行时状态与维护入口。
|
||||
* 在线环境首次连接会自动下载;这里提供预装、离线包导入与商用 jar 指引。
|
||||
*/
|
||||
export function DriverManager({ onClose }: { onClose: () => void }) {
|
||||
export function DriverManager({ onClose, embedded = false }: { onClose?: () => void; embedded?: boolean }) {
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const notifyError = useWorkspaceStore((s) => s.notifyError);
|
||||
const [items, setItems] = useState<DriverStatusItem[] | null>(null);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const reload = async () => {
|
||||
setLoadError("");
|
||||
try {
|
||||
setItems(await driverStatus());
|
||||
} catch (e) {
|
||||
notifyError(e, "toast");
|
||||
setLoadError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
@@ -46,7 +47,7 @@ export function DriverManager({ onClose }: { onClose: () => void }) {
|
||||
const r = await driverInstall(id);
|
||||
if (r.drivers) setItems(r.drivers);
|
||||
if (r.ok) {
|
||||
notify({ kind: "success", title: "安装完成", message: `${LABELS[id] ?? id} 驱动已就绪`, mode: "toast" });
|
||||
notify({ kind: "success", title: "安装完成", message: `${LABELS[id] ?? id} 驱动已就绪`, mode: "toast", feedback: "visible" });
|
||||
} else {
|
||||
notify({ kind: "error", title: "安装失败", message: r.error ?? "未知错误", mode: "modal" });
|
||||
}
|
||||
@@ -63,7 +64,7 @@ export function DriverManager({ onClose }: { onClose: () => void }) {
|
||||
const r = await driverImportOffline(f);
|
||||
if (r.drivers) setItems(r.drivers);
|
||||
if (r.ok) {
|
||||
notify({ kind: "success", title: "导入完成", message: `已导入 ${r.imported} 个文件`, mode: "toast" });
|
||||
notify({ kind: "success", title: "导入完成", message: `已导入 ${r.imported} 个文件`, mode: "toast", feedback: "visible" });
|
||||
} else {
|
||||
notify({ kind: "error", title: "导入失败", message: r.error ?? "包校验失败", mode: "modal" });
|
||||
}
|
||||
@@ -77,29 +78,31 @@ export function DriverManager({ onClose }: { onClose: () => void }) {
|
||||
|
||||
const jdk = items?.find((x) => x.jdkRequired);
|
||||
|
||||
return (
|
||||
<OpsModal variant="info" title="驱动管理" onOk={onClose}>
|
||||
<div className="space-y-2" style={{ minWidth: 480 }}>
|
||||
const content = (
|
||||
<div className="space-y-2" style={embedded ? undefined : { maxHeight: "65vh", overflowY: "auto" }}>
|
||||
{/* 环境状态行 */}
|
||||
<div
|
||||
className="flex items-center gap-3 rounded border px-2.5 py-1.5 text-[11.5px]"
|
||||
style={{ borderColor: "var(--ops-border)", background: "var(--ops-surface-2)" }}
|
||||
>
|
||||
<span style={{ color: "var(--ops-muted)" }}>JVM:</span>
|
||||
{jdk?.jdkOk ? (
|
||||
{items === null ? (
|
||||
<span style={{ color: "var(--ops-muted)" }}>{loadError ? "无法检测" : "检测中…"}</span>
|
||||
) : jdk?.jdkOk ? (
|
||||
<span style={{ color: "var(--ops-accent)" }}>已检测到 Java {jdk.javaVersion}</span>
|
||||
) : (
|
||||
<span style={{ color: "var(--ops-danger)" }}>未找到 Java(agent 驱动需 JDK 17+)</span>
|
||||
<span style={{ color: "var(--ops-danger)" }}>Java 未就绪(agent 需 Java 17+)</span>
|
||||
)}
|
||||
<span style={{ color: "var(--ops-muted)" }}>·</span>
|
||||
<span style={{ color: "var(--ops-muted)" }}>agent:</span>
|
||||
<span style={{ color: items?.[0]?.agentInstalled ? "var(--ops-accent)" : "var(--ops-warning)" }}>
|
||||
{items?.[0]?.agentInstalled ? "已安装" : "缺失(连接时自动下载)"}
|
||||
<span style={{ color: items === null ? "var(--ops-muted)" : items[0]?.agentInstalled ? "var(--ops-accent)" : "var(--ops-warning)" }}>
|
||||
{items === null ? loadError ? "无法检测" : "检测中…" : items[0]?.agentInstalled ? "已安装" : "缺失(连接时自动下载)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 驱动行 */}
|
||||
{items === null && <div className="py-3 text-[12px]" style={{ color: "var(--ops-muted)" }}>加载中…</div>}
|
||||
{loadError && <div role="alert" className="py-2 text-xs" style={{ color: "var(--ops-danger)" }}>驱动状态加载失败:{loadError} <button type="button" className="ops-btn-secondary ml-2" onClick={() => void reload()}>重试</button></div>}
|
||||
{items === null && !loadError && <div className="py-3 text-[12px]" style={{ color: "var(--ops-muted)" }}>加载中…</div>}
|
||||
{items?.map((d) => {
|
||||
const done = d.allInstalled;
|
||||
const badge = !d.jdkOk
|
||||
@@ -164,7 +167,7 @@ export function DriverManager({ onClose }: { onClose: () => void }) {
|
||||
>
|
||||
{busy === "import" ? "导入中…" : "导入离线驱动包…"}
|
||||
</button>
|
||||
<span className="truncate">(opsxdb-driver 仓库 pack.sh 产物 .zip)</span>
|
||||
<span className="truncate">选择 opsxdb-driver 的离线包 .zip</span>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
@@ -177,6 +180,6 @@ export function DriverManager({ onClose }: { onClose: () => void }) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</OpsModal>
|
||||
);
|
||||
return embedded ? content : <OpsModal wide variant="info" title="驱动管理" onOk={onClose ?? (() => {})}>{content}</OpsModal>;
|
||||
}
|
||||
+32
-84
@@ -24,11 +24,9 @@ const CLIENT_TABS: Array<{ id: ClientTab; label: string }> = [
|
||||
{ id: "claude-desktop", label: "Claude Desktop" },
|
||||
];
|
||||
|
||||
function buildClientSnippet(tab: ClientTab, goBin: string, readOnly: boolean): { path: string; json: string } {
|
||||
function buildClientSnippet(tab: ClientTab, goBin: string): { 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",
|
||||
@@ -88,6 +86,7 @@ function buildClientSnippet(tab: ClientTab, goBin: string, readOnly: boolean): {
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const map: Record<string, { label: string; color: string; bg: string }> = {
|
||||
checking: { label: "检测中", color: "var(--ops-muted)", bg: "var(--ops-surface-2)" },
|
||||
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)" },
|
||||
@@ -103,51 +102,16 @@ function StatusBadge({ status }: { status: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
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、白名单、多客户端配置片段 */
|
||||
/** 设置 → MCP:权限状态、授权连接、客户端接入 */
|
||||
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 [installLoading, setInstallLoading] = useState(true);
|
||||
const [installError, setInstallError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [clientTab, setClientTab] = useState<ClientTab>("cursor");
|
||||
@@ -160,12 +124,10 @@ export function McpSettings() {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
Promise.all([getMcpInstall(), getMcpPolicy()])
|
||||
.then(([inst, pol]) => {
|
||||
getMcpPolicy()
|
||||
.then((pol) => {
|
||||
if (!alive) return;
|
||||
setInstall(inst);
|
||||
setPolicy(pol.policy);
|
||||
setPolicyPath(pol.path || "");
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setError(e instanceof Error ? e.message : String(e));
|
||||
@@ -173,6 +135,12 @@ export function McpSettings() {
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
setInstallLoading(true);
|
||||
setInstallError("");
|
||||
getMcpInstall()
|
||||
.then((inst) => { if (alive) setInstall(inst); })
|
||||
.catch((e) => { if (alive) setInstallError(e instanceof Error ? e.message : String(e)); })
|
||||
.finally(() => { if (alive) setInstallLoading(false); });
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
@@ -183,8 +151,8 @@ export function McpSettings() {
|
||||
const allowed = new Set(policy?.allowed_connection_ids ?? []);
|
||||
|
||||
const snippet = useMemo(
|
||||
() => buildClientSnippet(clientTab, install?.goBinaryPath || "", readOnly),
|
||||
[clientTab, install?.goBinaryPath, readOnly],
|
||||
() => buildClientSnippet(clientTab, install?.goBinaryPath || ""),
|
||||
[clientTab, install?.goBinaryPath],
|
||||
);
|
||||
|
||||
const persist = async (next: McpPolicy) => {
|
||||
@@ -192,7 +160,6 @@ export function McpSettings() {
|
||||
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" });
|
||||
@@ -220,7 +187,7 @@ export function McpSettings() {
|
||||
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 (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">
|
||||
@@ -237,7 +204,7 @@ export function McpSettings() {
|
||||
{/* 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"} />
|
||||
<StatusBadge status={installLoading && !install ? "checking" : 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]"
|
||||
@@ -247,14 +214,15 @@ export function McpSettings() {
|
||||
MCP 指南 <ExternalLink className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
{installError && <p role="alert" className="text-[11px]" style={{ color: "var(--ops-warning, #d97706)" }}>安装检测失败:{installError}。连接白名单与权限设置仍可使用。</p>}
|
||||
|
||||
{/* Version grid */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
["当前版本", install?.currentVersion || (install?.goBinaryFound ? "native" : "未检测到全局安装")],
|
||||
["当前版本", install?.currentVersion || (installLoading ? "检测中…" : install?.goBinaryFound ? "native" : "未检测到全局安装")],
|
||||
["最新版本", install?.latestVersion ? `v${install.latestVersion}` : "—"],
|
||||
["Node.js", install?.nodeVersion || "未检测到"],
|
||||
["npm", install?.npmAvailable ? (install.npmVersion || "可用") : "不可用"],
|
||||
["Node.js", install?.nodeVersion || (installLoading ? "检测中…" : "未检测到")],
|
||||
["npm", install?.npmAvailable ? (install.npmVersion || "可用") : installLoading ? "检测中…" : "不可用"],
|
||||
].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>
|
||||
@@ -296,42 +264,22 @@ export function McpSettings() {
|
||||
</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 className="space-y-2 border-t pt-3" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="rounded-md px-2 py-1" style={{ background: "var(--ops-surface-2)" }}>{readOnly ? "只读模式" : "读写模式"}</span>
|
||||
{!readOnly && allowDangerous && <span className="rounded-md px-2 py-1" style={{ background: "var(--ops-surface-2)" }}>允许危险 SQL</span>}
|
||||
{(readOnly || !allowDangerous) && <button type="button" className="ops-btn-secondary" disabled={saving || !policy} onClick={() => {
|
||||
if (policy) void persist({ ...policy, mode: "read_write", allow_dangerous: true });
|
||||
}}>使用默认权限</button>}
|
||||
</div>
|
||||
<p className="text-[11px]" style={{ color: "var(--ops-muted)" }}>Agent 写入前确认具体命令、连接与数据库。</p>
|
||||
</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>
|
||||
@@ -388,7 +336,7 @@ export function McpSettings() {
|
||||
</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}>
|
||||
<button type="button" className="ops-btn-secondary inline-flex h-7 items-center gap-1 px-2.5 text-[11px]" onClick={reload} disabled={loading || installLoading}>
|
||||
<RefreshCw className="h-3 w-3" /> 重新检查
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { ObjectTree } from "./ObjectTree";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
|
||||
const initial = useWorkspaceStore.getState();
|
||||
const serverState = useWorkspaceStore.getInitialState();
|
||||
const initialServer = {
|
||||
connections: serverState.connections,
|
||||
activeConnectionId: serverState.activeConnectionId,
|
||||
tree: serverState.tree,
|
||||
treeLoading: serverState.treeLoading,
|
||||
sidebarFilter: serverState.sidebarFilter,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
Object.assign(serverState, initialServer);
|
||||
useWorkspaceStore.setState(initial, true);
|
||||
});
|
||||
|
||||
it("展开 Redis DB 时渲染 Key,而不是把 JSX 内的源码当文字展示", () => {
|
||||
const state = {
|
||||
connections: [{ id: "redis-local", name: "Redis 连接", driver: "redis", host: "127.0.0.1", port: 6379, user: "", password: "", database: "0" }],
|
||||
activeConnectionId: "redis-local",
|
||||
tree: [{ name: "0", kind: "database", loaded: true, children: [{ name: "USER:1", kind: "key", type: "string", ttl: -1 }] }],
|
||||
treeLoading: false,
|
||||
sidebarFilter: "",
|
||||
} as const;
|
||||
Object.assign(serverState, state);
|
||||
|
||||
const html = renderToStaticMarkup(<ObjectTree />);
|
||||
expect(html).toContain("USER:1");
|
||||
expect(html).toContain("搜索 DB0 的 Key");
|
||||
expect(html).not.toContain("renderRedisNodes(");
|
||||
expect(html).not.toContain("服务端已按 pattern SCAN");
|
||||
});
|
||||
+387
-174
@@ -2,17 +2,25 @@ import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent,
|
||||
import {
|
||||
deleteRedisKey,
|
||||
deleteRedisPattern,
|
||||
downloadBlob,
|
||||
dumpTable,
|
||||
exportData,
|
||||
execSql,
|
||||
getRedisKey,
|
||||
getDdl,
|
||||
listColumns,
|
||||
listRedisKeys,
|
||||
listTables,
|
||||
loadTree,
|
||||
openSession,
|
||||
searchObjects,
|
||||
type DumpMode,
|
||||
type RedisKeyInfo,
|
||||
type SearchObjectHit,
|
||||
} from "./api";
|
||||
import { TableMetadataTree } from "./TableMetadataTree";
|
||||
import { DdlModal } from "./DdlModal";
|
||||
import { TableDesigner } from "./TableDesigner";
|
||||
import type { ColumnInfo } from "./store";
|
||||
import { isDriverReady, driverCaps, type TreeGroupKind } from "./catalog";
|
||||
import { TreeChevron } from "./TreeChevron";
|
||||
@@ -26,8 +34,11 @@ import {
|
||||
redisTypeColor,
|
||||
removeRedisKeyFromTree,
|
||||
} from "./redis";
|
||||
import { previewSelectSql, sampleSelectSql } from "./sql";
|
||||
import { previewSelectSql, quoteIdent as quoteSqlIdent, relationParts } from "./sql";
|
||||
import { connectionEndpoint } from "./contextActions";
|
||||
import { askInput } from "./dialogs";
|
||||
import { useWorkspaceStore, type SavedConnection, type TreeNode } from "./store";
|
||||
import { editorDatabase, isRedisCluster } from "./redisContext";
|
||||
|
||||
export { formatRedisTtl };
|
||||
|
||||
@@ -232,10 +243,20 @@ type CtxMenu = {
|
||||
onDelete?: () => void;
|
||||
/** R2-F3:表/视图 —— 查看 DDL(弹 DdlModal) */
|
||||
onDdl?: () => void;
|
||||
onCopyDdl?: () => void;
|
||||
/** 表/视图:查看数据 */
|
||||
onQueryData?: () => void;
|
||||
/** 表/视图:生成 SQL(P1,菜单暂不展示) */
|
||||
onQueryLimit?: () => void;
|
||||
qualifiedName?: string;
|
||||
/** 表/视图:生成 SQL 到独立查询标签 */
|
||||
onGen?: (kind: GenSqlKind) => void;
|
||||
/** 表:设计表(设计器 v1:字段网格 + CREATE/ALTER 预览) */
|
||||
onDesign?: () => void;
|
||||
/** 表:转储 SQL 文件(仅结构/仅数据/结构+数据) */
|
||||
onDump?: (mode: DumpMode) => void;
|
||||
onExport?: (format: "csv" | "json") => void;
|
||||
/** 库:新建表(设计器空表) */
|
||||
onNewTable?: () => void;
|
||||
/** 表:截断/清空 */
|
||||
onTruncate?: () => void;
|
||||
/** 表:DROP */
|
||||
@@ -256,23 +277,16 @@ type CtxMenu = {
|
||||
|
||||
type Ctx = CtxMenu | null;
|
||||
|
||||
export type GenSqlKind = "select" | "insert" | "update" | "delete";
|
||||
export type GenSqlKind = "select" | "insert" | "update" | "delete" | "ddl";
|
||||
|
||||
/** 标识符按方言加引号(Oracle 裸名) */
|
||||
function quoteIdent(driver: string | undefined, ident: string): string {
|
||||
const d = (driver || "mysql").toLowerCase();
|
||||
if (d === "postgresql" || d === "postgres" || d === "gaussdb" || d === "kingbase" || d === "opengauss") return `"${ident}"`;
|
||||
if (d === "sqlserver" || d === "mssql") return `[${ident}]`;
|
||||
if (d === "sqlite") return `"${ident}"`;
|
||||
if (d === "oracle") return ident;
|
||||
return `\`${ident}\``;
|
||||
return quoteSqlIdent(driver, ident);
|
||||
}
|
||||
|
||||
/** 库.表 全限定(SQLite 为单文件无库级) */
|
||||
function qualTable(driver: string | undefined, db: string, table: string): string {
|
||||
const d = (driver || "mysql").toLowerCase();
|
||||
if (d === "sqlite") return quoteIdent(d, table);
|
||||
return `${quoteIdent(d, db)}.${quoteIdent(d, table)}`;
|
||||
return relationParts(driver, db, table).map((part) => quoteIdent(driver, part)).join(".");
|
||||
}
|
||||
|
||||
/** 生成 SQL 模板:? 占位符,主键自动进 WHERE;无列信息时退化为骨架 */
|
||||
@@ -298,7 +312,11 @@ function buildGenSql(
|
||||
if (kind === "delete") {
|
||||
return `DELETE FROM ${qt}\nWHERE ${pk ? `${q(pk)} = ?` : "/* WHERE 条件 */"};`;
|
||||
}
|
||||
return sampleSelectSql(driver, db, table);
|
||||
const columns = names.length ? names.map(q).join(", ") : "*";
|
||||
const d = (driver ?? "").toLowerCase();
|
||||
if (d === "sqlserver" || d === "mssql") return `SELECT TOP 100 ${columns} FROM ${qt};`;
|
||||
if (d === "oracle") return `SELECT ${columns} FROM ${qt} FETCH FIRST 100 ROWS ONLY;`;
|
||||
return `SELECT ${columns} FROM ${qt} LIMIT 100;`;
|
||||
}
|
||||
|
||||
export function ObjectTree() {
|
||||
@@ -318,11 +336,15 @@ export function ObjectTree() {
|
||||
const activeTabId = useWorkspaceStore((s) => s.activeTabId);
|
||||
const [ctx, setCtx] = useState<Ctx>(null);
|
||||
const keyFilter = useWorkspaceStore((s) => s.sidebarFilter);
|
||||
const [redisDbDraft, setRedisDbDraft] = useState<Record<string, string>>({});
|
||||
const [redisDbSearch, setRedisDbSearch] = useState<Record<string, string>>({});
|
||||
const [redisKeyCursors, setRedisKeyCursors] = useState<Record<string, string>>({});
|
||||
const redisLoadedKeys = useRef<Record<string, RedisKeyInfo[]>>({});
|
||||
const redisRequests = useRef<Record<string, number>>({});
|
||||
const setKeyFilter = (v: string) => useWorkspaceStore.getState().set("sidebarFilter", v);
|
||||
/** Redis 类型过滤:全部 / string / hash / list / set / zset / stream(前端过滤) */
|
||||
const redisTypeFilter = useWorkspaceStore((s) => s.sidebarTypeFilter);
|
||||
const setRedisTypeFilter = (v: string) => useWorkspaceStore.getState().set("sidebarTypeFilter", v);
|
||||
const [newKey, setNewKey] = useState<{ name: string; type: string; value: string } | null>(null);
|
||||
const [newKey, setNewKey] = useState<{ name: string; type: string; value: string; db?: string } | null>(null);
|
||||
/** 库/表过滤(非 Redis 连接);只匹配库名与已加载的对象名 */
|
||||
const treeFilter = useWorkspaceStore((s) => s.sidebarFilter);
|
||||
const setTreeFilter = (v: string) => useWorkspaceStore.getState().set("sidebarFilter", v);
|
||||
@@ -332,6 +354,8 @@ export function ObjectTree() {
|
||||
const [dbSearchResults, setDbSearchResults] = useState<SearchObjectHit[] | null>(null);
|
||||
/** R2-F3:DDL 弹窗目标(库.表) */
|
||||
const [ddlTarget, setDdlTarget] = useState<{ db: string; table: string } | null>(null);
|
||||
/** 表设计器目标(table 缺省 = 新建表) */
|
||||
const [designerTarget, setDesignerTarget] = useState<{ db: string; table?: string } | null>(null);
|
||||
const ddlRequest = useWorkspaceStore((s) => s.ddlRequest);
|
||||
const viewDataRequest = useWorkspaceStore((s) => s.viewDataRequest);
|
||||
/** 修 O1:单击选中的树节点 key(db 或 db/obj) */
|
||||
@@ -350,6 +374,20 @@ export function ObjectTree() {
|
||||
const treeScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const conn = connections.find((c) => c.id === activeId);
|
||||
const selectedRedisDb = editorDatabase(conn, tabs.find(t => t.id === activeTabId));
|
||||
|
||||
useEffect(() => {
|
||||
setRedisDbDraft({});
|
||||
setRedisDbSearch({});
|
||||
setRedisKeyCursors({});
|
||||
redisLoadedKeys.current = {};
|
||||
}, [activeId]);
|
||||
|
||||
useEffect(() => {
|
||||
// 底部全局搜索发起新查询时,清除当前库独立搜索的覆盖。
|
||||
setRedisDbSearch({});
|
||||
setRedisDbDraft({});
|
||||
}, [keyFilter]);
|
||||
|
||||
const refresh = async (id: string) => {
|
||||
const c = connections.find((x) => x.id === id);
|
||||
@@ -383,10 +421,17 @@ export function ObjectTree() {
|
||||
}
|
||||
};
|
||||
|
||||
const treeEpoch = useWorkspaceStore((s) => s.treeEpoch);
|
||||
useEffect(() => {
|
||||
if (activeId) void refresh(activeId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeId]);
|
||||
}, [activeId, treeEpoch]);
|
||||
|
||||
useEffect(() => {
|
||||
// 底部搜索/刷新可重复提交相同关键词;每次都以该关键词为准。
|
||||
setRedisDbSearch({});
|
||||
setRedisDbDraft({});
|
||||
}, [treeEpoch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx) return;
|
||||
@@ -434,38 +479,43 @@ export function ObjectTree() {
|
||||
);
|
||||
};
|
||||
|
||||
const expandDb = async (dbName: string) => {
|
||||
const expandDb = async (dbName: string, requestedPattern?: string, nextCursor?: string) => {
|
||||
if (!conn) return;
|
||||
const node = useWorkspaceStore.getState().tree.find((n) => n.name === dbName);
|
||||
if (node?.loaded || node?.loading) return;
|
||||
if ((node?.loaded && !nextCursor) || node?.loading) return;
|
||||
const redis = isRedisConn(conn.driver);
|
||||
const request = (redisRequests.current[dbName] ?? 0) + 1;
|
||||
redisRequests.current[dbName] = request;
|
||||
patchDb(dbName, {
|
||||
loading: true,
|
||||
children: redis ? [] : emptyGroups(),
|
||||
children: redis && nextCursor ? node?.children : redis ? [] : emptyGroups(),
|
||||
});
|
||||
try {
|
||||
if (redis) {
|
||||
// 模糊语义:无通配符的输入自动包 *q*(对标 ARDM),避免 "agent" 被当精确 glob 扫不到键
|
||||
const raw = keyFilter.trim();
|
||||
const pat = !raw ? undefined : /[*?[\]]/.test(raw) ? raw : `*${raw}*`;
|
||||
const res = await withSessionRetry(conn, (sid2) => listRedisKeys(conn, dbName, { sessionId: sid2, pattern: pat }));
|
||||
// 无通配符由服务端按 *关键词* 扫描,精确键单独置顶;有通配符按 glob 扫。
|
||||
const raw = (requestedPattern ?? redisDbSearch[dbName] ?? keyFilter).trim();
|
||||
const pat = raw || undefined;
|
||||
const res = await withSessionRetry(conn, (sid2) => listRedisKeys(conn, dbName, { sessionId: sid2, pattern: pat, keyCursor: nextCursor }));
|
||||
if (useWorkspaceStore.getState().activeConnectionId !== conn.id || redisRequests.current[dbName] !== request) return;
|
||||
if (res.error) throw new Error(res.error);
|
||||
const keys = res.keys ?? [];
|
||||
const page = (res.keys ?? []).map((k) => ({
|
||||
name: k.name,
|
||||
type: k.type,
|
||||
ttl: k.ttl,
|
||||
exact: k.exact || (res.exactHit && k.name === raw) || undefined,
|
||||
}));
|
||||
const merged = new Map<string, RedisKeyInfo>();
|
||||
if (nextCursor) for (const key of redisLoadedKeys.current[dbName] ?? []) merged.set(key.name, key);
|
||||
for (const key of page) merged.set(key.name, key);
|
||||
const keys = [...merged.values()];
|
||||
redisLoadedKeys.current[dbName] = keys;
|
||||
setRedisKeyCursors(prev => ({ ...prev, [dbName]: res.nextCursor ?? "" }));
|
||||
const sep = conn.keySeparator || ":";
|
||||
patchDb(dbName, {
|
||||
loading: false,
|
||||
loaded: true,
|
||||
children: buildRedisKeyTree(keys, sep),
|
||||
});
|
||||
if (res.truncated) {
|
||||
notify({
|
||||
kind: "info",
|
||||
title: "键列表已截断",
|
||||
message: `最多展示 ${keys.length} 个键(SCAN)。可用过滤缩小范围。`,
|
||||
mode: "toast",
|
||||
durationMs: 2200,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const sid = await ensureSession(conn);
|
||||
@@ -551,19 +601,40 @@ export function ObjectTree() {
|
||||
],
|
||||
});
|
||||
} catch (e) {
|
||||
if (redis && (redisRequests.current[dbName] !== request || useWorkspaceStore.getState().activeConnectionId !== conn.id)) return;
|
||||
patchDb(dbName, {
|
||||
loading: false,
|
||||
loaded: true,
|
||||
children: isRedisConn(conn.driver) ? [] : emptyGroups(),
|
||||
loaded: !!nextCursor,
|
||||
children: nextCursor ? node?.children : isRedisConn(conn.driver) ? [] : emptyGroups(),
|
||||
});
|
||||
notifyError(e, "toast");
|
||||
}
|
||||
};
|
||||
|
||||
const expandTable = async (dbName: string, table: string) => {
|
||||
const searchRedisDb = (dbName: string) => {
|
||||
const raw = (redisDbDraft[dbName] ?? "").trim();
|
||||
setRedisDbSearch(prev => ({ ...prev, [dbName]: raw }));
|
||||
patchDb(dbName, { loaded: false, loading: false, children: undefined });
|
||||
void expandDb(dbName, raw);
|
||||
};
|
||||
|
||||
// Redis:刷新后自动展开库以灌入 SCAN/精确结果(details open 属性不保证触发 onToggle)
|
||||
useEffect(() => {
|
||||
if (!conn || !isRedisConn(conn.driver) || treeLoading) return;
|
||||
const shouldExpand = keyFilter.trim().length > 0 || tree.length === 1;
|
||||
if (!shouldExpand) return;
|
||||
const currentDb = selectedRedisDb;
|
||||
for (const db of tree) {
|
||||
if (db.name !== currentDb) continue;
|
||||
if (db.kind === "database" && !db.loaded && !db.loading) void expandDb(db.name);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [conn?.id, tree, treeLoading, keyFilter, selectedRedisDb]);
|
||||
|
||||
const expandTable = async (dbName: string, table: string, groupId = "tables") => {
|
||||
if (!conn) return;
|
||||
const dbNode = useWorkspaceStore.getState().tree.find((n) => n.name === dbName);
|
||||
const tablesGroup = dbNode?.children?.find((n) => n.groupId === "tables");
|
||||
const tablesGroup = dbNode?.children?.find((n) => n.groupId === groupId);
|
||||
const tNode = tablesGroup?.children?.find((n) => n.name === table);
|
||||
if (tNode?.loaded || tNode?.loading) return;
|
||||
|
||||
@@ -571,7 +642,7 @@ export function ObjectTree() {
|
||||
patchDb(dbName, (d) => ({
|
||||
...d,
|
||||
children: (d.children ?? []).map((g) =>
|
||||
g.groupId !== "tables"
|
||||
g.groupId !== groupId
|
||||
? g
|
||||
: {
|
||||
...g,
|
||||
@@ -644,6 +715,7 @@ export function ObjectTree() {
|
||||
error: res.error,
|
||||
truncated: res.truncated,
|
||||
limit: res.limit,
|
||||
nextCursor: res.nextCursor,
|
||||
};
|
||||
const hint = redisHintForType(key, res.type);
|
||||
set("resultMeta", { database: dbName, table: key, pkCols: [], redisType: res.type, redisTtl: res.ttl });
|
||||
@@ -801,7 +873,7 @@ export function ObjectTree() {
|
||||
}
|
||||
};
|
||||
|
||||
const putSelectSql = async (dbName: string, table: string) => {
|
||||
const putSelectSql = async (dbName: string, table: string, limit = 100) => {
|
||||
if (!conn) return;
|
||||
set("focusedRelation", { database: dbName, table });
|
||||
if (isRedisConn(conn.driver)) {
|
||||
@@ -837,28 +909,23 @@ export function ObjectTree() {
|
||||
// 预览 SQL 不烧 LIMIT:翻页由请求层 offset/limit 承担(带 LIMIT 时第 2 页起恒为空)
|
||||
const sql = previewSelectSql(conn.driver, dbName, table);
|
||||
const tab = tabs.find((t) => t.id === activeTabId);
|
||||
if (tab?.dirty) {
|
||||
const openNew = await askConfirm({
|
||||
title: "当前查询未保存",
|
||||
message: `「${tab.title}」有未保存改动。新开标签写入 SELECT?`,
|
||||
okLabel: "新开标签",
|
||||
});
|
||||
if (!openNew) return;
|
||||
newQueryTab();
|
||||
await Promise.resolve();
|
||||
useWorkspaceStore.getState().updateActiveSql(sql);
|
||||
// 新 Tab 标题用表名,避免一排「查询 N」找不到哪页是哪张表
|
||||
useWorkspaceStore.getState().setActiveTabTitle(table);
|
||||
} else {
|
||||
updateActiveSql(sql);
|
||||
const samePreview = tab?.connectionId === conn.id && tab.database === dbName && tab.sql === sql &&
|
||||
(tab.title === table || tab.title === `${table}@${dbName}`);
|
||||
if (!samePreview && (tab?.dirty || !!tab?.sql.trim())) {
|
||||
// 当前标签有内容时新开,避免覆盖其他表预览或 SQL 草稿。
|
||||
newQueryTab(dbName);
|
||||
}
|
||||
useWorkspaceStore.getState().updateActiveSql(sql);
|
||||
useWorkspaceStore.getState().setActiveTabTitle(`${table}@${dbName}`);
|
||||
useWorkspaceStore.getState().setTabDatabase(dbName);
|
||||
set("running", true);
|
||||
set("status", `打开 ${table}…`);
|
||||
try {
|
||||
const res = await withSessionRetry(conn, (sid) => execSql(conn, sql, { database: dbName, sessionId: sid, limit: 100 }));
|
||||
const res = await withSessionRetry(conn, (sid) => execSql(conn, sql, { database: dbName, sessionId: sid, limit }));
|
||||
// 挂为结果集(C5):树打开的表预览同样可翻页
|
||||
setResultSets([
|
||||
{ id: crypto.randomUUID(), label: table, sql, result: res, page: 0, pageSize: 100, database: dbName },
|
||||
{ id: crypto.randomUUID(), label: table, sql, result: res, page: 0, pageSize: limit, database: dbName,
|
||||
source: { connectionId: conn.id, driver: conn.driver, database: dbName, table } },
|
||||
]);
|
||||
set("resultTab", "data");
|
||||
try {
|
||||
@@ -883,8 +950,9 @@ export function ObjectTree() {
|
||||
};
|
||||
|
||||
const copyName = async (name: string) => {
|
||||
await navigator.clipboard.writeText(name);
|
||||
notify({ kind: "success", title: "已复制", message: name, mode: "toast", durationMs: 1200 });
|
||||
try {
|
||||
await navigator.clipboard.writeText(name);
|
||||
} catch (e) { notifyError(e, "toast"); }
|
||||
};
|
||||
|
||||
const onCtx = (
|
||||
@@ -899,11 +967,18 @@ export function ObjectTree() {
|
||||
CtxMenu,
|
||||
| "kind"
|
||||
| "onQueryData"
|
||||
| "onQueryLimit"
|
||||
| "qualifiedName"
|
||||
| "onGen"
|
||||
| "onDesign"
|
||||
| "onDump"
|
||||
| "onExport"
|
||||
| "onNewTable"
|
||||
| "onTruncate"
|
||||
| "onDropTable"
|
||||
| "onNewQuery"
|
||||
| "onDdl"
|
||||
| "onCopyDdl"
|
||||
| "onBrowse"
|
||||
| "onSetDefaultDb"
|
||||
| "onDropDatabase"
|
||||
@@ -970,6 +1045,18 @@ export function ObjectTree() {
|
||||
/** 生成表 SQL 模板:优先用树上已加载的列,取不到再查库 */
|
||||
const genTableSql = async (dbName: string, table: string, kind: GenSqlKind) => {
|
||||
if (!conn) return;
|
||||
if (kind === "ddl") {
|
||||
try {
|
||||
const res = await withSessionRetry(conn, sid => getDdl(conn, table, { database: dbName, sessionId: sid }));
|
||||
if (res.error || !res.ddl) throw new Error(res.error || "未返回 DDL");
|
||||
if (useWorkspaceStore.getState().activeConnectionId !== conn.id) return;
|
||||
const st = useWorkspaceStore.getState();
|
||||
st.newQueryTab(dbName);
|
||||
st.updateActiveSql(res.ddl);
|
||||
st.setActiveTabTitle(`DDL · ${table}`);
|
||||
} catch (error) { notifyError(error, "toast"); }
|
||||
return;
|
||||
}
|
||||
const st = useWorkspaceStore.getState();
|
||||
const tNode = st.tree
|
||||
.find((n) => n.name === dbName)
|
||||
@@ -983,12 +1070,58 @@ export function ObjectTree() {
|
||||
/* 拿不到列也能生成骨架模板 */
|
||||
}
|
||||
}
|
||||
// 模板进入独立标签,避免覆盖现有草稿或异步加载期间切换到的查询。
|
||||
if (useWorkspaceStore.getState().activeConnectionId !== conn.id) return;
|
||||
st.newQueryTab(dbName);
|
||||
st.updateActiveSql(buildGenSql(kind, conn.driver, dbName, table, cols));
|
||||
st.setActiveTabTitle(`${kind.toUpperCase()} · ${table}`);
|
||||
set("status", `已生成 ${kind.toUpperCase()} · ${dbName}.${table}`);
|
||||
};
|
||||
|
||||
/** 截断 / 删除表:二次确认后执行并刷新库 */
|
||||
|
||||
/** 表右键「转储 SQL 文件」:服务端流式生成 .sql 后浏览器下载 */
|
||||
const dumpTableSql = async (dbName: string, table: string, mode: DumpMode) => {
|
||||
if (!conn) return;
|
||||
try {
|
||||
const { blob, filename } = await withSessionRetry(conn, (sid) =>
|
||||
dumpTable(conn, { table, database: dbName, mode, sessionId: sid }),
|
||||
);
|
||||
downloadBlob(blob, filename || `dump-${table}.sql`);
|
||||
const label = mode === "structure" ? "仅结构" : mode === "data" ? "仅数据" : "结构和数据";
|
||||
notify({ kind: "success", title: "转储已下载", message: `${label} · ${dbName}.${table}`, mode: "toast", durationMs: 1800, feedback: "visible" });
|
||||
} catch (e) {
|
||||
notifyError(e, "toast");
|
||||
}
|
||||
};
|
||||
|
||||
const exportTableData = async (dbName: string, table: string, format: "csv" | "json") => {
|
||||
if (!conn) return;
|
||||
const input = await askInput({
|
||||
title: `导出 ${dbName}.${table} · ${format.toUpperCase()}`,
|
||||
label: "最多导出行数(1–500000;达到上限时文件可能不完整)",
|
||||
defaultValue: "100000",
|
||||
placeholder: "100000",
|
||||
});
|
||||
if (input == null) return;
|
||||
const limit = Number(input.trim());
|
||||
if (!/^\d+$/.test(input.trim()) || limit < 1 || limit > 500000) {
|
||||
notify({ kind: "warning", title: "行数无效", message: "请输入 1–500000 之间的整数", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { blob, filename } = await withSessionRetry(conn, sid => exportData(conn, { database: dbName, table, format, limit, sessionId: sid }));
|
||||
downloadBlob(blob, filename || `${table}.${format}`);
|
||||
notify({ kind: "success", title: "导出已下载", message: `上限 ${limit} 行;若表超过上限,文件仅包含前 ${limit} 行`, mode: "toast", feedback: "visible" });
|
||||
} catch (error) { notifyError(error, "modal"); }
|
||||
};
|
||||
|
||||
/** 设计器应用后刷新该库对象(结构与表清单都可能变化) */
|
||||
const refreshDbAfterDesign = (dbName: string) => {
|
||||
patchDb(dbName, { loaded: false, loading: false, children: undefined });
|
||||
void expandDb(dbName);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!ddlRequest) return;
|
||||
setDdlTarget({ db: ddlRequest.database, table: ddlRequest.table });
|
||||
@@ -1059,7 +1192,8 @@ export function ObjectTree() {
|
||||
/** 过滤视图:库名命中显示整库;否则只留名称命中的子对象(表/视图/键等) */
|
||||
const f = treeFilter.trim().toLowerCase();
|
||||
const nameHit = (n: string) => !f || n.toLowerCase().includes(f);
|
||||
const visibleTree = f
|
||||
// Redis:侧栏关键字由 listRedisKeys + filterRedisTree 负责;勿按库名/顶层 folder 名剪空(否则精确键永远不显示)
|
||||
const visibleTree = f && !isRedisConn(conn?.driver)
|
||||
? tree
|
||||
.map((db) => {
|
||||
if (nameHit(db.name)) return db;
|
||||
@@ -1198,11 +1332,11 @@ export function ObjectTree() {
|
||||
kind: "folder",
|
||||
copyLabel: "复制前缀",
|
||||
onFilterPrefix: () => {
|
||||
setKeyFilter(`${obj.fullName || obj.name}*`);
|
||||
patchDb(dbName, { loaded: false, loading: false, children: undefined });
|
||||
void expandDb(dbName);
|
||||
const pattern = `${obj.fullName || obj.name}*`;
|
||||
setKeyFilter(pattern);
|
||||
useWorkspaceStore.getState().requestTreeRefresh();
|
||||
},
|
||||
onNewKey: () => setNewKey({ name: obj.fullName || "", type: "string", value: "" }),
|
||||
onNewKey: () => setNewKey({ name: obj.fullName || "", type: "string", value: "", db: dbName }),
|
||||
onDeleteFolder: () => void deleteFolderPrefix(dbName, obj.fullName || obj.name, obj.count ?? 0),
|
||||
})
|
||||
}
|
||||
@@ -1271,9 +1405,17 @@ export function ObjectTree() {
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{redisLeafLabel(obj.name, redisSep)}</span>
|
||||
{obj.exact && (
|
||||
<span
|
||||
className="ml-1 shrink-0 rounded px-1 text-[10px] font-medium"
|
||||
style={{ color: "var(--ops-accent)", background: "color-mix(in srgb, var(--ops-accent) 18%, transparent)" }}
|
||||
>
|
||||
精确
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-1 font-normal opacity-45">
|
||||
{obj.type || "?"}
|
||||
{obj.ttl != null ? ` · ${formatRedisTtl(obj.ttl)}` : ""}
|
||||
{obj.ttl != null && obj.ttl >= 0 ? ` · ${formatRedisTtl(obj.ttl)}` : ""}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1313,8 +1455,8 @@ export function ObjectTree() {
|
||||
<details
|
||||
key={obj.name}
|
||||
onToggle={(e) => {
|
||||
if ((e.currentTarget as HTMLDetailsElement).open && obj.kind === "table") {
|
||||
void expandTable(dbName, obj.name);
|
||||
if (e.target === e.currentTarget && (e.currentTarget as HTMLDetailsElement).open) {
|
||||
void expandTable(dbName, obj.name, obj.kind === "view" ? "views" : "tables");
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) =>
|
||||
@@ -1348,7 +1490,28 @@ export function ObjectTree() {
|
||||
{
|
||||
kind: obj.kind === "view" ? "view" : "table",
|
||||
onQueryData: () => void putSelectSql(dbName, obj.name),
|
||||
onQueryLimit: () => { void (async () => {
|
||||
const value = await askInput({ title: "打开前 N 行", label: "行数(1–10000)", defaultValue: "100", placeholder: "100" });
|
||||
if (value == null) return;
|
||||
if (!/^\d+$/.test(value.trim()) || Number(value) < 1 || Number(value) > 10000) {
|
||||
notify({ kind: "warning", title: "行数无效", message: "请输入 1–10000 之间的整数", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
if (useWorkspaceStore.getState().activeConnectionId === conn?.id) await putSelectSql(dbName, obj.name, Number(value));
|
||||
})(); },
|
||||
qualifiedName: qualTable(conn?.driver, dbName, obj.name),
|
||||
onGen: (k) => void genTableSql(dbName, obj.name, k),
|
||||
onDesign:
|
||||
obj.kind === "table" && driverCaps(conn?.driver ?? "").ddl
|
||||
? () => setDesignerTarget({ db: dbName, table: obj.name })
|
||||
: undefined,
|
||||
onDump:
|
||||
obj.kind === "table" && driverCaps(conn?.driver ?? "").ddl
|
||||
? (mode) => void dumpTableSql(dbName, obj.name, mode)
|
||||
: undefined,
|
||||
onExport: obj.kind === "table" && driverCaps(conn?.driver ?? "").ddl
|
||||
? format => void exportTableData(dbName, obj.name, format)
|
||||
: undefined,
|
||||
// DDL/截断/删除按驱动能力开关:ES/Influx/Mongo/ZK 等只读树不提供
|
||||
onDdl: driverCaps(conn?.driver ?? "").ddl
|
||||
? () => {
|
||||
@@ -1356,6 +1519,16 @@ export function ObjectTree() {
|
||||
setDdlTarget({ db: dbName, table: obj.name });
|
||||
}
|
||||
: undefined,
|
||||
onCopyDdl: driverCaps(conn?.driver ?? "").ddl && conn ? () => {
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await withSessionRetry(conn, (sessionId) => getDdl(conn, obj.name, { database: dbName, sessionId }));
|
||||
if (response.error) throw new Error(response.error);
|
||||
if (!response.ddl) throw new Error("未返回表结构定义");
|
||||
await copyName(response.ddl);
|
||||
} catch (error) { notifyError(error, "toast"); }
|
||||
})();
|
||||
} : undefined,
|
||||
onTruncate:
|
||||
obj.kind === "table" && driverCaps(conn?.driver ?? "").truncate
|
||||
? () => void runTableDanger(dbName, obj.name, "truncate")
|
||||
@@ -1421,6 +1594,7 @@ export function ObjectTree() {
|
||||
<ColumnRow key={sub.name} col={sub} onRefresh={() => void expandTable(dbName, obj.name)} />
|
||||
),
|
||||
)}
|
||||
{obj.loaded && conn && obj.kind === "table" && driverCaps(conn.driver).ddl && <TableMetadataTree conn={conn} database={dbName} table={obj.name} />}
|
||||
{obj.loading && (
|
||||
<div className="px-1.5 py-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||||
列…
|
||||
@@ -1539,118 +1713,60 @@ export function ObjectTree() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isRedisConn(conn?.driver) && (
|
||||
<div style={{ display: "none" }}>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
data-ops-tree-filter
|
||||
className="ops-input min-w-0 flex-1 py-0.5 text-[11px]"
|
||||
placeholder="搜索键名,输入即过滤;回车服务端模糊扫描"
|
||||
value={keyFilter}
|
||||
onChange={(e) => setKeyFilter(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const openDbs = useWorkspaceStore.getState().tree.filter((n) => n.loaded || n.loading);
|
||||
for (const d of openDbs) {
|
||||
patchDb(d.name, { loaded: false, loading: false, children: undefined });
|
||||
void expandDb(d.name);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* P1: Redis 顶栏已迁到 SidebarFilterBar(单搜索+↻);新建键表单由库/文件夹右键唤起 */}
|
||||
{isRedisConn(conn?.driver) && newKey && (
|
||||
<div className="mx-1 mb-1 space-y-1 rounded border p-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<div className="text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||||
新建键{newKey.db != null && newKey.db !== "" ? ` · DB${newKey.db}` : ""}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<select
|
||||
className="ops-input shrink-0 py-0.5 text-[11px]"
|
||||
style={{ width: "auto" }}
|
||||
value={redisTypeFilter}
|
||||
onChange={(e) => setRedisTypeFilter(e.target.value)}
|
||||
title="按类型筛选"
|
||||
className="ops-input h-6 py-0 text-[11px]"
|
||||
value={newKey.type}
|
||||
onChange={(e) => setNewKey({ ...newKey, type: e.target.value })}
|
||||
>
|
||||
<option value="all">全部类型</option>
|
||||
<option value="string">string</option>
|
||||
<option value="hash">hash</option>
|
||||
<option value="list">list</option>
|
||||
<option value="set">set</option>
|
||||
<option value="zset">zset</option>
|
||||
<option value="stream">stream</option>
|
||||
<option value="string">String</option>
|
||||
<option value="hash">Hash</option>
|
||||
<option value="list">List</option>
|
||||
<option value="set">Set</option>
|
||||
<option value="zset">ZSet</option>
|
||||
</select>
|
||||
<input
|
||||
className="ops-input h-6 min-w-0 flex-1 py-0 font-mono text-[11px]"
|
||||
placeholder="键名"
|
||||
value={newKey.name}
|
||||
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
className="ops-input h-6 w-full py-0 font-mono text-[11px]"
|
||||
placeholder={newKey.type === "hash" ? "初始 field 的值" : "初始值"}
|
||||
value={newKey.value}
|
||||
onChange={(e) => setNewKey({ ...newKey, value: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const dbName = newKey.db || tree.find((n) => n.kind === "database")?.name || "0";
|
||||
void createRedisKey(dbName, newKey);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" className="text-[11px] opacity-70" onClick={() => setNewKey(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-[11px] hover:underline"
|
||||
className="text-[11px] hover:underline"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
onClick={() => {
|
||||
const openDbs = useWorkspaceStore.getState().tree.filter((n) => n.loaded || n.loading);
|
||||
for (const d of openDbs) {
|
||||
patchDb(d.name, { loaded: false, loading: false, children: undefined });
|
||||
void expandDb(d.name);
|
||||
}
|
||||
const dbName = newKey.db || tree.find((n) => n.kind === "database")?.name || "0";
|
||||
void createRedisKey(dbName, newKey);
|
||||
}}
|
||||
>
|
||||
应用
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-[11px] hover:underline"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
title="新建键"
|
||||
onClick={() =>
|
||||
setNewKey({
|
||||
name: keyFilter.endsWith("*") ? keyFilter.slice(0, -1) : keyFilter,
|
||||
type: "string",
|
||||
value: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
新建
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
{newKey && (
|
||||
<div className="mt-1 space-y-1 rounded border p-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<div className="flex gap-1">
|
||||
<select
|
||||
className="ops-input h-6 py-0 text-[11px]"
|
||||
value={newKey.type}
|
||||
onChange={(e) => setNewKey({ ...newKey, type: e.target.value })}
|
||||
>
|
||||
<option value="string">String</option>
|
||||
<option value="hash">Hash</option>
|
||||
<option value="list">List</option>
|
||||
<option value="set">Set</option>
|
||||
<option value="zset">ZSet</option>
|
||||
</select>
|
||||
<input
|
||||
className="ops-input h-6 min-w-0 flex-1 py-0 font-mono text-[11px]"
|
||||
placeholder="键名"
|
||||
value={newKey.name}
|
||||
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
className="ops-input h-6 w-full py-0 font-mono text-[11px]"
|
||||
placeholder={newKey.type === "hash" ? "初始 field 的值" : "初始值"}
|
||||
value={newKey.value}
|
||||
onChange={(e) => setNewKey({ ...newKey, value: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && tree[0]) void createRedisKey(tree[0].name, newKey);
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" className="text-[11px] opacity-70" onClick={() => setNewKey(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] hover:underline"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
onClick={() => {
|
||||
const dbName = tree.find((n) => n.kind === "database")?.name ?? "0";
|
||||
void createRedisKey(dbName, newKey);
|
||||
}}
|
||||
>
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
@@ -1670,13 +1786,32 @@ export function ObjectTree() {
|
||||
{conn ? "点刷新加载库;无权限时会提示错误" : "选择连接后浏览对象"}
|
||||
</div>
|
||||
)}
|
||||
{visibleTree.map((db) => (
|
||||
{visibleTree.map((db) => conn && isRedisConn(conn.driver) && isRedisCluster(conn) ? (
|
||||
<div key={db.name}>
|
||||
<div className="flex items-center gap-1 px-1 py-1">
|
||||
<input className="ops-input h-6 min-w-0 flex-1 text-[11px]" aria-label="搜索集群 Key"
|
||||
placeholder="搜索集群 Key(自动包含匹配)"
|
||||
value={redisDbDraft[db.name] ?? ""}
|
||||
onChange={e => setRedisDbDraft(prev => ({ ...prev, [db.name]: e.target.value }))}
|
||||
onKeyDown={e => { if (e.key === "Enter" && !e.nativeEvent.isComposing) searchRedisDb(db.name); }} />
|
||||
<button type="button" className="ops-btn-ghost text-[11px]" onClick={() => searchRedisDb(db.name)}>搜索</button>
|
||||
</div>
|
||||
{db.loading && <div className="ops-tree-meta-note">加载 Key…</div>}
|
||||
{renderRedisNodes(db.name, filterRedisTree(db.children ?? [], /[*?[\]]/.test((redisDbSearch[db.name] ?? keyFilter).trim()) ? "" : redisDbSearch[db.name] ?? keyFilter, redisTypeFilter))}
|
||||
{redisKeyCursors[db.name] && <button type="button" className="ops-btn-ghost mx-2 my-1 text-[11px]" disabled={db.loading}
|
||||
title="继续从当前游标扫描,不会一次读取全部 Key"
|
||||
onClick={() => void expandDb(db.name, redisDbSearch[db.name] ?? keyFilter, redisKeyCursors[db.name])}>
|
||||
{db.loading ? "加载中…" : "加载更多"}
|
||||
</button>}
|
||||
{db.loaded && !db.children?.length && <div className="ops-tree-meta-note">未找到 Key;搜索有扫描上限,可输入更明确的名称</div>}
|
||||
</div>
|
||||
) : (
|
||||
<details
|
||||
key={db.name}
|
||||
className="mb-px"
|
||||
open={f ? true : undefined}
|
||||
open={isRedisConn(conn?.driver) ? db.name === selectedRedisDb || !!redisDbSearch[db.name] : f ? true : undefined}
|
||||
onToggle={(e) => {
|
||||
if ((e.currentTarget as HTMLDetailsElement).open) void expandDb(db.name);
|
||||
if (e.target === e.currentTarget && (e.currentTarget as HTMLDetailsElement).open) void expandDb(db.name);
|
||||
}}
|
||||
onContextMenu={(e) =>
|
||||
onCtx(e, db.name, () => {
|
||||
@@ -1686,9 +1821,12 @@ export function ObjectTree() {
|
||||
kind: "database",
|
||||
onNewQuery: isRedisConn(conn?.driver)
|
||||
? undefined
|
||||
: () => useWorkspaceStore.getState().newQueryTab(),
|
||||
: () => useWorkspaceStore.getState().newQueryTab(db.name),
|
||||
onNewTable: !isRedisConn(conn?.driver) && driverCaps(conn?.driver ?? "").ddl
|
||||
? () => setDesignerTarget({ db: db.name })
|
||||
: undefined,
|
||||
onNewKey: isRedisConn(conn?.driver)
|
||||
? () => setNewKey({ name: "", type: "string", value: "" })
|
||||
? () => setNewKey({ name: "", type: "string", value: "", db: db.name })
|
||||
: undefined,
|
||||
onBrowse: () => void expandDb(db.name),
|
||||
onSetDefaultDb: canSetDefaultDb(conn?.driver) && driverCaps(conn?.driver ?? "").setDefaultDb
|
||||
@@ -1715,8 +1853,29 @@ export function ObjectTree() {
|
||||
{db.loading && <span className="ml-auto text-[10px] opacity-40">…</span>}
|
||||
</summary>
|
||||
<div className="ml-1.5 border-l pl-1" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
{isRedisConn(conn?.driver) ? (
|
||||
renderRedisNodes(db.name, filterRedisTree(db.children ?? [], keyFilter, redisTypeFilter))
|
||||
{isRedisConn(conn?.driver) && <div className="flex items-center gap-1 px-1 py-1">
|
||||
<input className="ops-input h-6 min-w-0 flex-1 text-[11px]" aria-label={`搜索 DB${db.name} 的 Key`}
|
||||
placeholder={`搜索 DB${db.name} Key(自动包含匹配)`}
|
||||
value={redisDbDraft[db.name] ?? ""}
|
||||
onChange={e => setRedisDbDraft(prev => ({ ...prev, [db.name]: e.target.value }))}
|
||||
onKeyDown={e => { if (e.key === "Enter" && !e.nativeEvent.isComposing) searchRedisDb(db.name); }} />
|
||||
<button type="button" className="ops-btn-ghost text-[11px]" onClick={() => searchRedisDb(db.name)}>搜索</button>
|
||||
</div>}
|
||||
{isRedisConn(conn?.driver) ? (<>
|
||||
{/* 服务端已按 pattern SCAN/精确命中;通配符查询不再做字面子串过滤。 */}
|
||||
{renderRedisNodes(
|
||||
db.name,
|
||||
filterRedisTree(
|
||||
db.children ?? [],
|
||||
/[*?[\]]/.test((redisDbSearch[db.name] ?? keyFilter).trim()) ? "" : redisDbSearch[db.name] ?? keyFilter,
|
||||
redisTypeFilter,
|
||||
),
|
||||
)}
|
||||
{redisKeyCursors[db.name] && <button type="button" className="ops-btn-ghost mx-1 my-1 text-[11px]" disabled={db.loading} title="继续从当前游标扫描,不会一次读取全部 Key"
|
||||
onClick={() => void expandDb(db.name, redisDbSearch[db.name] ?? keyFilter, redisKeyCursors[db.name])}>
|
||||
{db.loading ? "加载中…" : "加载更多"}
|
||||
</button>}
|
||||
</>
|
||||
) : driverCaps(conn?.driver ?? "").treeGroups.length === 0 ? (
|
||||
// flat 驱动(ES/Mongo/Influx/ZK):对象直挂库节点,无分组包装
|
||||
<>
|
||||
@@ -1769,7 +1928,7 @@ export function ObjectTree() {
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
{f && visibleTree.length === 0 && !treeLoading && (
|
||||
{f && !isRedisConn(conn?.driver) && visibleTree.length === 0 && !treeLoading && (
|
||||
<div className="px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
<div>没有匹配「{treeFilter.trim()}」的对象;未展开的库只匹配库名。</div>
|
||||
{tree.some((db) => !db.loaded) && (
|
||||
@@ -1784,6 +1943,12 @@ export function ObjectTree() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{f && isRedisConn(conn?.driver) && !treeLoading && tree.length > 0 &&
|
||||
tree.every((db) => db.loaded && filterRedisTree(db.children ?? [], /[*?[\]]/.test(keyFilter.trim()) ? "" : keyFilter, redisTypeFilter).length === 0) && (
|
||||
<div className="px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
没有匹配「{treeFilter.trim()}」的键
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ctx && (
|
||||
@@ -1800,13 +1965,20 @@ export function ObjectTree() {
|
||||
{/* 库 P0 */}
|
||||
{ctx.kind === "database" && (
|
||||
<>
|
||||
{ctx.onNewKey && (
|
||||
<button type="button" onClick={() => { ctx.onNewKey?.(); setCtx(null); }}>新建键</button>
|
||||
)}
|
||||
{ctx.onNewQuery && (
|
||||
<button type="button" onClick={() => { ctx.onNewQuery?.(); setCtx(null); }}>新建查询</button>
|
||||
)}
|
||||
{ctx.onNewTable && (
|
||||
<button type="button" onClick={() => { ctx.onNewTable?.(); setCtx(null); }}>新建表…</button>
|
||||
)}
|
||||
{ctx.onBrowse && (
|
||||
<button type="button" onClick={() => { ctx.onBrowse?.(); setCtx(null); }}>浏览对象</button>
|
||||
)}
|
||||
<button type="button" onClick={() => { void copyName(ctx.label); setCtx(null); }}>{ctx.copyLabel ?? "复制名称"}</button>
|
||||
{conn && <button type="button" onClick={() => { void copyName(connectionEndpoint(conn)); setCtx(null); }}>复制主机端口</button>}
|
||||
{ctx.onSetDefaultDb ? (
|
||||
<button type="button" onClick={() => { ctx.onSetDefaultDb?.(); setCtx(null); }}>设为默认库</button>
|
||||
) : (
|
||||
@@ -1828,10 +2000,40 @@ export function ObjectTree() {
|
||||
{ctx.onQueryData && (
|
||||
<button type="button" onClick={() => { ctx.onQueryData?.(); setCtx(null); }}>查看数据</button>
|
||||
)}
|
||||
{ctx.onQueryLimit && <button type="button" onClick={() => { ctx.onQueryLimit?.(); setCtx(null); }}>打开前 N 行…</button>}
|
||||
{ctx.onSchema && <button type="button" onClick={() => { ctx.onSchema?.(); setCtx(null); }}>查看结构</button>}
|
||||
{ctx.onDesign && (
|
||||
<button type="button" onClick={() => { ctx.onDesign?.(); setCtx(null); }}>设计表…</button>
|
||||
)}
|
||||
{ctx.onDdl && (
|
||||
<button type="button" onClick={() => { ctx.onDdl?.(); setCtx(null); }}>查看 DDL</button>
|
||||
)}
|
||||
<button type="button" onClick={() => { void copyName(ctx.label); setCtx(null); }}>{ctx.copyLabel ?? "复制名称"}</button>
|
||||
{ctx.qualifiedName && <button type="button" onClick={() => { void copyName(ctx.qualifiedName!); setCtx(null); }}>复制限定表名(库 / Schema 前缀)</button>}
|
||||
{ctx.onCopyDdl && <button type="button" onClick={() => { ctx.onCopyDdl?.(); setCtx(null); }}>复制建表 / 视图 SQL</button>}
|
||||
{ctx.onGen && <>
|
||||
<div className="ops-ctx-sep" />
|
||||
<details className="ops-ctx-group"><summary>生成 SQL <span>›</span></summary>
|
||||
<button type="button" onClick={() => { ctx.onGen?.("select"); setCtx(null); }}>SELECT</button>
|
||||
{ctx.kind === "table" && <button type="button" onClick={() => { ctx.onGen?.("insert"); setCtx(null); }}>INSERT</button>}
|
||||
{ctx.kind === "table" && <button type="button" onClick={() => { ctx.onGen?.("update"); setCtx(null); }}>UPDATE</button>}
|
||||
{ctx.kind === "table" && <button type="button" onClick={() => { ctx.onGen?.("delete"); setCtx(null); }}>DELETE</button>}
|
||||
{ctx.onDdl && <button type="button" onClick={() => { ctx.onGen?.("ddl"); setCtx(null); }}>DDL</button>}
|
||||
</details>
|
||||
</>}
|
||||
{ctx.onExport && <>
|
||||
<details className="ops-ctx-group"><summary>导出数据 <span>›</span></summary>
|
||||
<button type="button" onClick={() => { ctx.onExport?.("csv"); setCtx(null); }}>CSV…</button>
|
||||
<button type="button" onClick={() => { ctx.onExport?.("json"); setCtx(null); }}>JSON…</button>
|
||||
</details>
|
||||
</>}
|
||||
{ctx.onDump && <>
|
||||
<details className="ops-ctx-group"><summary>转储 SQL <span>›</span></summary>
|
||||
<button type="button" onClick={() => { ctx.onDump?.("structure"); setCtx(null); }}>仅结构…</button>
|
||||
<button type="button" onClick={() => { ctx.onDump?.("data"); setCtx(null); }}>仅数据…</button>
|
||||
<button type="button" onClick={() => { ctx.onDump?.("structure_data"); setCtx(null); }}>结构和数据…</button>
|
||||
</details>
|
||||
</>}
|
||||
<button type="button" onClick={() => { ctx.onRefresh(); setCtx(null); }}>刷新</button>
|
||||
{(ctx.onTruncate || ctx.onDropTable) && <div className="ops-ctx-sep" />}
|
||||
{ctx.onTruncate && (
|
||||
@@ -1904,6 +2106,17 @@ export function ObjectTree() {
|
||||
onClose={() => setDdlTarget(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 表设计器 v1:新建表 / 设计既有表(字段网格 + DDL 预览) */}
|
||||
{designerTarget && conn && (
|
||||
<TableDesigner
|
||||
conn={conn}
|
||||
database={designerTarget.db}
|
||||
table={designerTarget.table}
|
||||
onClose={() => setDesignerTarget(null)}
|
||||
onApplied={() => refreshDbAfterDesign(designerTarget.db)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+17
-3
@@ -18,6 +18,7 @@ export type OpsModalProps = {
|
||||
/** 主按钮红色(删除/截断等危险确认) */
|
||||
danger?: boolean;
|
||||
okLabel?: string;
|
||||
okDisabled?: boolean;
|
||||
cancelLabel?: string;
|
||||
/** input 变体:输入框标签 */
|
||||
label?: string;
|
||||
@@ -29,6 +30,10 @@ export type OpsModalProps = {
|
||||
zIndex?: string | number;
|
||||
/** 宽版(max-w-2xl),DDL 等长文本展示用 */
|
||||
wide?: boolean;
|
||||
/** 表设计等多列编辑使用的大工作区。 */
|
||||
spacious?: boolean;
|
||||
className?: string;
|
||||
footerLeading?: ReactNode;
|
||||
/** 确认回调;input 变体携带输入值 */
|
||||
onOk: (value?: string) => void;
|
||||
/** 取消回调(Esc / 遮罩点击 / 取消按钮),info 变体缺省时回落到 onOk */
|
||||
@@ -45,12 +50,16 @@ export function OpsModal(props: OpsModalProps) {
|
||||
variant = "info",
|
||||
danger = false,
|
||||
okLabel = "确定",
|
||||
okDisabled = false,
|
||||
cancelLabel = "取消",
|
||||
label,
|
||||
defaultValue = "",
|
||||
placeholder,
|
||||
zIndex = "var(--ops-z-modal)",
|
||||
wide = false,
|
||||
spacious = false,
|
||||
className = "",
|
||||
footerLeading,
|
||||
onOk,
|
||||
onCancel,
|
||||
children,
|
||||
@@ -76,8 +85,11 @@ export function OpsModal(props: OpsModalProps) {
|
||||
Array.from(dialog.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
|
||||
(el) => el.offsetParent !== null || el === document.activeElement,
|
||||
);
|
||||
const initial =
|
||||
variant === "input" && inputRef.current ? inputRef.current : focusables().pop();
|
||||
const initial = variant === "input" && inputRef.current
|
||||
? inputRef.current
|
||||
: danger && variant === "confirm"
|
||||
? dialog.querySelector<HTMLButtonElement>(".ops-modal-footer button:not([disabled])")
|
||||
: focusables().pop();
|
||||
initial?.focus();
|
||||
if (variant === "input") inputRef.current?.select();
|
||||
|
||||
@@ -128,7 +140,7 @@ export function OpsModal(props: OpsModalProps) {
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={`ops-modal w-full ${wide ? "max-w-2xl" : "max-w-md"}`}
|
||||
className={`ops-modal w-full ${spacious ? "ops-modal-spacious" : wide ? "max-w-2xl" : "max-w-md"} ${className}`}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-label={typeof title === "string" ? title : undefined}
|
||||
@@ -165,6 +177,7 @@ export function OpsModal(props: OpsModalProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="ops-modal-footer">
|
||||
{footerLeading}
|
||||
{variant !== "info" && (
|
||||
<button type="button" className="ops-btn-secondary min-w-[72px]" onClick={cancel}>
|
||||
{cancelLabel}
|
||||
@@ -172,6 +185,7 @@ export function OpsModal(props: OpsModalProps) {
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={okDisabled}
|
||||
className={`${danger ? "ops-btn-danger" : "ops-btn-primary"} min-w-[72px]`}
|
||||
onClick={submit}
|
||||
>
|
||||
|
||||
+168
-76
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { execSql, getRedisKey } from "./api";
|
||||
import { ensureSession, withSessionRetry } from "./ObjectTree";
|
||||
import { OpsModal } from "./OpsModal";
|
||||
@@ -13,6 +13,20 @@ import {
|
||||
import { useWorkspaceStore } from "./store";
|
||||
|
||||
type ValueMode = "table" | "json";
|
||||
function highlightedJson(value: string): ReactNode[] {
|
||||
const tokens = /("(?:\\.|[^"\\])*"(?=\s*:)|"(?:\\.|[^"\\])*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|\b(?:true|false|null)\b)/g;
|
||||
const output: ReactNode[] = [];
|
||||
let start = 0;
|
||||
for (const match of value.matchAll(tokens)) {
|
||||
if (match.index > start) output.push(value.slice(start, match.index));
|
||||
const part = match[0];
|
||||
const color = part.startsWith('"') ? (value.slice(match.index + part.length).match(/^\s*:/) ? "var(--ops-accent)" : "var(--ops-success)") : "var(--ops-warning)";
|
||||
output.push(<span key={match.index} style={{ color }}>{part}</span>);
|
||||
start = match.index + part.length;
|
||||
}
|
||||
output.push(value.slice(start));
|
||||
return output;
|
||||
}
|
||||
/** 表格首屏渲染行数,超出走「加载更多」 */
|
||||
const INITIAL_VISIBLE = 100;
|
||||
const PAGE_STEP = 200;
|
||||
@@ -29,6 +43,7 @@ type LineEdit = {
|
||||
value: string;
|
||||
score: string;
|
||||
isNew: boolean;
|
||||
valueMode?: "raw" | "json";
|
||||
oldField?: string;
|
||||
index?: number;
|
||||
};
|
||||
@@ -58,7 +73,8 @@ export function RedisKeyPane() {
|
||||
const type = resultMeta?.redisType || "string";
|
||||
const [ttlDraft, setTtlDraft] = useState(String(resultMeta?.redisTtl ?? -1));
|
||||
const [keyDraft, setKeyDraft] = useState(keyName);
|
||||
const [mode, setMode] = useState<ValueMode>(type === "string" ? "json" : "table");
|
||||
const [mode, setMode] = useState<ValueMode>("table");
|
||||
const [editingJson, setEditingJson] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [lineEdit, setLineEdit] = useState<LineEdit | null>(null);
|
||||
@@ -73,16 +89,23 @@ export function RedisKeyPane() {
|
||||
/** 表格分批渲染:首屏条数与当前已显示条数(宽/大键不全量铺 DOM) */
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE);
|
||||
const [stringDraft, setStringDraft] = useState(() => cellString(result?.rows?.[0]?.value));
|
||||
/** 行内操作列:选中行保持四钮可见 */
|
||||
const [activeRow, setActiveRow] = useState<number | null>(null);
|
||||
/** 复制值 / 复制命令点完闪 ✓ 约 1s */
|
||||
const [copiedFlash, setCopiedFlash] = useState<{ i: number; kind: "value" | "cmd" } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setKeyDraft(keyName);
|
||||
setTtlDraft(String(resultMeta?.redisTtl ?? -1));
|
||||
setStringDraft(cellString(result?.rows?.[0]?.value));
|
||||
setFilter("");
|
||||
setMode(type === "string" ? "json" : "table");
|
||||
setMode("table");
|
||||
setEditingJson(false);
|
||||
setVisibleCount(INITIAL_VISIBLE);
|
||||
setSort(null);
|
||||
setInline(null);
|
||||
setActiveRow(null);
|
||||
setCopiedFlash(null);
|
||||
}, [keyName, type, resultMeta?.redisTtl, result]);
|
||||
|
||||
const rows = result?.rows ?? [];
|
||||
@@ -119,6 +142,17 @@ export function RedisKeyPane() {
|
||||
}
|
||||
return JSON.stringify(rows, null, 2);
|
||||
}, [rows, type]);
|
||||
const jsonDraft = prettyDraft ?? jsonText;
|
||||
const jsonValid = type !== "string" || tryPrettyJson(jsonDraft) !== null;
|
||||
const selectMode = (next: ValueMode) => {
|
||||
if (type === "string" && next === "json" && mode !== "json") {
|
||||
setPrettyDraft(tryPrettyJson(stringDraft) ?? stringDraft);
|
||||
} else if (type === "string" && next === "table" && mode === "json") {
|
||||
setStringDraft(jsonValid ? JSON.stringify(JSON.parse(jsonDraft)) : jsonDraft);
|
||||
setEditingJson(false);
|
||||
}
|
||||
setMode(next);
|
||||
};
|
||||
|
||||
// string 的 JSON 草稿随数据刷新同步(保存/重载后回到干净态)
|
||||
useEffect(() => {
|
||||
@@ -138,6 +172,7 @@ export function RedisKeyPane() {
|
||||
error: res.error,
|
||||
truncated: res.truncated,
|
||||
limit: res.limit,
|
||||
nextCursor: res.nextCursor,
|
||||
};
|
||||
set("result", qr);
|
||||
set("resultMeta", {
|
||||
@@ -177,12 +212,12 @@ export function RedisKeyPane() {
|
||||
|
||||
const saveTtl = async () => {
|
||||
const n = Number(ttlDraft);
|
||||
if (!Number.isFinite(n)) {
|
||||
notify({ kind: "warning", title: "TTL", message: "请输入秒数,-1 表示永久", mode: "toast" });
|
||||
if (!Number.isSafeInteger(n) || n < -1 || n === 0) {
|
||||
notify({ kind: "warning", title: "TTL", message: "请输入正整数秒;-1 表示永久。0 会立即删除 Key,请使用删除操作。", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
if (n < 0) await runCmd(redisCmd("PERSIST", keyName), "已设为永久");
|
||||
else await runCmd(redisCmd("EXPIRE", keyName, String(Math.floor(n))), "已更新 TTL");
|
||||
if (n === -1) await runCmd(redisCmd("PERSIST", keyName), "已设为永久");
|
||||
else await runCmd(redisCmd("EXPIRE", keyName, String(n)), "已更新 TTL");
|
||||
};
|
||||
|
||||
const rename = async () => {
|
||||
@@ -310,7 +345,7 @@ export function RedisKeyPane() {
|
||||
let val = stringDraft;
|
||||
if (type === "string" && mode === "json") {
|
||||
// JSON 视图直接编辑:保存前校验并压回单行,避免把 pretty 缩进写进 Redis
|
||||
const draft = prettyDraft ?? jsonText;
|
||||
const draft = jsonDraft;
|
||||
try {
|
||||
val = JSON.stringify(JSON.parse(draft));
|
||||
} catch (e) {
|
||||
@@ -324,7 +359,7 @@ export function RedisKeyPane() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await runCmd(redisCmd("SET", keyName, val), "已保存");
|
||||
await runCmd(redisCmd("SET", keyName, val, "KEEPTTL"), "已保存");
|
||||
};
|
||||
|
||||
const deleteKey = async () => {
|
||||
@@ -363,7 +398,7 @@ export function RedisKeyPane() {
|
||||
return;
|
||||
}
|
||||
if (type === "list") {
|
||||
setLineEdit({ field: "", value: cellString(row.value), score: "0", isNew: false, index });
|
||||
setLineEdit({ field: "", value: cellString(row.value), score: "0", isNew: false, index: Number(row.index ?? index) });
|
||||
return;
|
||||
}
|
||||
if (type === "set") {
|
||||
@@ -389,6 +424,12 @@ export function RedisKeyPane() {
|
||||
|
||||
const commitLine = async () => {
|
||||
if (!lineEdit) return;
|
||||
let value = lineEdit.value;
|
||||
if (lineEdit.valueMode === "json") {
|
||||
const pretty = tryPrettyJson(value);
|
||||
if (pretty === null) return;
|
||||
value = JSON.stringify(JSON.parse(pretty));
|
||||
}
|
||||
let sql = "";
|
||||
if (type === "hash") {
|
||||
if (!lineEdit.field.trim()) {
|
||||
@@ -396,20 +437,20 @@ export function RedisKeyPane() {
|
||||
return;
|
||||
}
|
||||
if (!lineEdit.isNew && lineEdit.oldField && lineEdit.oldField !== lineEdit.field) {
|
||||
sql = `${redisCmd("HDEL", keyName, lineEdit.oldField)}\n${redisCmd("HSET", keyName, lineEdit.field, lineEdit.value)}`;
|
||||
sql = `${redisCmd("HDEL", keyName, lineEdit.oldField)}\n${redisCmd("HSET", keyName, lineEdit.field, value)}`;
|
||||
} else {
|
||||
sql = redisCmd("HSET", keyName, lineEdit.field, lineEdit.value);
|
||||
sql = redisCmd("HSET", keyName, lineEdit.field, value);
|
||||
}
|
||||
} else if (type === "list") {
|
||||
if (lineEdit.isNew) sql = redisCmd("RPUSH", keyName, lineEdit.value);
|
||||
else sql = redisCmd("LSET", keyName, String(lineEdit.index ?? 0), lineEdit.value);
|
||||
if (lineEdit.isNew) sql = redisCmd("RPUSH", keyName, value);
|
||||
else sql = redisCmd("LSET", keyName, String(lineEdit.index ?? 0), value);
|
||||
} else if (type === "set") {
|
||||
if (lineEdit.isNew) sql = redisCmd("SADD", keyName, lineEdit.value);
|
||||
else if (lineEdit.oldField && lineEdit.oldField !== lineEdit.value) {
|
||||
sql = `${redisCmd("SREM", keyName, lineEdit.oldField)}\n${redisCmd("SADD", keyName, lineEdit.value)}`;
|
||||
} else sql = redisCmd("SADD", keyName, lineEdit.value);
|
||||
if (lineEdit.isNew) sql = redisCmd("SADD", keyName, value);
|
||||
else if (lineEdit.oldField && lineEdit.oldField !== value) {
|
||||
sql = `${redisCmd("SREM", keyName, lineEdit.oldField)}\n${redisCmd("SADD", keyName, value)}`;
|
||||
} else sql = redisCmd("SADD", keyName, value);
|
||||
} else if (type === "zset") {
|
||||
const member = lineEdit.value || lineEdit.field;
|
||||
const member = value || lineEdit.field;
|
||||
if (lineEdit.isNew) sql = redisCmd("ZADD", keyName, lineEdit.score || "0", member);
|
||||
else if (lineEdit.oldField && lineEdit.oldField !== member) {
|
||||
sql = `${redisCmd("ZREM", keyName, lineEdit.oldField)}\n${redisCmd("ZADD", keyName, lineEdit.score || "0", member)}`;
|
||||
@@ -417,11 +458,11 @@ export function RedisKeyPane() {
|
||||
}
|
||||
if (!sql) return;
|
||||
const parts = sql.split("\n");
|
||||
setLineEdit(null);
|
||||
for (const p of parts) {
|
||||
const ok = await runCmd(p, lineEdit.isNew ? "已新增" : "已更新");
|
||||
if (!ok) break;
|
||||
if (!ok) return;
|
||||
}
|
||||
setLineEdit(null);
|
||||
};
|
||||
|
||||
const deleteLine = async (row: Record<string, unknown>, index: number) => {
|
||||
@@ -437,8 +478,7 @@ export function RedisKeyPane() {
|
||||
else if (type === "zset") await runCmd(redisCmd("ZREM", keyName, cellString(row.member)), "已删除成员");
|
||||
else if (type === "list") {
|
||||
const val = cellString(row.value);
|
||||
await runCmd(redisCmd("LREM", keyName, "1", val), "已删除元素");
|
||||
void index;
|
||||
await runCmd(redisCmd("LDELETEINDEX", keyName, String(row.index ?? index), val), "已删除元素");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -452,12 +492,34 @@ export function RedisKeyPane() {
|
||||
case "set":
|
||||
return redisCmd("SADD", keyName, cellString(row.member ?? row.value));
|
||||
case "list":
|
||||
return redisCmd("RPUSH", keyName, cellString(row.value));
|
||||
// list 行内「复制命令」只拷值本身(与产品口径一致)
|
||||
return cellString(row.value);
|
||||
default:
|
||||
return cellString(row.value ?? row.member ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
const flashCopied = (i: number, kind: "value" | "cmd") => {
|
||||
setCopiedFlash({ i, kind });
|
||||
window.setTimeout(() => {
|
||||
setCopiedFlash((cur) => (cur && cur.i === i && cur.kind === kind ? null : cur));
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const copyRowValue = (row: Record<string, unknown>, i: number) => {
|
||||
const valueRaw = type === "hash" ? row.value : type === "zset" ? row.member : row.member ?? row.value;
|
||||
void copyText(cellString(valueRaw)).then(() => {
|
||||
flashCopied(i, "value");
|
||||
}).catch(e => notifyError(e, "toast"));
|
||||
};
|
||||
|
||||
const copyRowCmd = (row: Record<string, unknown>, i: number) => {
|
||||
const cmd = rowCommand(row);
|
||||
void copyText(cmd).then(() => {
|
||||
flashCopied(i, "cmd");
|
||||
}).catch(e => notifyError(e, "toast"));
|
||||
};
|
||||
|
||||
const dumpClipboard = async () => {
|
||||
const lines: string[] = [];
|
||||
if (type === "string") lines.push(redisCmd("SET", keyName, cellString(rows[0]?.value)));
|
||||
@@ -473,7 +535,6 @@ export function RedisKeyPane() {
|
||||
const ttl = resultMeta?.redisTtl ?? -1;
|
||||
if (ttl >= 0) lines.push(redisCmd("EXPIRE", keyName, String(ttl)));
|
||||
await copyText(lines.join("\n") || redisCmd("GET", keyName));
|
||||
notify({ kind: "success", title: "已复制命令", message: `${lines.length} 条`, mode: "toast", durationMs: 1400 });
|
||||
};
|
||||
|
||||
const addLabel =
|
||||
@@ -520,8 +581,8 @@ export function RedisKeyPane() {
|
||||
<button type="button" className="ops-btn-ghost h-7 px-2 text-[11px]" disabled={busy} title="刷新" onClick={() => void reload()}>
|
||||
刷新
|
||||
</button>
|
||||
<button type="button" className="ops-btn-ghost h-7 px-2 text-[11px]" title="复制还原命令" onClick={() => void dumpClipboard()}>
|
||||
导出命令
|
||||
<button type="button" className="ops-btn-ghost h-7 px-2 text-[11px] font-mono" title="复制为命令(整 key)" onClick={() => void dumpClipboard()}>
|
||||
{"</>"} 复制为命令
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -556,14 +617,18 @@ export function RedisKeyPane() {
|
||||
保存值
|
||||
</button>
|
||||
)}
|
||||
{type === "string" && <button type="button" className="ops-btn-ghost h-7 px-2 text-[11px]" title="复制当前视图中的值" onClick={() => {
|
||||
void copyText(mode === "json" ? jsonDraft : stringDraft).catch(e => notifyError(e, "toast"));
|
||||
}}>复制值</button>}
|
||||
<div className="ops-seg">
|
||||
<button type="button" className={mode === "table" ? "ops-seg-active" : undefined} onClick={() => setMode("table")}>
|
||||
{type === "string" ? "原文" : "结构"}
|
||||
<button type="button" className={mode === "table" ? "ops-seg-active" : undefined} onClick={() => selectMode("table")}>
|
||||
{type === "string" ? "Raw" : "结构"}
|
||||
</button>
|
||||
<button type="button" className={mode === "json" ? "ops-seg-active" : undefined} onClick={() => setMode("json")}>
|
||||
<button type="button" className={mode === "json" ? "ops-seg-active" : undefined} onClick={() => selectMode("json")}>
|
||||
JSON
|
||||
</button>
|
||||
</div>
|
||||
{type === "string" && mode === "json" && <button type="button" className="ops-btn-ghost h-7 px-2 text-[11px]" onClick={() => setEditingJson(v => !v)}>{editingJson ? "预览" : "编辑 JSON"}</button>}
|
||||
</div>
|
||||
{type !== "string" && (
|
||||
<input
|
||||
@@ -596,16 +661,14 @@ export function RedisKeyPane() {
|
||||
onChange={(e) => setStringDraft(e.target.value)}
|
||||
/>
|
||||
) : type === "string" ? (
|
||||
<textarea
|
||||
editingJson ? <textarea
|
||||
className="ops-input min-h-full w-full resize-none rounded-none border-0 p-3 font-mono text-[12px] leading-relaxed"
|
||||
value={mode === "json" ? prettyDraft ?? jsonText : stringDraft}
|
||||
spellCheck={false}
|
||||
onChange={(e) => {
|
||||
if (mode === "json") setPrettyDraft(e.target.value);
|
||||
else setStringDraft(e.target.value);
|
||||
}}
|
||||
placeholder="JSON 视图可直接编辑;保存时自动校验并压缩回写"
|
||||
/>
|
||||
value={jsonDraft} spellCheck={false} aria-label="编辑 JSON"
|
||||
onChange={(e) => setPrettyDraft(e.target.value)}
|
||||
/> : <div className="p-3">
|
||||
{!jsonValid && <p className="mb-2 text-[11px]" style={{ color: "var(--ops-warning)" }}>当前值不是有效 JSON,可切回 Raw 或编辑修正。</p>}
|
||||
<pre className="m-0 whitespace-pre-wrap break-all font-mono text-[12px] leading-relaxed" aria-label="JSON 格式化预览">{jsonValid ? highlightedJson(jsonDraft) : jsonDraft}</pre>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="m-0 whitespace-pre-wrap break-all p-3 font-mono text-[12px] leading-relaxed">{jsonText}</pre>
|
||||
)
|
||||
@@ -618,7 +681,7 @@ export function RedisKeyPane() {
|
||||
{type === "zset" && <th style={{ width: "56%" }}>{sortTh("member", "Member")}</th>}
|
||||
{type === "zset" && <th style={{ width: 120 }}>{sortTh("score", "Score")}</th>}
|
||||
{type !== "zset" && <th>{type === "list" ? "Value" : sortTh("value", "Value")}</th>}
|
||||
<th style={{ width: 108 }} />
|
||||
<th style={{ width: 136 }} aria-label="行操作" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -629,7 +692,11 @@ export function RedisKeyPane() {
|
||||
const summary = cellSummary(cellString(valueRaw), valuePretty);
|
||||
const scoreTs = type === "zset" ? tsLabel(Number(row.score)) : null;
|
||||
return (
|
||||
<tr key={i}>
|
||||
<tr
|
||||
key={i}
|
||||
className={activeRow === i ? "ops-redis-row-active" : undefined}
|
||||
onClick={() => setActiveRow(i)}
|
||||
>
|
||||
<td className="ops-rownum">{type === "list" ? cellString(row.index ?? i) : i + 1}</td>
|
||||
{type === "hash" && (
|
||||
<td className="font-mono" title={cellString(row.field)}>
|
||||
@@ -711,7 +778,7 @@ export function RedisKeyPane() {
|
||||
<InlineInput row={row} i={i} col="value" value={cellString(valueRaw)} />
|
||||
) : (
|
||||
<pre
|
||||
className="m-0 max-h-24 cursor-text overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-snug"
|
||||
className="m-0 max-h-48 cursor-text overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-snug"
|
||||
title="双击编辑"
|
||||
onDoubleClick={() => setInline({ i, col: "value", draft: cellString(valueRaw) })}
|
||||
>
|
||||
@@ -720,53 +787,64 @@ export function RedisKeyPane() {
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<td onClick={(e) => e.stopPropagation()}>
|
||||
<div className="ops-redis-row-actions" role="group" aria-label="行操作">
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[10px]"
|
||||
title="复制值(纯文本)"
|
||||
onClick={() => {
|
||||
const val = cellString(valueRaw);
|
||||
void copyText(val).then(() =>
|
||||
notify({ kind: "success", title: "已复制值", message: "", mode: "toast", durationMs: 1200 }),
|
||||
);
|
||||
}}
|
||||
className={`ops-redis-row-btn ops-redis-row-btn-copy${copiedFlash?.i === i && copiedFlash.kind === "value" ? " ops-redis-row-btn-flash" : ""}`}
|
||||
title="复制本行值"
|
||||
aria-label="复制本行值"
|
||||
onClick={() => copyRowValue(row, i)}
|
||||
>
|
||||
⧉
|
||||
{copiedFlash?.i === i && copiedFlash.kind === "value" ? (
|
||||
"✓"
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<rect x="5.5" y="5.5" width="7" height="7" rx="1.2" stroke="currentColor" strokeWidth="1.4" />
|
||||
<path d="M3.5 10.5V3.5A1 1 0 0 1 4.5 2.5h7" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[10px]"
|
||||
title={`复制重建命令(${type === "zset" ? "ZADD key score member" : type === "hash" ? "HSET key field value" : type === "set" ? "SADD key member" : "RPUSH key value"})`}
|
||||
onClick={() => {
|
||||
const cmd = rowCommand(row);
|
||||
void copyText(cmd).then(() =>
|
||||
notify({ kind: "success", title: "已复制命令", message: cmd, mode: "toast", durationMs: 1600 }),
|
||||
);
|
||||
}}
|
||||
className={`ops-redis-row-btn ops-redis-row-btn-copy ops-redis-row-btn-cmd${copiedFlash?.i === i && copiedFlash.kind === "cmd" ? " ops-redis-row-btn-flash" : ""}`}
|
||||
title={
|
||||
type === "list"
|
||||
? "复制本行值"
|
||||
: type === "zset"
|
||||
? "复制本行命令(ZADD key score member)"
|
||||
: type === "hash"
|
||||
? "复制本行命令(HSET key field value)"
|
||||
: type === "set"
|
||||
? "复制本行命令(SADD key member)"
|
||||
: "复制本行命令"
|
||||
}
|
||||
aria-label={type === "list" ? "复制本行值" : "复制本行命令"}
|
||||
onClick={() => copyRowCmd(row, i)}
|
||||
>
|
||||
⌘
|
||||
{copiedFlash?.i === i && copiedFlash.kind === "cmd" ? "✓" : "</>"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[10px]"
|
||||
title={valuePretty ? "查看 JSON" : "查看文本"}
|
||||
onClick={() => setCellViewer(valuePretty ?? cellString(valueRaw))}
|
||||
className="ops-redis-row-btn"
|
||||
title="编辑"
|
||||
aria-label="编辑"
|
||||
onClick={() => openEditRow(row, i)}
|
||||
>
|
||||
{valuePretty ? "{}" : "☰"}
|
||||
</button>
|
||||
<button type="button" className="ops-btn-ghost px-1 text-[11px]" title="编辑" onClick={() => openEditRow(row, i)}>
|
||||
✎
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<path d="M11.5 2.5 13.5 4.5 6 12H4V10L11.5 2.5Z" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[11px]"
|
||||
style={{ color: "var(--ops-danger)" }}
|
||||
className="ops-redis-row-btn ops-redis-row-btn-del"
|
||||
title="删除此行"
|
||||
aria-label="删除此行"
|
||||
onClick={() => void deleteLine(row, i)}
|
||||
>
|
||||
×
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<path d="M4 4.5h8M6.5 4.5V3.5h3v1M5.5 6.5l.5 6h4l.5-6" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -816,6 +894,7 @@ export function RedisKeyPane() {
|
||||
title={lineEdit.isNew ? addLabel || "新增" : "编辑"}
|
||||
variant="confirm"
|
||||
okLabel="保存"
|
||||
okDisabled={busy || (lineEdit.valueMode === "json" && tryPrettyJson(lineEdit.value) === null)}
|
||||
onOk={() => void commitLine()}
|
||||
onCancel={() => setLineEdit(null)}
|
||||
>
|
||||
@@ -840,8 +919,17 @@ export function RedisKeyPane() {
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="block text-[12px]">
|
||||
<div className="flex items-center justify-between gap-2 text-[12px]">
|
||||
<span style={{ color: "var(--ops-muted)" }}>{type === "zset" ? "Member" : "Value"}</span>
|
||||
<div className="ops-seg" role="group" aria-label="值编辑方式">
|
||||
<button type="button" className={lineEdit.valueMode !== "json" ? "ops-seg-active" : undefined}
|
||||
onClick={() => setLineEdit({ ...lineEdit, valueMode: "raw", value: lineEdit.valueMode === "json" && tryPrettyJson(lineEdit.value) !== null ? JSON.stringify(JSON.parse(lineEdit.value)) : lineEdit.value })}>Raw</button>
|
||||
<button type="button" className={lineEdit.valueMode === "json" ? "ops-seg-active" : undefined}
|
||||
onClick={() => setLineEdit({ ...lineEdit, valueMode: "json", value: tryPrettyJson(lineEdit.value) ?? lineEdit.value })}>JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
<label className="block text-[12px]">
|
||||
<span className="sr-only">{type === "zset" ? "Member" : "Value"}</span>
|
||||
<textarea
|
||||
className="ops-input mt-1 min-h-[120px] w-full font-mono text-[12px]"
|
||||
value={lineEdit.value}
|
||||
@@ -849,6 +937,12 @@ export function RedisKeyPane() {
|
||||
onChange={(e) => setLineEdit({ ...lineEdit, value: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
{lineEdit.valueMode === "json" && (tryPrettyJson(lineEdit.value) === null
|
||||
? <p role="alert" className="text-[11px]" style={{ color: "var(--ops-warning)" }}>JSON 格式无效,请修正后保存,或切回 Raw。</p>
|
||||
: <div className="max-h-40 overflow-auto rounded border p-2" style={{ borderColor: "var(--ops-border)", background: "var(--ops-bg-soft, transparent)" }}>
|
||||
<div className="mb-1 text-[10px]" style={{ color: "var(--ops-muted)" }}>格式化预览</div>
|
||||
<pre className="m-0 whitespace-pre-wrap break-all font-mono text-[11px] leading-snug" aria-label="JSON 格式化预览">{highlightedJson(tryPrettyJson(lineEdit.value) ?? "")}</pre>
|
||||
</div>)}
|
||||
</div>
|
||||
</OpsModal>
|
||||
)}
|
||||
@@ -859,9 +953,7 @@ export function RedisKeyPane() {
|
||||
variant="confirm"
|
||||
okLabel="复制 JSON"
|
||||
onOk={() => {
|
||||
void copyText(cellViewer).then(() =>
|
||||
notify({ kind: "success", title: "已复制 JSON", message: "", mode: "toast", durationMs: 1200 }),
|
||||
);
|
||||
void copyText(cellViewer).catch(e => notifyError(e, "toast"));
|
||||
setCellViewer(null);
|
||||
}}
|
||||
onCancel={() => setCellViewer(null)}
|
||||
|
||||
+187
-202
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type KeyboardEvent, type ClipboardEvent } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type KeyboardEvent, type ClipboardEvent } from "react";
|
||||
import { execSql, exportData, type ExportError, type ExportRequest } from "./api";
|
||||
import { ensureSession } from "./ObjectTree";
|
||||
import { rowsToCSV, rowsToInsert, rowsToTSV, buildUpdateSql, buildDeleteSql, parseClipboardGrid, isWritableDriver, splitStatements } from "./sql";
|
||||
import { rowsToCSV, rowsToInsert, rowsToTSV, buildUpdateSql, buildDeleteSql, parseClipboardGrid, isWritableDriver, splitStatements, relationParts } from "./sql";
|
||||
import { RedisKeyPane } from "./RedisKeyPane";
|
||||
import { SchemaPane } from "./SchemaPane";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
|
||||
const DISPLAY_CAP = 2000;
|
||||
import { useWorkspaceStore, type QueryResult } from "./store";
|
||||
import { ContextMenu } from "./ContextMenu";
|
||||
import { cellText, selectionTSV } from "./contextActions";
|
||||
import { RESULT_BATCH, filterRows, compareCells, cellPreview, suggestedColumnWidth } from "./resultView";
|
||||
import { OpsModal } from "./OpsModal";
|
||||
|
||||
/** C5:仅单条 SELECT(含 WITH)启用分页 */
|
||||
function isSingleSelect(sql: string): boolean {
|
||||
@@ -18,16 +20,16 @@ function isSingleSelect(sql: string): boolean {
|
||||
|
||||
function formatCell(v: unknown) {
|
||||
if (v == null) return <span className="ops-null">NULL</span>;
|
||||
if (typeof v === "object") return JSON.stringify(v);
|
||||
const s = String(v);
|
||||
if (s.length > 120) return s.slice(0, 120) + "…";
|
||||
return s;
|
||||
if (v === "") return <span className="ops-empty-value">空字符串</span>;
|
||||
if (typeof v === "boolean") return <span className="ops-boolean">{String(v)}</span>;
|
||||
if (typeof v === "string" && !v.trim()) return <span className="ops-empty-value">空白文本</span>;
|
||||
return cellPreview(v);
|
||||
}
|
||||
|
||||
/** 值是否为被截断展示的长文本(修 G4:提供查看/复制全文 popover) */
|
||||
/** 判断是否需要提供长文本全文入口。 */
|
||||
function longText(v: unknown): string | null {
|
||||
if (v == null || typeof v === "object") return null;
|
||||
const s = String(v);
|
||||
if (v == null) return null;
|
||||
const s = typeof v === "object" ? JSON.stringify(v, null, 2) : String(v);
|
||||
return s.length > 120 ? s : null;
|
||||
}
|
||||
|
||||
@@ -35,25 +37,11 @@ async function copyText(text: string) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
function cmpVal(a: unknown, b: unknown): number {
|
||||
if (a == null && b == null) return 0;
|
||||
if (a == null) return -1;
|
||||
if (b == null) return 1;
|
||||
if (typeof a === "number" && typeof b === "number") return a - b;
|
||||
const na = Number(a);
|
||||
const nb = Number(b);
|
||||
if (!Number.isNaN(na) && !Number.isNaN(nb) && String(a).trim() !== "" && String(b).trim() !== "") {
|
||||
return na - nb;
|
||||
}
|
||||
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
|
||||
export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
const result = useWorkspaceStore((s) => s.result);
|
||||
const resultSets = useWorkspaceStore((s) => s.resultSets);
|
||||
const activeResultSetId = useWorkspaceStore((s) => s.activeResultSetId);
|
||||
const resultTab = useWorkspaceStore((s) => s.resultTab);
|
||||
const explainText = useWorkspaceStore((s) => s.explainText);
|
||||
const resultMeta = useWorkspaceStore((s) => s.resultMeta);
|
||||
const connections = useWorkspaceStore((s) => s.connections);
|
||||
const activeConnectionId = useWorkspaceStore((s) => s.activeConnectionId);
|
||||
@@ -62,58 +50,69 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const askConfirm = useWorkspaceStore((s) => s.askConfirm);
|
||||
|
||||
const conn = connections.find((c) => c.id === activeConnectionId);
|
||||
const activeRs = resultSets.find((r) => r.id === activeResultSetId) ?? null;
|
||||
const insertSource = activeRs?.source;
|
||||
const conn = connections.find((c) => c.id === (insertSource?.connectionId ?? activeConnectionId));
|
||||
const writable = isWritableDriver(conn?.driver);
|
||||
const canEdit = writable && !!resultMeta?.table && (resultMeta.pkCols?.length ?? 0) > 0;
|
||||
const sourceMatches = !!insertSource && insertSource.table === resultMeta?.table && insertSource.database === resultMeta?.database && insertSource.connectionId === activeConnectionId;
|
||||
const canEdit = writable && sourceMatches && (resultMeta?.pkCols?.length ?? 0) > 0;
|
||||
const redisInspect = (conn?.driver || "").toLowerCase() === "redis" && !!resultMeta?.redisType && !!resultMeta?.table;
|
||||
|
||||
const [sortCol, setSortCol] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc" | null>(null);
|
||||
const [colWidths, setColWidths] = useState<Record<string, number>>({});
|
||||
const [filter, setFilter] = useState("");
|
||||
const [displayCount, setDisplayCount] = useState(RESULT_BATCH);
|
||||
const [viewKey, setViewKey] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<{ title: string; text: string } | null>(null);
|
||||
const [selectedIdx, setSelectedIdx] = useState<number | null>(null);
|
||||
const [selectedCol, setSelectedCol] = useState<string | null>(null);
|
||||
const [edit, setEdit] = useState<{ row: number; col: string; value: string; original: string } | null>(null);
|
||||
/** 长值 popover(修 G4) */
|
||||
const [peek, setPeek] = useState<{ text: string; x: number; y: number } | null>(null);
|
||||
const [paging, setPaging] = useState(false);
|
||||
/** R2-F2:导出中心下拉菜单 */
|
||||
const [expMenu, setExpMenu] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [context, setContext] = useState<{ x: number; y: number; row: Record<string, unknown> | null; column: string | null } | null>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<{ col: string; startX: number; startW: number } | null>(null);
|
||||
const editRef = useRef(edit);
|
||||
editRef.current = edit;
|
||||
|
||||
const activeRs = resultSets.find((r) => r.id === activeResultSetId) ?? null;
|
||||
const updateResultSet = useWorkspaceStore((s) => s.updateResultSet);
|
||||
// 异步写回/回滚只更新发起操作的结果集,不覆盖后来切换到的结果。
|
||||
const publishResult = (next: QueryResult) => {
|
||||
if (activeResultSetId) updateResultSet(activeResultSetId, { result: next });
|
||||
};
|
||||
|
||||
// 切换结果集时重置排序/编辑,并恢复该结果集持久化的列宽与选中态(修 G3)
|
||||
useEffect(() => {
|
||||
setSortCol(null);
|
||||
setSortDir(null);
|
||||
// 切换结果集时退出编辑,并恢复该结果集的列宽与排序。
|
||||
useLayoutEffect(() => {
|
||||
setEdit(null);
|
||||
setPeek(null);
|
||||
const cur = useWorkspaceStore.getState().resultSets.find((x) => x.id === activeResultSetId);
|
||||
const vs = cur?.viewState;
|
||||
setSortCol(vs?.sortCol ?? null);
|
||||
setSortDir(vs?.sortDir ?? null);
|
||||
setColWidths(vs?.colWidths ?? {});
|
||||
setSelectedIdx(vs?.selectedIdx ?? null);
|
||||
setSelectedCol(vs?.selectedCol ?? null);
|
||||
setSelectedIdx(null);
|
||||
setSelectedCol(null);
|
||||
setFilter("");
|
||||
setDisplayCount(RESULT_BATCH);
|
||||
setViewKey(activeResultSetId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeResultSetId, result]);
|
||||
}, [activeResultSetId]);
|
||||
|
||||
// 列宽/选中态变化时写回 resultSet(内容相同则跳过,避免回环)
|
||||
// 列宽/排序变化时写回 resultSet(内容相同则跳过,避免回环)。
|
||||
useEffect(() => {
|
||||
if (!activeResultSetId) return;
|
||||
if (!activeResultSetId || viewKey !== activeResultSetId) return;
|
||||
const cur = useWorkspaceStore.getState().resultSets.find((x) => x.id === activeResultSetId);
|
||||
if (!cur) return;
|
||||
const prev = cur.viewState;
|
||||
const same =
|
||||
JSON.stringify(prev?.colWidths ?? {}) === JSON.stringify(colWidths) &&
|
||||
(prev?.selectedIdx ?? null) === selectedIdx &&
|
||||
(prev?.selectedCol ?? null) === selectedCol;
|
||||
(prev?.sortCol ?? null) === sortCol &&
|
||||
(prev?.sortDir ?? null) === sortDir;
|
||||
if (same) return;
|
||||
updateResultSet(activeResultSetId, { viewState: { colWidths, selectedIdx, selectedCol } });
|
||||
}, [colWidths, selectedIdx, selectedCol, activeResultSetId, updateResultSet]);
|
||||
updateResultSet(activeResultSetId, { viewState: { colWidths, sortCol, sortDir } });
|
||||
}, [colWidths, sortCol, sortDir, viewKey, activeResultSetId, updateResultSet]);
|
||||
|
||||
// discard pending edit when switching tabs
|
||||
useEffect(() => {
|
||||
@@ -125,18 +124,44 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
|
||||
const sortedRows = useMemo(() => {
|
||||
if (!result || result.error) return [];
|
||||
let rows = result.rows;
|
||||
let rows = filterRows(result.rows, result.columns, filter);
|
||||
if (sortCol && sortDir) {
|
||||
rows = [...rows].sort((ra, rb) => {
|
||||
const c = cmpVal(ra[sortCol], rb[sortCol]);
|
||||
const c = compareCells(ra[sortCol], rb[sortCol]);
|
||||
return sortDir === "asc" ? c : -c;
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}, [result, sortCol, sortDir]);
|
||||
}, [result, sortCol, sortDir, filter]);
|
||||
|
||||
const truncatedLocal = sortedRows.length > DISPLAY_CAP;
|
||||
const visibleRows = truncatedLocal ? sortedRows.slice(0, DISPLAY_CAP) : sortedRows;
|
||||
const truncatedLocal = sortedRows.length > displayCount;
|
||||
const visibleRows = sortedRows.slice(0, displayCount);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIdx(null);
|
||||
setSelectedCol(null);
|
||||
setDisplayCount(RESULT_BATCH);
|
||||
}, [filter, sortCol, sortDir, result]);
|
||||
|
||||
useEffect(() => { setContext(null); setDetail(null); }, [result, activeResultSetId, resultTab, sortCol, sortDir, filter]);
|
||||
|
||||
const openContext = (e: ReactMouseEvent, row: Record<string, unknown> | null, column: string | null) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!result || result.error || edit) return;
|
||||
gridRef.current?.focus();
|
||||
setContext({ x: e.clientX, y: e.clientY, row, column });
|
||||
};
|
||||
const copyContext = async (text: string, label: string) => {
|
||||
try {
|
||||
await copyText(text);
|
||||
void label;
|
||||
} catch (e) { notifyError(e, "toast"); }
|
||||
};
|
||||
const showDetail = (row: Record<string, unknown>, column: string) => {
|
||||
const value = row[column];
|
||||
setDetail({ title: `单元格详情 · ${column}`, text: typeof value === "object" && value !== null ? JSON.stringify(value, null, 2) : cellText(value) });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!result || result.error) return;
|
||||
@@ -144,9 +169,10 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
parts.push(`${result.rowCount} 行`);
|
||||
if (result.timeMs != null) parts.push(`${result.timeMs} ms`);
|
||||
if (selectedIdx != null) parts.push(`选中第 ${selectedIdx + 1} 行`);
|
||||
if (result.truncated || truncatedLocal) parts.push("已截断");
|
||||
if (result.truncated) parts.push("服务端还有更多行");
|
||||
if (truncatedLocal) parts.push(`已显示 ${visibleRows.length} / ${sortedRows.length} 行`);
|
||||
set("status", parts.join(" · "));
|
||||
}, [result, selectedIdx, truncatedLocal, set]);
|
||||
}, [result, selectedIdx, truncatedLocal, visibleRows.length, sortedRows.length, set]);
|
||||
|
||||
const cycleSort = (col: string) => {
|
||||
if (sortCol !== col) {
|
||||
@@ -183,6 +209,10 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
|
||||
const beginEdit = (rowIdx: number, col: string) => {
|
||||
if (!result || result.error) return;
|
||||
if (!sourceMatches) {
|
||||
notify({ kind: "info", title: "只读结果", message: "请从对象树重新打开表以确认写回目标。", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
if (!writable) {
|
||||
notify({ kind: "warning", title: "只读", message: "当前引擎不支持结果内联写回。", mode: "toast" });
|
||||
return;
|
||||
@@ -230,20 +260,13 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
}
|
||||
const prev = edit;
|
||||
setEdit(null);
|
||||
// optimistic
|
||||
const nextRows = result.rows.map((r, i) => {
|
||||
// map via visible index carefully — use identity on sorted view
|
||||
if (visibleRows[prev.row] !== r && sortedRows[prev.row] !== r) return r;
|
||||
// find by reference in original
|
||||
return r === row ? { ...r, [prev.col]: newVal } : r;
|
||||
});
|
||||
// safer: update by matching PK
|
||||
// 按主键定位原始记录,不使用筛选或排序后的行号写回。
|
||||
const pk = resultMeta.pkCols || [];
|
||||
const patched = result.rows.map((r) => {
|
||||
const same = pk.every((k) => String(r[k] ?? "") === String(row[k] ?? ""));
|
||||
return same ? { ...r, [prev.col]: newVal } : r;
|
||||
});
|
||||
set("result", { ...result, rows: patched });
|
||||
publishResult({ ...result, rows: patched });
|
||||
try {
|
||||
const sid = await ensureSession(conn);
|
||||
const res = await execSql(conn, sql, { database: resultMeta.database, sessionId: sid });
|
||||
@@ -252,7 +275,7 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
notify({ kind: "success", title: "已写回", message: `${prev.col} 已更新`, mode: "toast", durationMs: 1400 });
|
||||
} catch (e) {
|
||||
// rollback
|
||||
set("result", { ...result, rows: result.rows });
|
||||
publishResult({ ...result, rows: result.rows });
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down");
|
||||
notifyError(e, "modal");
|
||||
@@ -327,7 +350,7 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
return same ? { ...r, [ch.col]: ch.newVal } : r;
|
||||
});
|
||||
}
|
||||
set("result", { ...result, rows: patched });
|
||||
publishResult({ ...result, rows: patched });
|
||||
|
||||
try {
|
||||
const sid = await ensureSession(conn);
|
||||
@@ -340,7 +363,7 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
set("connState", "ok");
|
||||
notify({ kind: "success", title: "已粘贴", message: `已粘贴 ${changes.length} 格`, mode: "toast", durationMs: 1600 });
|
||||
} catch (err) {
|
||||
set("result", { ...result, rows: snapshotRows });
|
||||
publishResult({ ...result, rows: snapshotRows });
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down");
|
||||
notifyError(err, "modal");
|
||||
@@ -368,7 +391,7 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
const snapshot = result.rows;
|
||||
const snapshotCount = result.rowCount;
|
||||
const filtered = result.rows.filter((r) => !pk.every((k) => String(r[k] ?? "") === String(row[k] ?? "")));
|
||||
set("result", { ...result, rows: filtered, rowCount: Math.max(0, (result.rowCount ?? filtered.length) - 1) });
|
||||
publishResult({ ...result, rows: filtered, rowCount: Math.max(0, (result.rowCount ?? filtered.length) - 1) });
|
||||
setSelectedIdx(null);
|
||||
setSelectedCol(null);
|
||||
try {
|
||||
@@ -378,7 +401,7 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
set("connState", "ok");
|
||||
notify({ kind: "success", title: "已删除", message: `第 ${n} 行已删除`, mode: "toast", durationMs: 1400 });
|
||||
} catch (err) {
|
||||
set("result", { ...result, rows: snapshot, rowCount: snapshotCount });
|
||||
publishResult({ ...result, rows: snapshot, rowCount: snapshotCount });
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down");
|
||||
notifyError(err, "modal");
|
||||
@@ -405,6 +428,26 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
};
|
||||
const max = visibleRows.length - 1;
|
||||
if (max < 0) return;
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "c" && selectedIdx != null) {
|
||||
e.preventDefault();
|
||||
const row = visibleRows[selectedIdx];
|
||||
if (row) void copyContext(selectedCol ? cellText(row[selectedCol]) : selectionTSV(result!.columns, [row]), selectedCol ? "单元格" : "当前行");
|
||||
return;
|
||||
}
|
||||
if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) {
|
||||
e.preventDefault();
|
||||
const row = selectedIdx == null ? null : visibleRows[selectedIdx];
|
||||
const rect = gridRef.current?.getBoundingClientRect();
|
||||
if (rect) setContext({ x: Math.max(8, rect.left + 40), y: Math.max(8, rect.top + 30), row: row ?? null, column: selectedCol });
|
||||
return;
|
||||
}
|
||||
if ((e.key === "ArrowLeft" || e.key === "ArrowRight") && result) {
|
||||
e.preventDefault();
|
||||
const index = selectedCol ? result.columns.indexOf(selectedCol) : -1;
|
||||
setSelectedCol(result.columns[Math.max(0, Math.min(result.columns.length - 1, index + (e.key === "ArrowRight" ? 1 : -1)))]);
|
||||
if (selectedIdx == null) setSelectedIdx(0);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
moveSel(selectedIdx == null ? 0 : Math.min(max, selectedIdx + 1));
|
||||
@@ -423,9 +466,10 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
} else if (e.key === "End") {
|
||||
e.preventDefault();
|
||||
moveSel(max);
|
||||
} else if (e.key === "Enter" && selectedIdx != null && selectedCol && canEdit) {
|
||||
} else if (e.key === "Enter" && selectedIdx != null && selectedCol) {
|
||||
e.preventDefault();
|
||||
beginEdit(selectedIdx, selectedCol);
|
||||
if (canEdit) beginEdit(selectedIdx, selectedCol);
|
||||
else showDetail(visibleRows[selectedIdx], selectedCol);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -523,14 +567,17 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
|
||||
const copy = async (kind: "tsv" | "csv" | "insert") => {
|
||||
if (!result || result.error) return;
|
||||
if (kind === "insert" && !insertSource) {
|
||||
notify({ kind: "info", title: "无法生成 INSERT", message: "当前查询未确认目标表,请使用 JSON 或 CSV。", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
const text =
|
||||
kind === "csv"
|
||||
? rowsToCSV(result.columns, result.rows)
|
||||
: kind === "insert"
|
||||
? rowsToInsert(conn?.driver, resultMeta?.table || tableHint || "t", result.columns, result.rows)
|
||||
? rowsToInsert(insertSource!.driver, relationParts(insertSource!.driver, insertSource!.database, insertSource!.table), result.columns, result.rows)
|
||||
: rowsToTSV(result.columns, result.rows);
|
||||
await copyText(text);
|
||||
notify({ kind: "success", title: "已复制", message: kind === "insert" ? "INSERT 语句" : kind.toUpperCase(), mode: "toast" });
|
||||
await copyContext(text, kind === "insert" ? "INSERT 语句" : kind.toUpperCase());
|
||||
};
|
||||
|
||||
const downloadBlob = (blob: Blob, filename: string) => {
|
||||
@@ -544,6 +591,10 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
/** R2-F2:当前结果本地导出(INSERT 走 quoteIdent 方言引号,修 01-P1-1) */
|
||||
const exportCurrent = (format: "csv" | "json" | "insert") => {
|
||||
if (!result || result.error) return;
|
||||
if (format === "insert" && !insertSource) {
|
||||
notify({ kind: "info", title: "无法导出 INSERT", message: "当前查询未确认目标表,请使用 JSON 或 CSV。", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
const stamp = Date.now();
|
||||
if (format === "csv") {
|
||||
const blob = new Blob(["\ufeff" + rowsToCSV(result.columns, result.rows)], { type: "text/csv;charset=utf-8" });
|
||||
@@ -552,11 +603,11 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
const blob = new Blob([JSON.stringify(result.rows, null, 2)], { type: "application/json;charset=utf-8" });
|
||||
downloadBlob(blob, `opsxdb-${stamp}.json`);
|
||||
} else {
|
||||
const text = rowsToInsert(conn?.driver, resultMeta?.table || tableHint || "t", result.columns, result.rows);
|
||||
const text = rowsToInsert(insertSource!.driver, relationParts(insertSource!.driver, insertSource!.database, insertSource!.table), result.columns, result.rows);
|
||||
const blob = new Blob([text], { type: "application/sql;charset=utf-8" });
|
||||
downloadBlob(blob, `opsxdb-${stamp}.sql`);
|
||||
}
|
||||
notify({ kind: "success", title: "已导出", message: `当前结果 ${format.toUpperCase()}`, mode: "toast", durationMs: 1600 });
|
||||
notify({ kind: "success", title: "已导出", message: `当前结果 ${format.toUpperCase()}`, mode: "toast", durationMs: 1600, feedback: "visible" });
|
||||
setExpMenu(false);
|
||||
};
|
||||
|
||||
@@ -570,7 +621,7 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
try {
|
||||
const { blob, filename } = await exportData(conn, { ...req, sessionId: sid });
|
||||
downloadBlob(blob, filename || `export.${ext}`);
|
||||
notify({ kind: "success", title: "导出完成", message: filename || `export.${ext}`, mode: "toast", durationMs: 1800 });
|
||||
notify({ kind: "success", title: "导出完成", message: filename || `export.${ext}`, mode: "toast", durationMs: 1800, feedback: "visible" });
|
||||
setExpMenu(false);
|
||||
} catch (e) {
|
||||
const err = e as ExportError;
|
||||
@@ -585,7 +636,7 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
if (!ok) return;
|
||||
const again = await exportData(conn, { ...req, sessionId: sid, confirmDanger: true });
|
||||
downloadBlob(again.blob, again.filename || `export.${ext}`);
|
||||
notify({ kind: "success", title: "导出完成", message: again.filename || `export.${ext}`, mode: "toast", durationMs: 1800 });
|
||||
notify({ kind: "success", title: "导出完成", message: again.filename || `export.${ext}`, mode: "toast", durationMs: 1800, feedback: "visible" });
|
||||
setExpMenu(false);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -596,31 +647,11 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
};
|
||||
|
||||
/** 服务端导出可用性:整表(需表上下文) / 当前查询(需单条 SELECT) */
|
||||
const canExportTable = !!conn && !!resultMeta?.table;
|
||||
const canExportTable = !!conn && sourceMatches;
|
||||
const canExportQuery = !!conn && canPaginate && !!activeRs;
|
||||
|
||||
const emptyResult = result && !result.error && result.rows.length === 0;
|
||||
|
||||
// 长值 popover:Esc / 点击外部关闭(修 G4)
|
||||
useEffect(() => {
|
||||
if (!peek) return;
|
||||
const onKey = (e: globalThis.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setPeek(null);
|
||||
}
|
||||
};
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!(e.target as HTMLElement).closest?.("[data-ops-peek]")) setPeek(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
window.addEventListener("mousedown", onDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey, true);
|
||||
window.removeEventListener("mousedown", onDown);
|
||||
};
|
||||
}, [peek]);
|
||||
|
||||
// R2-F2:导出菜单 —— Esc / 点击外部关闭
|
||||
useEffect(() => {
|
||||
if (!expMenu) return;
|
||||
@@ -645,10 +676,29 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
onContextMenu={(e) => {
|
||||
// 拦系统菜单(Look Up / Inspect);结果表 P0 暂不自绘
|
||||
e.preventDefault();
|
||||
if (resultTab === "data" && !redisInspect) openContext(e, null, null);
|
||||
}}
|
||||
>
|
||||
{context && result && <ContextMenu x={context.x} y={context.y}
|
||||
title={context.column ? `结果 · ${context.column}` : "当前结果"} onClose={() => setContext(null)} items={[
|
||||
{ label: "查看完整值", hidden: !context.row || !context.column, action: () => showDetail(context.row!, context.column!) },
|
||||
{ label: "复制单元格", hidden: !context.row || !context.column, action: () => void copyContext(cellText(context.row?.[context.column!]), "单元格全文") },
|
||||
{ label: "复制当前行(TSV)", hidden: !context.row, action: () => void copyContext(selectionTSV(result.columns, [context.row!]), "当前行(不含表头)") },
|
||||
{ label: "复制当前列(TSV)", hidden: !context.column, hint: "当前筛选后的已加载行,按当前排序,不含表头", action: () => void copyContext(selectionTSV([context.column!], sortedRows), "当前列(筛选后的已加载行)") },
|
||||
{ label: "复制列名", hidden: !!context.row || !context.column, action: () => void copyContext(context.column!, "列名") },
|
||||
{ label: "本页升序", separator: true, hidden: !!context.row || !context.column, action: () => { setSortCol(context.column); setSortDir("asc"); } },
|
||||
{ label: "本页降序", hidden: !!context.row || !context.column, action: () => { setSortCol(context.column); setSortDir("desc"); } },
|
||||
{ label: "取消排序", hidden: !!context.row || !context.column || !sortCol, action: () => { setSortCol(null); setSortDir(null); } },
|
||||
{ label: "列宽适应内容", hidden: !!context.row || !context.column, action: () => setColWidths((w) => ({ ...w, [context.column!]: suggestedColumnWidth(context.column!, sortedRows) })) },
|
||||
{ label: "恢复默认列宽", hidden: !!context.row || !context.column, action: () => setColWidths((w) => ({ ...w, [context.column!]: 140 })) },
|
||||
{ label: "复制当前行 JSON", separator: true, hidden: !context.row, action: () => void copyContext(JSON.stringify(context.row, null, 2), "当前行 JSON") },
|
||||
{ label: "复制当前行 INSERT", hidden: !context.row, disabled: !insertSource || !isWritableDriver(insertSource.driver), hint: "仅已确认表来源的结果可用;任意查询不猜测目标表", action: () => { if (insertSource) void copyContext(rowsToInsert(insertSource.driver, relationParts(insertSource.driver, insertSource.database, insertSource.table), result.columns, [context.row!]), "当前行 INSERT"); } },
|
||||
{ label: filter ? "复制筛选结果(TSV)" : "复制全部已加载行(TSV)", hidden: !!context.row || !!context.column, action: () => void copyContext(selectionTSV(result.columns, sortedRows), "筛选后的已加载行(不含表头)") },
|
||||
{ label: filter ? "复制筛选结果 JSON" : "复制当前结果 JSON", hidden: !!context.row || !!context.column, action: () => void copyContext(JSON.stringify(sortedRows, null, 2), "当前结果 JSON") },
|
||||
{ label: "导出当前结果 CSV", separator: true, action: () => exportCurrent("csv") },
|
||||
{ label: "导出当前结果 JSON", action: () => exportCurrent("json") },
|
||||
]} />}
|
||||
<div className="ops-section-label justify-between border-b gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto normal-case tracking-normal">
|
||||
<div className="ops-seg" role="tablist" aria-label="结果视图">
|
||||
@@ -677,15 +727,6 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
>
|
||||
结构
|
||||
</button>
|
||||
{!redisInspect && (
|
||||
<button
|
||||
type="button"
|
||||
className={resultTab === "explain" ? "ops-seg-active" : undefined}
|
||||
onClick={() => set("resultTab", "explain")}
|
||||
>
|
||||
计划
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{resultSets.length > 1 && (
|
||||
<>
|
||||
@@ -713,13 +754,15 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{resultTab === "data" && result && !result.error && !redisInspect && (
|
||||
<span className="flex items-center gap-2 font-normal normal-case tracking-normal">
|
||||
{resultTab === "data" && result && !result.error && (
|
||||
<span className="flex items-center gap-2 font-normal normal-case tracking-normal" style={{ color: "var(--ops-muted)" }}>
|
||||
<span>
|
||||
{result.rowCount} 行 · {result.timeMs} ms
|
||||
{selectedIdx != null ? ` · #${selectedIdx + 1}` : ""}
|
||||
{canEdit ? "" : writable ? "" : " · 只读"}
|
||||
{!redisInspect && selectedIdx != null ? ` · #${selectedIdx + 1}` : ""}
|
||||
{!redisInspect && (canEdit ? "" : writable ? "" : " · 只读")}
|
||||
</span>
|
||||
{!redisInspect && (
|
||||
<>
|
||||
<button type="button" className="hover:underline" style={{ color: "var(--ops-accent)" }} onClick={() => void copy("tsv")}>
|
||||
复制全部
|
||||
</button>
|
||||
@@ -732,9 +775,7 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
if (selectedIdx == null || !result) return;
|
||||
const row = visibleRows[selectedIdx];
|
||||
if (!row) return;
|
||||
void copyText(result.columns.map((c) => (row[c] == null ? "" : String(row[c]))).join("\t")).then(() =>
|
||||
notify({ kind: "success", title: "已复制行", message: `#${selectedIdx + 1}`, mode: "toast", durationMs: 1200 }),
|
||||
);
|
||||
void copyContext(selectionTSV(result.columns, [row]), `第 ${selectedIdx + 1} 行`);
|
||||
}}
|
||||
>
|
||||
复制行
|
||||
@@ -778,6 +819,7 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
className="block w-full rounded px-2 py-1 text-left hover:bg-[var(--ops-bg-active)]"
|
||||
style={{ fontSize: "var(--ops-text-xs)", color: "var(--ops-text)" }}
|
||||
onClick={() => exportCurrent(f)}
|
||||
disabled={f === "insert" && !insertSource}
|
||||
>
|
||||
{f.toUpperCase()}
|
||||
</button>
|
||||
@@ -790,6 +832,7 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
void copy("insert");
|
||||
setExpMenu(false);
|
||||
}}
|
||||
disabled={!insertSource}
|
||||
>
|
||||
复制 INSERT
|
||||
</button>
|
||||
@@ -846,6 +889,8 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -854,38 +899,15 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
<SchemaPane />
|
||||
) : resultTab === "data" && redisInspect && result && !result.error ? (
|
||||
<RedisKeyPane />
|
||||
) : resultTab === "explain" ? (
|
||||
<div className="min-h-0 flex-1 overflow-auto p-2">
|
||||
{!explainText ? (
|
||||
<div className="ops-empty" style={{ minHeight: 100 }}>
|
||||
<h2 style={{ fontSize: "var(--ops-text-lg)" }}>执行计划</h2>
|
||||
<p>点工具栏「执行计划」获取(部分引擎可用)。</p>
|
||||
</div>
|
||||
) : (
|
||||
<pre
|
||||
className="m-0 whitespace-pre-wrap font-mono leading-relaxed"
|
||||
style={{ fontSize: "var(--ops-text-sm)", color: "var(--ops-text)" }}
|
||||
>
|
||||
{explainText}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(result?.truncated || truncatedLocal) && (
|
||||
<div
|
||||
className="shrink-0 border-b px-2 py-1 text-[11px]"
|
||||
style={{
|
||||
borderColor: "color-mix(in srgb, var(--ops-warning) 40%, var(--ops-border))",
|
||||
background: "color-mix(in srgb, var(--ops-warning) 10%, transparent)",
|
||||
color: "var(--ops-warning)",
|
||||
}}
|
||||
>
|
||||
已截断:界面最多显示 {result?.limit || DISPLAY_CAP} 行
|
||||
{truncatedLocal && sortedRows.length > DISPLAY_CAP ? `(共 ${sortedRows.length})` : ""}。加 LIMIT 或导出 CSV。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && !result.error && result.columns.length > 0 && <div className="ops-result-tools">
|
||||
<span className="ops-result-location" title={insertSource ? `${insertSource.database} / ${insertSource.table}` : "查询结果"}>{insertSource ? `${insertSource.database} / ${insertSource.table}` : "查询结果"}</span>
|
||||
<input className="ops-input" aria-label="筛选已加载结果" placeholder="筛选已加载结果…" value={filter} disabled={!!edit} onChange={(e) => setFilter(e.target.value)} />
|
||||
{filter && <button className="ops-btn-ghost" onClick={() => setFilter("")}>清除</button>}
|
||||
<span className="ml-auto tabular-nums">{filter ? `匹配 ${sortedRows.length} / ${result.rows.length} 行` : `${result.rows.length} 行`} · {result.columns.length} 列 · {result.timeMs} ms</span>
|
||||
</div>}
|
||||
{result?.truncated && <div className="ops-result-notice">服务端还有更多数据。请使用下一页或导出完整查询。</div>}
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{result?.error && (
|
||||
<div
|
||||
@@ -913,9 +935,7 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
type="button"
|
||||
className="hover:underline"
|
||||
onClick={() =>
|
||||
void copyText(activeRs ? `${activeRs.sql}\n\n${result.error ?? ""}` : (result.error ?? "")).then(() =>
|
||||
notify({ kind: "success", title: "已复制", message: "错误详情已复制", mode: "toast", durationMs: 1400 }),
|
||||
)
|
||||
void copyText(activeRs ? `${activeRs.sql}\n\n${result.error ?? ""}` : (result.error ?? "")).catch((e) => notifyError(e, "toast"))
|
||||
}
|
||||
>
|
||||
复制错误详情
|
||||
@@ -935,14 +955,15 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && !result.error && result.rows.length > 0 && !sortedRows.length && <div className="ops-empty"><h2>没有匹配的数据</h2><p>筛选仅作用于已加载结果,不会查询数据库。</p><button className="ops-btn-secondary" onClick={() => setFilter("")}>清除筛选</button></div>}
|
||||
{emptyResult && (
|
||||
<div className="ops-empty" style={{ minHeight: 120 }}>
|
||||
<h2 style={{ fontSize: "var(--ops-text-lg)" }}>空结果</h2>
|
||||
<p>查询成功,但没有返回行。试试改写 SQL 或放宽条件。</p>
|
||||
<h2 style={{ fontSize: "var(--ops-text-lg)" }}>{result?.columns.length ? "未找到记录" : "执行完成"}</h2>
|
||||
<p>{result?.columns.length ? "查询成功,但没有返回记录。可调整查询条件后重试。" : `语句执行成功,影响 ${result?.rowCount ?? 0} 行。`}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && !result.error && result.rows.length > 0 && (
|
||||
{result && !result.error && sortedRows.length > 0 && (
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="outline-none"
|
||||
@@ -962,9 +983,11 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
return (
|
||||
<th
|
||||
key={c}
|
||||
onContextMenu={(e) => openContext(e, null, c)}
|
||||
className="ops-sortable"
|
||||
style={{ width: w, minWidth: w, maxWidth: w }}
|
||||
onClick={() => cycleSort(c)}
|
||||
aria-sort={sortCol === c ? (sortDir === "asc" ? "ascending" : "descending") : "none"}
|
||||
onClick={() => { if (!edit) cycleSort(c); }}
|
||||
title="点击排序:升 → 降 → 取消(仅当前页数据)"
|
||||
>
|
||||
{c}
|
||||
@@ -978,7 +1001,7 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
本页
|
||||
</span>
|
||||
)}
|
||||
<span className="ops-col-resize" onMouseDown={(e) => onResizeStart(c, e)} />
|
||||
<span className="ops-col-resize" title="拖动调整列宽,双击适应内容" onClick={(e) => e.stopPropagation()} onDoubleClick={(e) => { e.stopPropagation(); setColWidths((widths) => ({ ...widths, [c]: suggestedColumnWidth(c, sortedRows) })); }} onMouseDown={(e) => onResizeStart(c, e)} />
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
@@ -988,9 +1011,10 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
{visibleRows.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
onContextMenu={(e) => { setSelectedIdx(i); setSelectedCol(null); openContext(e, row, null); }}
|
||||
data-row-idx={i}
|
||||
className={selectedIdx === i ? "ops-row-selected" : undefined}
|
||||
onClick={() => setSelectedIdx(i)}
|
||||
onClick={() => { setSelectedIdx(i); setSelectedCol(null); gridRef.current?.focus(); }}
|
||||
>
|
||||
<td className="ops-rownum">{(result.offset ?? 0) + i + 1}</td>
|
||||
{result.columns.map((c) => {
|
||||
@@ -1000,7 +1024,8 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
return (
|
||||
<td
|
||||
key={c}
|
||||
className={cellSelected ? "ops-cell-selected" : undefined}
|
||||
onContextMenu={(e) => { setSelectedIdx(i); setSelectedCol(c); openContext(e, row, c); }}
|
||||
className={`${cellSelected ? "ops-cell-selected" : ""} ${typeof row[c] === "number" ? "ops-number" : ""}`}
|
||||
style={{
|
||||
width: colWidths[c] ?? 140,
|
||||
maxWidth: colWidths[c] ?? 140,
|
||||
@@ -1009,13 +1034,14 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
outline: cellSelected ? "1px solid var(--ops-accent)" : undefined,
|
||||
outlineOffset: cellSelected ? -1 : undefined,
|
||||
}}
|
||||
title={row[c] == null ? "NULL" : String(row[c])}
|
||||
title={cellText(row[c])}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedIdx(i);
|
||||
setSelectedCol(c);
|
||||
gridRef.current?.focus();
|
||||
}}
|
||||
onDoubleClick={() => beginEdit(i, c)}
|
||||
onDoubleClick={() => { if (canEdit) beginEdit(i, c); else showDetail(row, c); }}
|
||||
>
|
||||
{editing ? (
|
||||
<input
|
||||
@@ -1039,8 +1065,7 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
title="查看全文"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setPeek({ text: long, x: Math.min(r.left, window.innerWidth - 460), y: r.bottom + 4 });
|
||||
showDetail(row, c);
|
||||
}}
|
||||
>
|
||||
展开
|
||||
@@ -1055,6 +1080,7 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{truncatedLocal && <div className="ops-load-more"><span>已显示 {visibleRows.length} / {sortedRows.length} 行</span><button className="ops-btn-secondary" onClick={() => setDisplayCount((n) => n + RESULT_BATCH)}>继续显示 {Math.min(RESULT_BATCH, sortedRows.length - displayCount)} 行</button></div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1090,51 +1116,10 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* G4:长值查看/复制全文 popover */}
|
||||
{peek && (
|
||||
<div
|
||||
data-ops-peek
|
||||
className="fixed flex max-w-[440px] flex-col gap-1.5 rounded border p-2 shadow-lg"
|
||||
style={{
|
||||
left: peek.x,
|
||||
top: peek.y,
|
||||
zIndex: "var(--ops-z-ctx)",
|
||||
borderColor: "var(--ops-border)",
|
||||
background: "var(--ops-surface)",
|
||||
boxShadow: "var(--ops-shadow-pop)",
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
className="m-0 max-h-56 overflow-auto whitespace-pre-wrap break-all font-mono"
|
||||
style={{ fontSize: "var(--ops-text-xs)", color: "var(--ops-text)" }}
|
||||
>
|
||||
{peek.text}
|
||||
</pre>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-secondary h-5 px-2"
|
||||
style={{ fontSize: "var(--ops-text-xs)" }}
|
||||
onClick={() =>
|
||||
void copyText(peek.text).then(() => {
|
||||
notify({ kind: "success", title: "已复制", message: "全文已复制", mode: "toast", durationMs: 1200 });
|
||||
setPeek(null);
|
||||
})
|
||||
}
|
||||
>
|
||||
复制全文
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost h-5 px-2"
|
||||
style={{ fontSize: "var(--ops-text-xs)" }}
|
||||
onClick={() => setPeek(null)}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{detail && <OpsModal wide title={detail.title} okLabel="关闭" onOk={() => setDetail(null)}>
|
||||
<div className="flex items-center justify-between mb-2 text-xs"><span>{detail.text.length} 字符 · 只读</span><button className="ops-btn-secondary" onClick={() => void copyContext(detail.text, "完整值")}>复制全文</button></div>
|
||||
<textarea readOnly aria-label="完整值" className="ops-input ops-value-detail" value={detail.text} />
|
||||
</OpsModal>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { execSql } from "./api";
|
||||
import { ensureSession } from "./ObjectTree";
|
||||
import { metadataGroups, tableMetadataSql, type MetadataKind } from "./tableMetadata";
|
||||
import type { SavedConnection } from "./store";
|
||||
|
||||
type GroupState = { rows?: { name: string; detail: string }[]; error?: string; loading?: boolean; truncated?: boolean };
|
||||
|
||||
/** 与对象树共用方言查询,单组失败不妨碍查看其他结构。 */
|
||||
function SchemaMetadataGroup({ conn, database, table, kind, label }: {
|
||||
conn: SavedConnection; database: string; table: string; kind: MetadataKind; label: string;
|
||||
}) {
|
||||
const sql = tableMetadataSql(conn.driver, database, table, kind);
|
||||
const [state, setState] = useState<GroupState>({ loading: true });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!sql) { setState({}); return; }
|
||||
let alive = true;
|
||||
setState({ loading: true });
|
||||
(async () => {
|
||||
try {
|
||||
const sessionId = await ensureSession(conn);
|
||||
const result = await execSql(conn, sql, { database, sessionId, limit: 1000 });
|
||||
if (result.error) throw new Error(result.error);
|
||||
if (alive) setState({
|
||||
rows: result.rows.map(row => ({ name: String(row.name ?? ""), detail: String(row.detail ?? "") })),
|
||||
truncated: !!result.truncated,
|
||||
});
|
||||
} catch (error) {
|
||||
if (alive) setState({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [conn, database, table, sql, attempt]);
|
||||
|
||||
if (!sql) return null;
|
||||
return <details open className="border-t px-3 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<summary className="cursor-pointer text-[12.5px] font-medium">
|
||||
{label}{state.rows ? `(${state.rows.length}${state.truncated ? "+" : ""})` : ""}
|
||||
</summary>
|
||||
{state.loading ? <p className="ops-tree-meta-note">加载中…</p> : state.error ?
|
||||
<div className="ops-tree-meta-note" role="alert">{label}读取失败:{state.error}
|
||||
<button type="button" onClick={() => setAttempt(n => n + 1)}>重试</button>
|
||||
</div> : state.rows?.length ? <>
|
||||
<table className="ops-grid" style={{ width: "100%" }}><thead><tr><th>名称</th><th>定义 / 详情</th></tr></thead>
|
||||
<tbody>{state.rows.map((row, index) => <tr key={`${row.name}-${index}`}>
|
||||
<td className="font-mono text-[12px]">{row.name}</td>
|
||||
<td className="font-mono text-[12px] whitespace-pre-wrap break-all">{row.detail}</td>
|
||||
</tr>)}</tbody>
|
||||
</table>
|
||||
{state.truncated && <p className="ops-tree-meta-note">仅展示前 1000 项</p>}
|
||||
</> : <p className="ops-tree-meta-note">当前权限下未找到{label}</p>}
|
||||
</details>;
|
||||
}
|
||||
|
||||
export function SchemaMetadata({ conn, database, table }: { conn: SavedConnection; database: string; table: string }) {
|
||||
return <div>{metadataGroups.map(group => <SchemaMetadataGroup
|
||||
key={`${conn.id}/${database}/${table}/${group.kind}`}
|
||||
conn={conn} database={database} table={table} kind={group.kind} label={group.label}
|
||||
/>)}</div>;
|
||||
}
|
||||
+4
-143
@@ -1,69 +1,19 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { execSql, listColumns } from "./api";
|
||||
import { useState } from "react";
|
||||
import { listColumns } from "./api";
|
||||
import { DdlModal } from "./DdlModal";
|
||||
import { ensureSession } from "./ObjectTree";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
|
||||
type MetaRow = Record<string, unknown>;
|
||||
|
||||
type TableMeta = {
|
||||
indexes: MetaRow[];
|
||||
fks: MetaRow[];
|
||||
triggers: MetaRow[];
|
||||
};
|
||||
|
||||
const EMPTY_META: TableMeta = { indexes: [], fks: [], triggers: [] };
|
||||
|
||||
/** MySQL 协议族才走 information_schema 元数据(PG/Oracle 的系统目录另行适配) */
|
||||
function isMysqlFamily(driver?: string) {
|
||||
return ["mysql", "mariadb", "tidb", "oceanbase", "polardb", "tdsql"].includes((driver || "").toLowerCase());
|
||||
}
|
||||
|
||||
/** 标识符转义进 information_schema 字符串比较 */
|
||||
const esc = (s: string) => s.replace(/'/g, "''");
|
||||
import { SchemaMetadata } from "./SchemaMetadata";
|
||||
|
||||
export function SchemaPane() {
|
||||
const schema = useWorkspaceStore((s) => s.schemaView);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const conn = useWorkspaceStore((s) => s.connections.find((c) => c.id === s.activeConnectionId));
|
||||
const [meta, setMeta] = useState<TableMeta>(EMPTY_META);
|
||||
/** R2-F3:DDL 查看弹窗 */
|
||||
const [showDdl, setShowDdl] = useState(false);
|
||||
const mysql = isMysqlFamily(conn?.driver);
|
||||
/** Redis 键的结构页不展示 DDL(服务端仅支持表/视图) */
|
||||
const ddlEnabled = !!conn && (conn.driver || "").toLowerCase() !== "redis";
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setMeta(EMPTY_META);
|
||||
if (!conn || !mysql || !schema || schema.loading || schema.error || !schema.table) return;
|
||||
(async () => {
|
||||
try {
|
||||
const sid = await ensureSession(conn);
|
||||
const db = esc(schema.database);
|
||||
const t = esc(schema.table);
|
||||
const run = (sqlText: string) => execSql(conn, sqlText, { database: schema.database, sessionId: sid, limit: 200 });
|
||||
const [ix, fk, tg] = await Promise.all([
|
||||
run(
|
||||
`SELECT INDEX_NAME AS name, IF(NON_UNIQUE=0,'UNIQUE','') AS uniq, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ', ') AS cols FROM information_schema.STATISTICS WHERE TABLE_SCHEMA='${db}' AND TABLE_NAME='${t}' GROUP BY INDEX_NAME, NON_UNIQUE ORDER BY INDEX_NAME`,
|
||||
),
|
||||
run(
|
||||
`SELECT CONSTRAINT_NAME AS name, COLUMN_NAME AS col, REFERENCED_TABLE_NAME AS refTable, REFERENCED_COLUMN_NAME AS refCol FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA='${db}' AND TABLE_NAME='${t}' AND REFERENCED_TABLE_NAME IS NOT NULL`,
|
||||
),
|
||||
run(
|
||||
`SELECT TRIGGER_NAME AS name, ACTION_TIMING AS timing, EVENT_MANIPULATION AS event, EVENT_OBJECT_TABLE AS onTable FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA='${db}' AND EVENT_OBJECT_TABLE='${t}'`,
|
||||
),
|
||||
]);
|
||||
if (alive) setMeta({ indexes: ix.rows, fks: fk.rows, triggers: tg.rows });
|
||||
} catch {
|
||||
/* 元数据不可用时静默降级:结构页至少保底显示列 */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [conn, mysql, schema?.database, schema?.table, schema?.loading, schema?.error]);
|
||||
|
||||
if (!schema) {
|
||||
return (
|
||||
<div className="ops-empty" style={{ minHeight: 120 }}>
|
||||
@@ -122,8 +72,6 @@ export function SchemaPane() {
|
||||
);
|
||||
}
|
||||
|
||||
const str = (v: unknown) => (v == null ? "" : String(v));
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="ops-section-label justify-between border-b">
|
||||
@@ -205,94 +153,7 @@ export function SchemaPane() {
|
||||
</table>
|
||||
)}
|
||||
|
||||
{mysql && (
|
||||
<div className="border-t" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<details open className="px-3 py-1.5"> <summary className="cursor-pointer text-[12.5px] font-medium" style={{ listStyle: "none" }}>
|
||||
索引({meta.indexes.length})
|
||||
</summary>
|
||||
{meta.indexes.length === 0 ? (
|
||||
<div className="px-1 py-1 text-[11.5px]" style={{ color: "var(--ops-muted)" }}>无索引</div>
|
||||
) : (
|
||||
<table className="ops-grid" style={{ width: "100%" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>索引名</th>
|
||||
<th style={{ width: 70 }}>唯一</th>
|
||||
<th>列</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{meta.indexes.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td className="font-mono text-[12px]">{str(r.name)}</td>
|
||||
<td>{r.uniq ? "✓" : ""}</td>
|
||||
<td className="font-mono text-[12px]" style={{ color: "var(--ops-muted)" }}>{str(r.cols)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</details>
|
||||
|
||||
<details open className="border-t px-3 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<summary className="cursor-pointer text-[12.5px] font-medium" style={{ listStyle: "none" }}>
|
||||
外键({meta.fks.length})
|
||||
</summary>
|
||||
{meta.fks.length === 0 ? (
|
||||
<div className="px-1 py-1 text-[11.5px]" style={{ color: "var(--ops-muted)" }}>无外键</div>
|
||||
) : (
|
||||
<table className="ops-grid" style={{ width: "100%" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>约束名</th>
|
||||
<th>列</th>
|
||||
<th>引用</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{meta.fks.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td className="font-mono text-[12px]">{str(r.name)}</td>
|
||||
<td className="font-mono text-[12px]">{str(r.col)}</td>
|
||||
<td className="font-mono text-[12px]" style={{ color: "var(--ops-muted)" }}>
|
||||
{str(r.refTable)}.{str(r.refCol)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</details>
|
||||
|
||||
<details open className="border-t px-3 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<summary className="cursor-pointer text-[12.5px] font-medium" style={{ listStyle: "none" }}>
|
||||
触发器({meta.triggers.length})
|
||||
</summary>
|
||||
{meta.triggers.length === 0 ? (
|
||||
<div className="px-1 py-1 text-[11.5px]" style={{ color: "var(--ops-muted)" }}>无触发器</div>
|
||||
) : (
|
||||
<table className="ops-grid" style={{ width: "100%" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>触发器</th>
|
||||
<th style={{ width: 80 }}>时机</th>
|
||||
<th style={{ width: 80 }}>事件</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{meta.triggers.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td className="font-mono text-[12px]">{str(r.name)}</td>
|
||||
<td>{str(r.timing)}</td>
|
||||
<td>{str(r.event)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
{conn && <SchemaMetadata conn={conn} database={schema.database} table={schema.table} />}
|
||||
</div>
|
||||
{showDdl && conn && (
|
||||
<DdlModal conn={conn} database={schema.database} table={schema.table} onClose={() => setShowDdl(false)} />
|
||||
|
||||
+33
-108
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ExternalLink,
|
||||
Info,
|
||||
@@ -12,20 +12,18 @@ import {
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { driverStatus, health, type DriverStatusItem } from "./api";
|
||||
import { health } from "./api";
|
||||
import { OpsModal } from "./OpsModal";
|
||||
import { AiSettings } from "./AiPanel";
|
||||
import { AiSettings, type AiSettingsHandle, type AiSettingsStatus } from "./AiPanel";
|
||||
import { McpSettings } from "./McpSettings";
|
||||
import { DriverManager } from "./DriverManager";
|
||||
import appPackage from "../package.json";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
import { openExternal } from "./openExternal";
|
||||
import wechatGroupQr from "./assets/wechat-group-qr.png";
|
||||
|
||||
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";
|
||||
@@ -34,8 +32,8 @@ 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: "ai", icon: Sparkles, label: "大模型", desc: "AI 供应商" },
|
||||
{ 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: "键盘操作一览" },
|
||||
@@ -110,58 +108,7 @@ function AboutLink({ label, description, onClick }: { label: string; description
|
||||
);
|
||||
}
|
||||
|
||||
function WechatGroupBlock() {
|
||||
const [imgOk, setImgOk] = useState(true);
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg border p-3" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{imgOk ? (
|
||||
<img
|
||||
src={wechatGroupQr}
|
||||
alt="微信交流群二维码"
|
||||
width={132}
|
||||
height={132}
|
||||
className="rounded-lg object-cover"
|
||||
style={{
|
||||
width: 132,
|
||||
height: 132,
|
||||
border: "1px solid var(--ops-border-soft)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
onError={() => setImgOk(false)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="grid place-items-center text-center text-[11px]"
|
||||
style={{
|
||||
width: 132,
|
||||
height: 132,
|
||||
borderRadius: 8,
|
||||
border: "1px dashed var(--ops-border)",
|
||||
color: "var(--ops-muted)",
|
||||
background: "var(--ops-surface-2)",
|
||||
}}
|
||||
>
|
||||
二维码暂缺
|
||||
<br />
|
||||
请点下方链接加入
|
||||
</div>
|
||||
)}
|
||||
<p className="text-center text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
扫码加入交流群
|
||||
</p>
|
||||
</div>
|
||||
<AboutLink
|
||||
label="微信群"
|
||||
description="扫码加入交流群"
|
||||
onClick={() => void openExternal(WECHAT_GROUP_URL)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/** 设置弹框:左分类导航 + 右面板(对标 opsxshell / DBeaver Preferences) */
|
||||
/** 设置弹框:左分类导航 + 右面板 */
|
||||
export function SettingsModal({ onClose }: { onClose: () => void }) {
|
||||
const [tab, setTab] = useState<TabId>("general");
|
||||
const theme = useWorkspaceStore((s) => s.theme);
|
||||
@@ -171,20 +118,12 @@ 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 [showDriverManager, setShowDriverManager] = useState(false);
|
||||
const [driverError, setDriverError] = useState("");
|
||||
|
||||
const [drivers, setDrivers] = useState<DriverStatusItem[] | null>(null);
|
||||
const aiSettingsRef = useRef<AiSettingsHandle>(null);
|
||||
const [aiStatus, setAiStatus] = useState<AiSettingsStatus>({ busy: false, testing: false, canSave: false, canTest: false, result: 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(() => {});
|
||||
}, []);
|
||||
|
||||
@@ -201,14 +140,30 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<OpsModal wide variant="info" title="设置" okLabel="完成" onOk={onClose}>
|
||||
<div className="flex" style={{ height: "min(520px, 65vh)", minHeight: 260 }}>
|
||||
<OpsModal wide className="ops-settings-modal" variant="info" title="设置"
|
||||
okLabel={active === "ai" ? aiStatus.busy && !aiStatus.testing ? "保存中…" : "保存并关闭" : "完成"}
|
||||
okDisabled={active === "ai" && !aiStatus.canSave}
|
||||
onCancel={() => { if (!aiStatus.busy || active !== "ai") onClose(); }}
|
||||
footerLeading={active === "ai" ? <>
|
||||
<div className="ops-settings-feedback" role="status" aria-live="polite" style={{ color: aiStatus.result?.ok ? "var(--ops-success)" : "var(--ops-danger)" }}>
|
||||
{aiStatus.testing ? <span style={{ color: "var(--ops-muted)" }}>正在测试连接…</span> : aiStatus.result?.text}
|
||||
</div>
|
||||
<button type="button" className="ops-btn-secondary" disabled={!aiStatus.canTest} onClick={() => void aiSettingsRef.current?.test()}>
|
||||
{aiStatus.testing ? "测试中…" : "测试连接"}
|
||||
</button>
|
||||
</> : undefined}
|
||||
onOk={() => {
|
||||
if (active === "ai") void aiSettingsRef.current?.save().then((saved) => { if (saved) onClose(); });
|
||||
else onClose();
|
||||
}}>
|
||||
<div className="ops-settings-layout">
|
||||
{/* 左导航:搜索 + 线框图标 + 主标题/副文案(对齐 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="搜索设置"
|
||||
disabled={active === "ai" && aiStatus.busy}
|
||||
value={navSearch}
|
||||
onChange={(e) => setNavSearch(e.target.value)}
|
||||
/>
|
||||
@@ -226,6 +181,7 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
|
||||
}`}
|
||||
style={selected ? { boxShadow: "inset 2px 0 0 var(--ops-accent)" } : undefined}
|
||||
aria-current={selected ? "page" : undefined}
|
||||
disabled={active === "ai" && aiStatus.busy}
|
||||
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)" }}>
|
||||
@@ -241,11 +197,11 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
|
||||
</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")} />}
|
||||
<div className="ops-settings-panel 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-sm font-semibold">{active === "ai" ? "AI 供应商" : activeTab.label}</h2>{active === "ai" && <p className="mt-1 text-xs" style={{ color: "var(--ops-muted)" }}>配置 Ask 和 Agent 使用的服务与模型</p>}</div> : <div className="py-12 text-center"><p>没有匹配的设置</p><button className="ops-btn-secondary mt-3" onClick={() => setNavSearch("")}>清空搜索</button></div>}
|
||||
{active === "ai" && <AiSettings ref={aiSettingsRef} onStatus={setAiStatus} />}
|
||||
{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 === "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="主题">
|
||||
@@ -272,7 +228,7 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
|
||||
|
||||
{active === "execution" && (
|
||||
<>
|
||||
<Row label="默认执行超时" hint="单语句执行上限,超时由服务端强制中断">
|
||||
<Row label="默认执行超时" hint="为每条语句设置超时;0 表示不额外指定,具体中断行为取决于驱动">
|
||||
<Seg
|
||||
value={execTimeoutMs}
|
||||
options={[
|
||||
@@ -312,32 +268,7 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{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 === "drivers" && <DriverManager embedded />}
|
||||
|
||||
{active === "shortcuts" && (
|
||||
<div className="space-y-1.5">
|
||||
@@ -377,15 +308,10 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
|
||||
<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>
|
||||
<div>
|
||||
<div className="mb-2 px-1 text-[11px] font-medium" style={{ color: "var(--ops-muted)" }}>资源</div>
|
||||
<div className="space-y-2">
|
||||
<WechatGroupBlock />
|
||||
<div className="overflow-hidden rounded-lg border" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
<AboutLink
|
||||
label="驱动商店"
|
||||
@@ -400,7 +326,6 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
</div>
|
||||
</OpsModal>
|
||||
{showDriverManager && <DriverManager onClose={() => { setShowDriverManager(false); reloadDrivers(); }} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+24
-18
@@ -1,7 +1,9 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, placeholder } from "@codemirror/view";
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab, toggleComment } from "@codemirror/commands";
|
||||
import { search, searchKeymap, openSearchPanel } from "@codemirror/search";
|
||||
import { identifierAt } from "./contextActions";
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import { sql } from "@codemirror/lang-sql";
|
||||
import { autocompletion, type Completion, type CompletionContext, type CompletionResult } from "@codemirror/autocomplete";
|
||||
@@ -93,12 +95,15 @@ export type SqlEditorHandle = {
|
||||
cutSelection: () => Promise<void>;
|
||||
copySelection: () => Promise<void>;
|
||||
pasteClipboard: () => Promise<void>;
|
||||
toggleComment: () => void;
|
||||
findReplace: () => void;
|
||||
};
|
||||
|
||||
export type SqlEditorContextInfo = {
|
||||
x: number;
|
||||
y: number;
|
||||
hasSelection: boolean;
|
||||
identifier: string[] | null;
|
||||
};
|
||||
|
||||
export const SqlEditor = forwardRef<
|
||||
@@ -159,6 +164,8 @@ export const SqlEditor = forwardRef<
|
||||
view.focus();
|
||||
},
|
||||
focus: () => viewRef.current?.focus(),
|
||||
toggleComment: () => { const view = viewRef.current; if (view) { toggleComment(view); view.focus(); } },
|
||||
findReplace: () => { const view = viewRef.current; if (view) openSearchPanel(view); },
|
||||
hasSelection: () => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return false;
|
||||
@@ -183,11 +190,9 @@ export const SqlEditor = forwardRef<
|
||||
const sel = view.state.selection.main;
|
||||
if (sel.empty) return;
|
||||
const text = view.state.sliceDoc(sel.from, sel.to);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const doc = view.state.doc;
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (view.state.doc !== doc) throw new Error("文档已变化,已复制但未删除原选区");
|
||||
view.dispatch({ changes: { from: sel.from, to: sel.to, insert: "" } });
|
||||
view.focus();
|
||||
},
|
||||
@@ -197,22 +202,13 @@ export const SqlEditor = forwardRef<
|
||||
const sel = view.state.selection.main;
|
||||
if (sel.empty) return;
|
||||
const text = view.state.sliceDoc(sel.from, sel.to);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await navigator.clipboard.writeText(text);
|
||||
view.focus();
|
||||
},
|
||||
pasteClipboard: async () => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
let text = "";
|
||||
try {
|
||||
text = await navigator.clipboard.readText();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const text = await navigator.clipboard.readText();
|
||||
const sel = view.state.selection.main;
|
||||
view.dispatch({
|
||||
changes: { from: sel.from, to: sel.to, insert: text },
|
||||
@@ -240,11 +236,13 @@ export const SqlEditor = forwardRef<
|
||||
lineNumbers(),
|
||||
highlightActiveLine(),
|
||||
history(),
|
||||
search({ top: true }),
|
||||
sql(),
|
||||
// §3.10:关键字 + schemaCache 补全(override 覆盖 lang-sql 默认源)
|
||||
autocompletion({ override: [redis ? redisComplete : sqlComplete(connIdRef)] }),
|
||||
placeholder(redis ? "写 Redis 命令,⌘/Ctrl+Enter 执行当前行或选区" : "写 SQL,⌘/Ctrl+Enter 执行当前语句或选区"),
|
||||
keymap.of([runKey, indentWithTab, ...historyKeymap, ...defaultKeymap]),
|
||||
// Mod-/(⌘/Ctrl+/)切换 SQL 注释
|
||||
keymap.of([runKey, indentWithTab, { key: "Mod-/", run: toggleComment }, ...searchKeymap, ...historyKeymap, ...defaultKeymap]),
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged) onChangeRef.current(u.state.doc.toString());
|
||||
}),
|
||||
@@ -252,11 +250,19 @@ export const SqlEditor = forwardRef<
|
||||
contextmenu: (e, view) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const pos = view.posAtCoords({ x: e.clientX, y: e.clientY });
|
||||
const before = view.state.selection.main;
|
||||
// 选区内右键保留选区;选区外以实际右键位置为执行目标。
|
||||
if (pos != null && (before.empty || pos < before.from || pos > before.to)) {
|
||||
view.dispatch({ selection: EditorSelection.cursor(pos) });
|
||||
}
|
||||
const sel = view.state.selection.main;
|
||||
view.focus();
|
||||
onContextMenuRef.current?.({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
hasSelection: !sel.empty,
|
||||
identifier: pos == null ? null : identifierAt(view.state.doc.toString(), pos),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { execSql, listColumns } from "./api";
|
||||
import { OpsModal } from "./OpsModal";
|
||||
import { withSessionRetry } from "./ObjectTree";
|
||||
import { useWorkspaceStore, type SavedConnection } from "./store";
|
||||
import { tableMetadataSql, type MetadataKind } from "./tableMetadata";
|
||||
import { quoteIdent } from "./sql";
|
||||
import { buildMysqlIndexAlter, canEditIndexes, mysqlIndexQuery, toDesignerIndexes, validateIndexes, type DesignerIndex } from "./tableIndex";
|
||||
import {
|
||||
buildAlterStatements,
|
||||
buildCreateSql,
|
||||
columnTypeSql,
|
||||
designerSupport,
|
||||
toDesignerColumns,
|
||||
validateColumns,
|
||||
type DesignerColumn,
|
||||
isMysqlFamily,
|
||||
} from "./tableDdl";
|
||||
|
||||
const COMMON_TYPES = [
|
||||
"INT", "BIGINT", "SMALLINT", "VARCHAR", "CHAR", "TEXT", "DECIMAL", "NUMERIC",
|
||||
"FLOAT", "DOUBLE", "DATE", "DATETIME", "TIMESTAMP", "TIME", "BOOLEAN", "BLOB", "JSON",
|
||||
];
|
||||
|
||||
function emptyColumn(): DesignerColumn {
|
||||
return { id: crypto.randomUUID(), name: "", type: "VARCHAR", length: "255", nullable: true, defaultValue: "", comment: "", primary: false, autoIncrement: false };
|
||||
}
|
||||
|
||||
type DesignTab = "fields" | "indexes" | "foreignKeys" | "triggers" | "preview";
|
||||
const designTabs: { id: DesignTab; label: string }[] = [
|
||||
{ id: "fields", label: "字段" }, { id: "indexes", label: "索引" },
|
||||
{ id: "foreignKeys", label: "外键" }, { id: "triggers", label: "触发器" }, { id: "preview", label: "SQL 预览" },
|
||||
];
|
||||
|
||||
function MetadataView({ conn, database, table, kind }: { conn: SavedConnection; database: string; table: string; kind: MetadataKind }) {
|
||||
const [rows, setRows] = useState<{ name: string; detail: string }[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [retry, setRetry] = useState(0);
|
||||
const sql = tableMetadataSql(conn.driver, database, table, kind);
|
||||
useEffect(() => {
|
||||
if (!sql) return;
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
void withSessionRetry(conn, sid => execSql(conn, sql, { database, sessionId: sid, limit: 1000 }))
|
||||
.then(res => {
|
||||
if (res.error) throw new Error(res.error);
|
||||
if (alive) setRows(res.rows.map(row => ({ name: String(row.name ?? ""), detail: String(row.detail ?? "") })));
|
||||
})
|
||||
.catch(e => alive && setError(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => { alive = false; };
|
||||
}, [conn, database, table, kind, sql, retry]);
|
||||
if (!sql) return <p className="ops-tree-meta-note">当前驱动不支持读取此类结构。</p>;
|
||||
if (loading) return <p className="ops-tree-meta-note">读取结构中…</p>;
|
||||
if (error) return <p role="alert" className="ops-tree-meta-note">读取失败:{error} <button type="button" onClick={() => setRetry(n => n + 1)}>重试</button></p>;
|
||||
if (!rows.length) return <p className="ops-tree-meta-note">当前表暂无记录。</p>;
|
||||
return <div className="max-h-[48vh] overflow-auto rounded border" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<table className="ops-grid w-full"><thead><tr><th>名称</th><th>定义 / 详情</th></tr></thead><tbody>
|
||||
{rows.map((row, i) => <tr key={`${row.name}-${i}`}><td>{row.name}</td><td className="whitespace-pre-wrap break-all font-mono">{row.detail}</td></tr>)}
|
||||
</tbody></table>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/** 字段与 MySQL 索引同屏编辑;其他结构明确只读。 */
|
||||
export function TableDesigner({
|
||||
conn,
|
||||
database,
|
||||
table,
|
||||
onClose,
|
||||
onApplied,
|
||||
}: {
|
||||
conn: SavedConnection;
|
||||
database: string;
|
||||
/** 缺省 = 新建表 */
|
||||
table?: string;
|
||||
onClose: () => void;
|
||||
onApplied: () => void;
|
||||
}) {
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const notifyError = useWorkspaceStore((s) => s.notifyError);
|
||||
const askConfirm = useWorkspaceStore((s) => s.askConfirm);
|
||||
const [tableName, setTableName] = useState(table || "");
|
||||
const [cols, setCols] = useState<DesignerColumn[]>(() => [emptyColumn()]);
|
||||
const [initial, setInitial] = useState<DesignerColumn[]>([]);
|
||||
const [loading, setLoading] = useState(!!table);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<DesignTab>("fields");
|
||||
const [indexes, setIndexes] = useState<DesignerIndex[]>([]);
|
||||
const [initialIndexes, setInitialIndexes] = useState<DesignerIndex[]>([]);
|
||||
const [metadataError, setMetadataError] = useState("");
|
||||
const [retry, setRetry] = useState(0);
|
||||
const [density, setDensity] = useState<"comfortable" | "compact">("comfortable");
|
||||
const [comment, setComment] = useState("");
|
||||
const [initialComment, setInitialComment] = useState("");
|
||||
const mysql = isMysqlFamily(conn.driver);
|
||||
const editableIndexes = canEditIndexes(conn.driver);
|
||||
|
||||
useEffect(() => {
|
||||
if (!table) return;
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setMetadataError("");
|
||||
const load = async () => {
|
||||
const res = await withSessionRetry(conn, sid => listColumns(conn, database, table, sid));
|
||||
if (!alive) return;
|
||||
const columns = toDesignerColumns(res.columns);
|
||||
setCols(columns);
|
||||
setInitial(columns.map(c => ({ ...c })));
|
||||
if (mysql) {
|
||||
const idx = await withSessionRetry(conn, sid => execSql(conn, mysqlIndexQuery(database, table), { database, sessionId: sid, limit: 2000 }));
|
||||
if (idx.error || idx.truncated) throw new Error(idx.error || "索引列表已截断,无法安全编辑");
|
||||
const items = toDesignerIndexes(idx.rows);
|
||||
// 元数据查询使用十六进制文本,避免 SQL 模式对反斜线的歧义。
|
||||
const hex = (value: string) => `CONVERT(X'${Array.from(new TextEncoder().encode(value), b => b.toString(16).padStart(2, "0")).join("")}' USING utf8mb4)`;
|
||||
const sql = `SELECT TABLE_COMMENT AS comment FROM information_schema.TABLES WHERE TABLE_SCHEMA=${hex(database)} AND TABLE_NAME=${hex(table)}`;
|
||||
const meta = await withSessionRetry(conn, sid => execSql(conn, sql, { database, sessionId: sid, limit: 1 }));
|
||||
if (meta.error) throw new Error(meta.error);
|
||||
if (!alive) return;
|
||||
setIndexes(items);
|
||||
setInitialIndexes(items.map(i => ({ ...i, columns: [...i.columns] })));
|
||||
const note = String(meta.rows[0]?.comment ?? "");
|
||||
setComment(note);
|
||||
setInitialComment(note);
|
||||
}
|
||||
};
|
||||
void load()
|
||||
.catch((e) => alive && setMetadataError(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [conn, database, table, mysql, retry]);
|
||||
|
||||
const support = designerSupport(conn.driver);
|
||||
const errors = useMemo(() => [
|
||||
...validateColumns(tableName, cols),
|
||||
...(editableIndexes ? validateIndexes(indexes, cols.map(c => c.name)) : []),
|
||||
], [tableName, cols, editableIndexes, indexes]);
|
||||
const isNew = !table;
|
||||
const alterStmts = useMemo(
|
||||
() => (isNew ? [] : buildAlterStatements(conn.driver, database, table, initial, cols)),
|
||||
[isNew, conn.driver, database, table, initial, cols],
|
||||
);
|
||||
const indexChanges = useMemo(() => isNew || !editableIndexes ? { drop: [], add: [] } : buildMysqlIndexAlter(database, table, initialIndexes, indexes),
|
||||
[isNew, editableIndexes, database, table, initialIndexes, indexes]);
|
||||
const commentChange = !isNew && mysql && comment !== initialComment
|
||||
? [`ALTER TABLE ${quoteIdent(conn.driver, database)}.${quoteIdent(conn.driver, table)} COMMENT='${comment.replace(/'/g, "''")}';`] : [];
|
||||
const allStatements = isNew
|
||||
? [buildCreateSql(conn.driver, database, tableName.trim(), cols, mysql ? indexes : [], comment)]
|
||||
: [...indexChanges.drop, ...alterStmts, ...indexChanges.add, ...commentChange];
|
||||
const unsupported = allStatements.some(s => s.startsWith("--")) || (!isNew && !support.alter);
|
||||
const ddl = useMemo(() => {
|
||||
if (errors.length) return "";
|
||||
return allStatements.length ? allStatements.join("\n") : "-- 暂无变更";
|
||||
}, [errors, allStatements]);
|
||||
|
||||
const patch = (id: string, p: Partial<DesignerColumn>) =>
|
||||
setCols((cs) => cs.map((c) => (c.id === id ? { ...c, ...p } : c)));
|
||||
|
||||
const apply = async () => {
|
||||
if (!conn || !canApply) return;
|
||||
const stmts = allStatements;
|
||||
const ok = await askConfirm({
|
||||
title: isNew ? "确认创建表" : "确认修改表结构",
|
||||
message: `即将在 ${database}.${tableName.trim()} 执行 ${stmts.length} 条 DDL;请核对 SQL 预览。修改结构可能影响现有数据。`,
|
||||
okLabel: isNew ? "创建表" : "执行变更",
|
||||
danger: !isNew,
|
||||
items: stmts,
|
||||
});
|
||||
if (!ok) return;
|
||||
setApplying(true);
|
||||
try {
|
||||
for (const s of stmts) {
|
||||
const res = await withSessionRetry(conn, (sid) =>
|
||||
execSql(conn, s, { sessionId: sid, database, confirmDanger: true }),
|
||||
);
|
||||
if (res.error) throw new Error(res.error);
|
||||
}
|
||||
notify({
|
||||
kind: "success",
|
||||
title: isNew ? "表已创建" : "表结构已更新",
|
||||
message: `${database}.${tableName.trim()}`,
|
||||
mode: "toast",
|
||||
durationMs: 1800,
|
||||
});
|
||||
onApplied();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
notifyError(e, "toast");
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canApply = support.create && !applying && !loading && !metadataError && !errors.length && !unsupported && allStatements.length > 0;
|
||||
|
||||
const copyPreview = async () => {
|
||||
if (!ddl || errors.length || !allStatements.length) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(ddl);
|
||||
} catch (e) {
|
||||
notifyError(e, "toast");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<OpsModal
|
||||
spacious
|
||||
variant="confirm"
|
||||
title={isNew ? `新建表 · ${database}` : `设计表 · ${database}.${table}`}
|
||||
okLabel={applying ? "应用中…" : isNew ? "创建" : "应用变更"}
|
||||
okDisabled={!canApply}
|
||||
cancelLabel="取消"
|
||||
onOk={() => void apply()}
|
||||
onCancel={() => { if (!applying) onClose(); }}
|
||||
>
|
||||
{loading ? (
|
||||
<p role="status" className="text-xs">正在读取表结构…</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{metadataError && <div role="alert" className="rounded border p-2 text-[11px]" style={{ color: "var(--ops-danger)", borderColor: "var(--ops-danger)" }}>
|
||||
结构读取失败:{metadataError}。为避免遗漏已有索引,已暂停应用。
|
||||
<button type="button" className="ops-btn-ghost" onClick={() => setRetry(n => n + 1)}>重试</button>
|
||||
</div>}
|
||||
{!support.create && <div role="alert" className="text-xs">{support.note}</div>}
|
||||
{support.note && support.create && <div className="text-[11px]" style={{ color: "var(--ops-warning)" }}>{support.note}</div>}
|
||||
<div className="flex flex-wrap items-end gap-x-4 gap-y-2 text-[12px]">
|
||||
<label className="flex min-w-0 flex-col gap-1">
|
||||
<span style={{ color: "var(--ops-muted)" }}>表名</span>
|
||||
<input
|
||||
className="ops-input h-8 w-56"
|
||||
value={tableName}
|
||||
disabled={!isNew}
|
||||
aria-label="表名"
|
||||
onChange={(e) => setTableName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{mysql && <label className="flex min-w-44 max-w-md flex-1 flex-col gap-1">
|
||||
<span style={{ color: "var(--ops-muted)" }}>表注释</span>
|
||||
<input className="ops-input h-8 w-full" value={comment} aria-label="表注释" onChange={e => setComment(e.target.value)} />
|
||||
</label>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end justify-between gap-x-3 gap-y-1 border-b" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<div className="flex gap-1" role="tablist" aria-label="表设计分区">
|
||||
{designTabs.map(item => <button type="button" key={item.id} role="tab" aria-selected={activeTab === item.id}
|
||||
className={`border-b-2 px-2.5 py-2 text-[12px] transition ${activeTab === item.id ? "font-semibold" : "hover:opacity-100"}`}
|
||||
style={{ borderColor: activeTab === item.id ? "var(--ops-accent)" : "transparent", color: activeTab === item.id ? "var(--ops-text)" : "var(--ops-muted)" }}
|
||||
onClick={() => setActiveTab(item.id)}>{item.label}</button>)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pb-1.5">
|
||||
{activeTab === "fields" && <><button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" onClick={() => setCols(cs => [...cs, emptyColumn()])}>+ 添加字段</button>
|
||||
<select className="ops-input h-6 text-[11px]" value={density} aria-label="字段密度" onChange={e => setDensity(e.target.value as typeof density)}><option value="comfortable">标准</option><option value="compact">紧凑</option></select></>}
|
||||
{activeTab === "indexes" && editableIndexes && <button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" onClick={() => setIndexes(items => [...items, { id: crypto.randomUUID(), name: "", unique: false, columns: [] }])}>+ 添加索引</button>}
|
||||
<span className="min-w-14 text-right text-[11px] tabular-nums" style={{ color: allStatements.length ? "var(--ops-accent)" : "var(--ops-muted)" }}>{allStatements.length} 条变更</span>
|
||||
</div>
|
||||
</div>
|
||||
{activeTab === "fields" && <div className="max-h-[48vh] overflow-auto rounded border" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<table className={`ops-designer-grid w-full ${density === "compact" ? "ops-designer-compact" : ""}`} style={{ minWidth: 980 }}>
|
||||
<thead>
|
||||
<tr className="text-left" style={{ color: "var(--ops-muted)" }}>
|
||||
<th className="py-1 pr-2">#</th><th className="py-1 pr-2">字段名</th>
|
||||
<th className="pr-2">类型</th>
|
||||
<th className="pr-2">长度/精度</th>
|
||||
<th className="pr-2">NULL</th>
|
||||
{isMysqlFamily(conn.driver) && <th className="pr-2">自增</th>}
|
||||
<th className="pr-2">主键</th>
|
||||
<th className="pr-2">默认值</th>
|
||||
<th className="pr-2">注释</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cols.map((c, index) => (
|
||||
<tr key={c.id}>
|
||||
<td className="text-center" style={{ color: "var(--ops-muted)" }}>{index + 1}</td>
|
||||
<td className="py-0.5 pr-2">
|
||||
<input className="ops-input h-6 w-32" value={c.name} aria-label="字段名" onChange={(e) => patch(c.id, { name: e.target.value })} />
|
||||
</td>
|
||||
<td className="pr-2">
|
||||
<input className="ops-input h-6 w-28" list="designer-types" value={c.type} aria-label="类型" onChange={(e) => patch(c.id, { type: e.target.value.toUpperCase() })} />
|
||||
</td>
|
||||
<td className="pr-2">
|
||||
<input className="ops-input h-6 w-20" value={c.length} aria-label="长度" onChange={(e) => patch(c.id, { length: e.target.value })} />
|
||||
</td>
|
||||
<td className="pr-2 text-center">
|
||||
<input type="checkbox" checked={c.nullable} aria-label="可空" onChange={(e) => patch(c.id, { nullable: e.target.checked })} />
|
||||
</td>
|
||||
{isMysqlFamily(conn.driver) && (
|
||||
<td className="pr-2 text-center">
|
||||
<input type="checkbox" checked={c.autoIncrement} aria-label="自增" onChange={(e) => patch(c.id, { autoIncrement: e.target.checked })} />
|
||||
</td>
|
||||
)}
|
||||
<td className="pr-2 text-center">
|
||||
<input type="checkbox" checked={c.primary} aria-label="主键" onChange={(e) => patch(c.id, { primary: e.target.checked, nullable: e.target.checked ? false : c.nullable })} />
|
||||
</td>
|
||||
<td className="pr-2">
|
||||
<input className="ops-input h-6 w-24" value={c.defaultValue} aria-label="默认值" onChange={(e) => patch(c.id, { defaultValue: e.target.value })} />
|
||||
</td>
|
||||
<td className="pr-2">
|
||||
<input className="ops-input h-6 w-28" value={c.comment} aria-label="注释" onChange={(e) => patch(c.id, { comment: e.target.value })} />
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" className="ops-btn-ghost px-1" title={isNew ? "上移字段" : "现有表字段重排暂不支持"} disabled={!isNew || index === 0} onClick={() => setCols(cs => { const next = [...cs]; [next[index - 1], next[index]] = [next[index], next[index - 1]]; return next; })}>↑</button>
|
||||
<button type="button" className="ops-btn-ghost px-1" title={isNew ? "下移字段" : "现有表字段重排暂不支持"} disabled={!isNew || index === cols.length - 1} onClick={() => setCols(cs => { const next = [...cs]; [next[index + 1], next[index]] = [next[index], next[index + 1]]; return next; })}>↓</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-close-faint"
|
||||
title="删除字段"
|
||||
aria-label={`删除字段 ${c.name}`}
|
||||
onClick={() => setCols((cs) => cs.filter((x) => x.id !== c.id))}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<datalist id="designer-types">
|
||||
{COMMON_TYPES.map((t) => <option key={t} value={t} />)}
|
||||
</datalist>
|
||||
</div>}
|
||||
{activeTab === "indexes" && (editableIndexes ? <div className="max-h-[48vh] max-w-4xl space-y-2 overflow-auto">
|
||||
<p className="m-0 text-[11px]" style={{ color: "var(--ops-muted)" }}>主键在字段页设置;复合索引按字段顺序创建。</p>
|
||||
{indexes.map((idx, i) => <div key={idx.id} className="rounded-md border px-3 py-2.5" style={{ borderColor: "var(--ops-border-soft)", background: "var(--ops-surface-2)" }}>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<span className="w-4 shrink-0 text-center text-[11px] tabular-nums" style={{ color: "var(--ops-muted)" }}>{i + 1}</span>
|
||||
<input className="ops-input h-7 min-w-40 flex-1" value={idx.name} disabled={idx.readOnly} placeholder="索引名" aria-label={`第 ${i + 1} 个索引名`}
|
||||
onChange={e => setIndexes(items => items.map(item => item.id === idx.id ? { ...item, name: e.target.value } : item))} />
|
||||
<label className="flex shrink-0 items-center gap-1.5 text-[11px]"><input type="checkbox" checked={idx.unique} disabled={idx.readOnly} onChange={e => setIndexes(items => items.map(item => item.id === idx.id ? { ...item, unique: e.target.checked } : item))} />唯一</label>
|
||||
<select className="ops-input h-7 w-36 shrink-0 text-[11px]" disabled={idx.readOnly} aria-label={`为索引 ${idx.name} 添加字段`} value=""
|
||||
onChange={e => { if (e.target.value) setIndexes(items => items.map(item => item.id === idx.id ? { ...item, columns: [...item.columns, e.target.value] } : item)); }}>
|
||||
<option value="">添加字段…</option>{cols.filter(c => c.name.trim() && !idx.columns.includes(c.name)).map(c => <option key={c.id} value={c.name}>{c.name}</option>)}
|
||||
</select>
|
||||
{idx.readOnly ? <span className="text-[11px]" style={{ color: "var(--ops-warning)" }}>特殊索引 · 只读</span>
|
||||
: <button type="button" className="ops-btn-ghost shrink-0 text-[11px]" onClick={() => setIndexes(items => items.filter(item => item.id !== idx.id))}>删除</button>}
|
||||
</div>
|
||||
<div className="mt-2 flex min-h-7 flex-wrap items-center gap-1.5 pl-7">
|
||||
{idx.columns.map((name, j) => <button type="button" key={`${name}-${j}`} disabled={idx.readOnly} className="ops-btn-secondary h-6 px-2 text-[11px]" title="移除此字段"
|
||||
onClick={() => setIndexes(items => items.map(item => item.id === idx.id ? { ...item, columns: item.columns.filter((_, pos) => pos !== j) } : item))}>{j + 1}. {name} ×</button>)}
|
||||
{!idx.columns.length && <span className="text-[11px]" style={{ color: "var(--ops-muted)" }}>选择构成索引的字段</span>}
|
||||
</div>
|
||||
</div>)}
|
||||
{!indexes.length && <div className="rounded-md border px-3 py-8 text-center text-[12px]" style={{ borderColor: "var(--ops-border-soft)", color: "var(--ops-muted)" }}>暂无索引,点击右上角「添加索引」</div>}
|
||||
</div> : isNew ? <p className="ops-tree-meta-note">此驱动的图形索引编辑暂未接入;建表后可用 SQL 创建。</p>
|
||||
: <><p className="ops-tree-meta-note">此驱动索引只读,修改请使用 SQL 工作台。</p><MetadataView conn={conn} database={database} table={table} kind="indexes" /></>)}
|
||||
{(activeTab === "foreignKeys" || activeTab === "triggers") && (isNew
|
||||
? <p className="ops-tree-meta-note">建表后可查看{activeTab === "foreignKeys" ? "外键" : "触发器"};图形编辑尚未接入。</p>
|
||||
: <><p className="ops-tree-meta-note">只读查看;修改请使用 SQL 工作台并核对依赖。</p><MetadataView conn={conn} database={database} table={table} kind={activeTab} /></>)}
|
||||
{activeTab === "preview" && <div className="overflow-hidden rounded-md border" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
<div className="flex items-center justify-between border-b px-3 py-1.5 text-[11px]" style={{ borderColor: "var(--ops-border-soft)", color: "var(--ops-muted)" }}>
|
||||
<span>{allStatements.length ? `${allStatements.length} 条待执行语句` : "暂无待执行语句"}</span>
|
||||
<button type="button" className="ops-btn-ghost" disabled={!ddl || !!errors.length || !allStatements.length} onClick={() => void copyPreview()}>复制 SQL</button>
|
||||
</div>
|
||||
<pre className="m-0 max-h-[48vh] min-h-48 overflow-auto whitespace-pre-wrap px-3 py-2 font-mono"
|
||||
style={{ fontSize: "var(--ops-text-xs)", background: "var(--ops-editor-bg)" }}>
|
||||
{ddl || (errors.length ? "-- 请先修正上方错误" : "-- 没有可生成的 DDL")}
|
||||
</pre>
|
||||
</div>}
|
||||
{!!errors.length && <p role="alert" className="m-0 text-[11px]" style={{ color: "var(--ops-danger)" }}>{errors[0]}{errors.length > 1 ? ` 等 ${errors.length} 项` : ""}</p>}
|
||||
{unsupported && <p role="alert" className="text-[11px]" style={{ color: "var(--ops-warning)" }}>当前变更包含不支持的 DDL;不能只执行其中一部分。请调整字段或使用 SQL 工作台。</p>}
|
||||
</div>
|
||||
)}
|
||||
</OpsModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { execSql } from './api';
|
||||
import { ensureSession } from './ObjectTree';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
import { TreeChevron } from './TreeChevron';
|
||||
import { metadataGroups, tableMetadataSql } from './tableMetadata';
|
||||
import { useWorkspaceStore, type SavedConnection } from './store';
|
||||
|
||||
function MetadataGroup({ conn, database, table, kind, label, icon, color }: { conn: SavedConnection; database: string; table: string } & typeof metadataGroups[number]) {
|
||||
const [rows, setRows] = useState<{ name: string; detail: string }[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [more, setMore] = useState(false);
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; name?: string; detail?: string } | null>(null);
|
||||
const generation = useRef(0);
|
||||
const pending = useRef(false);
|
||||
useEffect(() => () => { generation.current++; }, []);
|
||||
const sql = tableMetadataSql(conn.driver, database, table, kind);
|
||||
const load = async () => {
|
||||
if (!sql || pending.current) return;
|
||||
const version = ++generation.current;
|
||||
pending.current = true;
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const sessionId = await ensureSession(conn);
|
||||
const result = await execSql(conn, sql, { database, sessionId, limit: 1000 });
|
||||
if (result.error) throw new Error(result.error);
|
||||
if (version === generation.current) {
|
||||
setRows(result.rows.map(r => ({ name: String(r.name ?? ''), detail: String(r.detail ?? '') })));
|
||||
setMore(!!result.truncated);
|
||||
}
|
||||
} catch (e) { if (version === generation.current) setError(e instanceof Error ? e.message : String(e)); }
|
||||
finally { if (version === generation.current) { pending.current = false; setLoading(false); } }
|
||||
};
|
||||
const copy = async (text: string) => {
|
||||
try { await navigator.clipboard.writeText(text); }
|
||||
catch (e) { useWorkspaceStore.getState().notifyError(e, 'toast'); }
|
||||
};
|
||||
return <details onToggle={e => { e.stopPropagation(); if (e.currentTarget.open && rows === null && !error) void load(); }}
|
||||
onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setMenu({ x: e.clientX, y: e.clientY }); }}>
|
||||
<summary className="ops-tree-item list-none [&::-webkit-details-marker]:hidden" title={sql ? label : '当前引擎尚未适配此元数据'}>
|
||||
<TreeChevron size={9} /><span aria-hidden="true" style={{ color, width: 13, textAlign: 'center' }}>{icon}</span>
|
||||
<span className="ops-tree-group">{label}</span><span className="text-[10px] opacity-50">{loading ? '…' : rows?.length ?? ''}</span>
|
||||
</summary>
|
||||
<div className="ml-2 border-l pl-1" style={{ borderColor: 'var(--ops-border-soft)' }}>
|
||||
{!sql && <div className="ops-tree-meta-note">当前引擎暂不支持</div>}
|
||||
{loading && <div className="ops-tree-meta-note">加载中…</div>}
|
||||
{error && <div className="ops-tree-meta-note" title={error}>加载失败 <button type="button" onClick={() => void load()}>重试</button></div>}
|
||||
{!loading && !error && rows?.length === 0 && <div className="ops-tree-meta-note">暂无{label}(当前权限可见范围)</div>}
|
||||
{!error && rows?.map(row => <button type="button" key={row.name} className="ops-tree-item w-full text-left" title={`${row.name}\n${row.detail}\n双击复制名称`}
|
||||
onDoubleClick={() => void copy(row.name)} onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setMenu({ x: e.clientX, y: e.clientY, ...row }); }}>
|
||||
<span className="truncate">{row.name}</span><span className="truncate text-[10px] opacity-50">{row.detail}</span>
|
||||
</button>)}
|
||||
{more && <div className="ops-tree-meta-note">仅展示前 1000 项,请使用目录查询查看全部</div>}
|
||||
</div>
|
||||
{menu && <ContextMenu {...menu} title={menu.name ?? label} onClose={() => setMenu(null)} items={[
|
||||
{ label: '复制名称', hidden: !menu.name, action: () => void copy(menu.name!) },
|
||||
{ label: '复制定义 / 详情', hidden: !menu.detail, action: () => void copy(menu.detail!) },
|
||||
{ label: '刷新', disabled: !sql || loading, action: () => void load() },
|
||||
]} />}
|
||||
</details>;
|
||||
}
|
||||
|
||||
export function TableMetadataTree(props: { conn: SavedConnection; database: string; table: string }) {
|
||||
return <>{metadataGroups.map(group => <MetadataGroup key={`${props.conn.id}/${props.database}/${props.table}/${group.kind}`} {...props} {...group} />)}</>;
|
||||
}
|
||||
+372
-500
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,17 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { aiEnterAction } from "./aiComposerKeys";
|
||||
|
||||
const enter = { key: "Enter", shiftKey: false, isComposing: false, keyCode: 13, repeat: false };
|
||||
|
||||
it("输入法确认候选词的回车不会发送,即使 WebKit 已发出 compositionend", () => {
|
||||
expect(aiEnterAction(enter, true, false)).toBe("ignore");
|
||||
expect(aiEnterAction({ ...enter, isComposing: true }, false, false)).toBe("ignore");
|
||||
expect(aiEnterAction({ ...enter, keyCode: 229 }, false, false)).toBe("ignore");
|
||||
expect(aiEnterAction(enter, false, true)).toBe("suppress");
|
||||
});
|
||||
|
||||
it("普通回车发送,Shift 回车换行,长按回车不重复发送", () => {
|
||||
expect(aiEnterAction(enter, false, false)).toBe("send");
|
||||
expect(aiEnterAction({ ...enter, shiftKey: true }, false, false)).toBe("newline");
|
||||
expect(aiEnterAction({ ...enter, repeat: true }, false, false)).toBe("suppress");
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
export type AiEnterAction = "ignore" | "suppress" | "newline" | "send";
|
||||
|
||||
/** WebKit 中候选词确认可能先触发 compositionend,随后才触发 Enter keydown。 */
|
||||
export function aiEnterAction(
|
||||
event: Pick<KeyboardEvent, "key" | "shiftKey" | "isComposing" | "keyCode" | "repeat">,
|
||||
composing: boolean,
|
||||
recentlyComposed: boolean,
|
||||
): AiEnterAction {
|
||||
if (event.key !== "Enter") return "ignore";
|
||||
if (composing || event.isComposing || event.keyCode === 229) return "ignore";
|
||||
if (event.shiftKey) return "newline";
|
||||
if (recentlyComposed || event.repeat) return "suppress";
|
||||
return "send";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AI_PRESETS, findAiPreset } from "./aiPresets";
|
||||
|
||||
describe("AI 预设表", () => {
|
||||
it("id 唯一且包含本地与自定义兜底", () => {
|
||||
const ids = AI_PRESETS.map((p) => p.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(findAiPreset("ollama")?.allowInsecureHttp).toBe(true);
|
||||
expect(findAiPreset("custom")?.baseUrl).toBe("");
|
||||
});
|
||||
|
||||
it("云端预设一律 https 且默认不放行 http", () => {
|
||||
for (const p of AI_PRESETS.filter((x) => x.id !== "ollama" && x.id !== "custom")) {
|
||||
expect(p.baseUrl.startsWith("https://"), p.id).toBe(true);
|
||||
expect(p.allowInsecureHttp, p.id).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("anthropic 协议与 x-api-key 配对,其余为 bearer/none", () => {
|
||||
for (const p of AI_PRESETS) {
|
||||
if (p.protocol === "anthropic") expect(p.authStyle, p.id).toBe("x-api-key");
|
||||
else expect(["bearer", "none"], p.id).toContain(p.authStyle);
|
||||
}
|
||||
});
|
||||
|
||||
it("除自定义外默认模型都在模型清单中", () => {
|
||||
for (const p of AI_PRESETS.filter((x) => x.id !== "custom")) {
|
||||
expect(p.models, p.id).toContain(p.defaultModel);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/** AI Provider 预设(对齐 opsxshell providerPresets,裁剪 family/billing 分层)。
|
||||
* protocol: openai-chat 覆盖绝大多数兼容服务;openai-responses 为 OpenAI 官方;anthropic 为 Claude 原生。 */
|
||||
|
||||
export type AiProtocol = "openai-chat" | "openai-responses" | "anthropic";
|
||||
export type AiAuthStyle = "bearer" | "x-api-key" | "none";
|
||||
|
||||
export interface AiPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
protocol: AiProtocol;
|
||||
authStyle: AiAuthStyle;
|
||||
baseUrl: string;
|
||||
allowInsecureHttp: boolean;
|
||||
models: string[];
|
||||
defaultModel: string;
|
||||
}
|
||||
|
||||
export const AI_PRESETS: AiPreset[] = [
|
||||
{
|
||||
id: "deepseek",
|
||||
name: "DeepSeek",
|
||||
desc: "官方 API · 按量",
|
||||
protocol: "openai-chat",
|
||||
authStyle: "bearer",
|
||||
baseUrl: "https://api.deepseek.com/v1",
|
||||
allowInsecureHttp: false,
|
||||
models: ["deepseek-v4-pro", "deepseek-flash"],
|
||||
defaultModel: "deepseek-v4-pro",
|
||||
},
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
desc: "官方 Responses API",
|
||||
protocol: "openai-responses",
|
||||
authStyle: "bearer",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
allowInsecureHttp: false,
|
||||
models: ["gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"],
|
||||
defaultModel: "gpt-6-astra",
|
||||
},
|
||||
{
|
||||
id: "qwen",
|
||||
name: "Qwen 通义",
|
||||
desc: "阿里云百炼 · OpenAI 兼容",
|
||||
protocol: "openai-chat",
|
||||
authStyle: "bearer",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
allowInsecureHttp: false,
|
||||
models: ["qwen3.8-max", "qwen3.8-flash", "qwen3.7-plus"],
|
||||
defaultModel: "qwen3.8-max",
|
||||
},
|
||||
{
|
||||
id: "kimi",
|
||||
name: "Kimi",
|
||||
desc: "Moonshot",
|
||||
protocol: "openai-chat",
|
||||
authStyle: "bearer",
|
||||
baseUrl: "https://api.moonshot.cn/v1",
|
||||
allowInsecureHttp: false,
|
||||
models: ["kimi-k3", "kimi-k2.7-code", "kimi-k2.6"],
|
||||
defaultModel: "kimi-k3",
|
||||
},
|
||||
{
|
||||
id: "glm",
|
||||
name: "GLM 智谱",
|
||||
desc: "开放平台",
|
||||
protocol: "openai-chat",
|
||||
authStyle: "bearer",
|
||||
baseUrl: "https://open.bigmodel.cn/api/paas/v4",
|
||||
allowInsecureHttp: false,
|
||||
models: ["glm-5.3", "glm-5.2", "glm-5.1"],
|
||||
defaultModel: "glm-5.3",
|
||||
},
|
||||
{
|
||||
id: "minimax",
|
||||
name: "MiniMax",
|
||||
desc: "按量",
|
||||
protocol: "openai-chat",
|
||||
authStyle: "bearer",
|
||||
baseUrl: "https://api.minimax.io/v1",
|
||||
allowInsecureHttp: false,
|
||||
models: ["MiniMax-M3", "MiniMax-M2.7"],
|
||||
defaultModel: "MiniMax-M3",
|
||||
},
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
desc: "Claude 原生协议",
|
||||
protocol: "anthropic",
|
||||
authStyle: "x-api-key",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
allowInsecureHttp: false,
|
||||
models: ["claude-fable-5-1", "claude-sonnet-5", "claude-haiku-4-5"],
|
||||
defaultModel: "claude-sonnet-5",
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
name: "Google Gemini",
|
||||
desc: "OpenAI 兼容端点",
|
||||
protocol: "openai-chat",
|
||||
authStyle: "bearer",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
allowInsecureHttp: false,
|
||||
models: ["gemini-3.1-pro-preview", "gemini-3.6-flash"],
|
||||
defaultModel: "gemini-3.1-pro-preview",
|
||||
},
|
||||
{
|
||||
id: "ollama",
|
||||
name: "Ollama",
|
||||
desc: "本机私有化 · 免鉴权 HTTP",
|
||||
protocol: "openai-chat",
|
||||
authStyle: "none",
|
||||
baseUrl: "http://127.0.0.1:11434/v1",
|
||||
allowInsecureHttp: true,
|
||||
models: ["qwen3:latest", "deepseek-r1:latest"],
|
||||
defaultModel: "qwen3:latest",
|
||||
},
|
||||
{
|
||||
id: "custom",
|
||||
name: "自定义",
|
||||
desc: "自填 Base URL(内网网关 / vLLM 等)",
|
||||
protocol: "openai-chat",
|
||||
authStyle: "bearer",
|
||||
baseUrl: "",
|
||||
allowInsecureHttp: false,
|
||||
models: [],
|
||||
defaultModel: "",
|
||||
},
|
||||
];
|
||||
|
||||
export function findAiPreset(id: string): AiPreset | undefined {
|
||||
return AI_PRESETS.find((p) => p.id === id);
|
||||
}
|
||||
+29
-12
@@ -838,17 +838,32 @@ export async function getAiConfig(signal?: AbortSignal) {
|
||||
}
|
||||
|
||||
|
||||
/** 桌面连接双写到 ~/.opsxdb/connections.json,供 MCP 进程读取(P0)。失败不抛,避免挡保存。 */
|
||||
export async function syncConnectionsToDisk(connections: SavedConnection[]): Promise<void> {
|
||||
try {
|
||||
await fetch(`${API}/api/db/connections-store`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify({ connections }),
|
||||
});
|
||||
} catch {
|
||||
/* 后端未起时忽略 */
|
||||
}
|
||||
let connectionSyncQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
/** 按顺序同步桌面连接;Agent 必须等待成功,避免旧快照覆盖最新连接。 */
|
||||
export function syncConnectionsToDisk(connections: SavedConnection[]): Promise<void> {
|
||||
const body = JSON.stringify({ connections });
|
||||
const sync = connectionSyncQueue.catch(() => {}).then(async () => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 10000);
|
||||
try {
|
||||
const res = await fetch(`${API}/api/db/connections-store`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`本机服务返回 ${res.status}`);
|
||||
const result = await res.json();
|
||||
if (result?.ok !== true) throw new Error("本机服务未确认连接已保存");
|
||||
} catch (e) {
|
||||
throw new Error(`连接同步失败,Agent 尚未执行任务:${controller.signal.aborted ? "本机服务响应超时" : e instanceof Error ? e.message : String(e)}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
connectionSyncQueue = sync;
|
||||
return sync;
|
||||
}
|
||||
|
||||
export async function loadConnectionsFromDisk(): Promise<SavedConnection[] | null> {
|
||||
@@ -914,7 +929,9 @@ export async function driverStatus() {
|
||||
const res = await fetch(`${API}/api/drivers/status`);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error((data as { error?: string }).error || res.statusText);
|
||||
return (data as { drivers: DriverStatusItem[] }).drivers;
|
||||
const drivers = (data as { drivers?: DriverStatusItem[] }).drivers;
|
||||
if (!Array.isArray(drivers)) throw new Error("本机服务未提供驱动状态,请重启桌面应用后重试");
|
||||
return drivers;
|
||||
}
|
||||
|
||||
export async function driverInstall(driver: string) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("桌面后端引导", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("使用 sidecar 注入的动态地址和令牌,不回退到 8788", async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ service: "opsxdb", version: "0.1.0" }),
|
||||
});
|
||||
vi.stubGlobal("window", {
|
||||
__OPSXDB_API__: "http://127.0.0.1:49123",
|
||||
__OPSXDB_TOKEN__: "local-test-token",
|
||||
location: { protocol: "tauri:", hostname: "tauri.localhost", port: "" },
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
vi.resetModules();
|
||||
const { health } = await import("./api");
|
||||
await health();
|
||||
expect(fetcher).toHaveBeenCalledWith("http://127.0.0.1:49123/api/health", {
|
||||
headers: { Authorization: "Bearer local-test-token" },
|
||||
});
|
||||
});
|
||||
|
||||
it("其他项目占用目标地址时拒绝将其当作 OpsxDB", async () => {
|
||||
vi.stubGlobal("window", {
|
||||
__OPSXDB_API__: "http://127.0.0.1:8788",
|
||||
location: { protocol: "tauri:", hostname: "tauri.localhost", port: "" },
|
||||
});
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ service: "opsxapi" }),
|
||||
}));
|
||||
vi.resetModules();
|
||||
const { health } = await import("./api");
|
||||
await expect(health()).rejects.toThrow("不是 OpsxDB 后端");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SavedConnection } from "./store";
|
||||
|
||||
const connection = { id: "redis-local", name: "Redis", driver: "redis", host: "127.0.0.1", port: 6379 } as SavedConnection;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("Agent 连接同步", () => {
|
||||
it("服务端拒绝同步时报告失败,后续可以重试", async () => {
|
||||
const fetcher = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: false, status: 500 })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ ok: true }) });
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
const { syncConnectionsToDisk } = await import("./api");
|
||||
await expect(syncConnectionsToDisk([connection])).rejects.toThrow("连接同步失败");
|
||||
await expect(syncConnectionsToDisk([connection])).resolves.toBeUndefined();
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("先完成旧快照再发送新快照,等待最新连接实际写入", async () => {
|
||||
let finishFirst!: (response: unknown) => void;
|
||||
const fetcher = vi.fn()
|
||||
.mockImplementationOnce(() => new Promise((resolve) => { finishFirst = resolve; }))
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ ok: true }) });
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
const { syncConnectionsToDisk } = await import("./api");
|
||||
const oldSync = syncConnectionsToDisk([]);
|
||||
const newSync = syncConnectionsToDisk([connection]);
|
||||
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
|
||||
finishFirst({ ok: true, json: async () => ({ ok: true }) });
|
||||
await Promise.all([oldSync, newSync]);
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
expect(JSON.parse(fetcher.mock.calls[1][1].body).connections).toEqual([connection]);
|
||||
});
|
||||
|
||||
it("网络失败不会被当作同步成功", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Load failed")));
|
||||
const { syncConnectionsToDisk } = await import("./api");
|
||||
await expect(syncConnectionsToDisk([connection])).rejects.toThrow("Agent 尚未执行任务");
|
||||
});
|
||||
|
||||
it("HTTP 成功但没有保存确认时仍然阻止 Agent", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }));
|
||||
const { syncConnectionsToDisk } = await import("./api");
|
||||
await expect(syncConnectionsToDisk([connection])).rejects.toThrow("未确认连接已保存");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { connectionEndpoint, connectionSummary, connectionUri, identifierAt, selectionTSV } from "./contextActions";
|
||||
import { relationParts, rowsToInsert } from "./sql";
|
||||
|
||||
describe("右键复制与对象识别边界", () => {
|
||||
it("复制连接信息不包含密码、用户和 SSH 凭据", () => {
|
||||
const c = { driver: "mysql" as const, host: "localhost", port: 3306, database: "订单 库", user: "private-user", password: "private-password", sshPrivateKey: "private-key" };
|
||||
expect(connectionUri(c)).toBe("mysql://localhost:3306/%E8%AE%A2%E5%8D%95%20%E5%BA%93");
|
||||
expect(connectionSummary(c)).toBe("mysql · localhost:3306 · 订单 库");
|
||||
expect(connectionUri(c) + connectionSummary(c)).not.toContain("private");
|
||||
});
|
||||
it("IPv6 主机端口不产生歧义", () => {
|
||||
expect(connectionEndpoint({ host: "::1", port: 5432 })).toBe("[::1]:5432");
|
||||
expect(connectionEndpoint({ host: "[::1]", port: 5432 })).toBe("[::1]:5432");
|
||||
});
|
||||
it("Redis 集群与 Sentinel 展示种子节点,不展示单机默认 6379", () => {
|
||||
const cluster = { driver: "redis" as const, redisMode: "cluster" as const, redisNodes: "127.0.0.1:7101\n127.0.0.1:7102", host: "127.0.0.1", port: 6379, database: "0" };
|
||||
expect(connectionEndpoint(cluster)).toBe("127.0.0.1:7101");
|
||||
expect(connectionUri(cluster)).toBe("redis+cluster://127.0.0.1:7101/0");
|
||||
expect(connectionEndpoint({ ...cluster, redisMode: "sentinel", redisNodes: "127.0.0.1:26379" })).toBe("127.0.0.1:26379");
|
||||
});
|
||||
it("TSV 保留 NULL、对象、制表符和换行", () => {
|
||||
expect(selectionTSV(["a", "b", "c"], [{ a: null, b: "a\tb\n\"c\"", c: { ok: true } }]))
|
||||
.toBe('NULL\t"a\tb\n""c"""\t"{""ok"":true}"');
|
||||
expect(selectionTSV(["a"], [{ a: "" }])).toBe("");
|
||||
});
|
||||
it("识别带引号、空格和转义的限定名", () => {
|
||||
const text = 'SELECT * FROM "my schema"."ord""ers"';
|
||||
expect(identifierAt(text, text.indexOf("ord"))).toEqual(["my schema", 'ord"ers']);
|
||||
expect(identifierAt("SELECT * FROM [dbo].[a]]b]", 23)).toEqual(["dbo", "a]b"]);
|
||||
expect(identifierAt("SELECT * FROM `my db`.`orders`", 25)).toEqual(["my db", "orders"]);
|
||||
});
|
||||
it("字符串、注释和空白不误判为表名", () => {
|
||||
for (const text of ["SELECT 'orders'", "-- orders", "/* orders */ SELECT 1"]) {
|
||||
expect(identifierAt(text, text.indexOf("orders"))).toBeNull();
|
||||
}
|
||||
expect(identifierAt("SELECT * FROM orders", 7)).toBeNull();
|
||||
});
|
||||
it("PG 使用 schema,不误把数据库名前缀拼进 SQL", () => {
|
||||
expect(relationParts("postgresql", "production", "orders")).toEqual(["public", "orders"]);
|
||||
expect(relationParts("postgresql", "production", "sales.orders")).toEqual(["sales", "orders"]);
|
||||
expect(relationParts("sqlserver", "production", "orders")).toEqual(["dbo", "orders"]);
|
||||
});
|
||||
it("MySQL 表名里的点与反引号保持为标识符内容", () => {
|
||||
const parts = relationParts("mysql", "a`b", "orders.archive");
|
||||
expect(rowsToInsert("mysql", parts, ["name"], [{ name: "O'Reilly" }]))
|
||||
.toBe("INSERT INTO `a``b`.`orders.archive` (`name`) VALUES ('O''Reilly');");
|
||||
});
|
||||
it("INSERT 中空字符串与 NULL 不混淆,对象使用 JSON", () => {
|
||||
expect(rowsToInsert("postgresql", ["public", "t"], ["a", "b", "c"], [{ a: "", b: null, c: { x: 1 } }]))
|
||||
.toBe('INSERT INTO "public"."t" ("a", "b", "c") VALUES (\'\', NULL, \'{"x":1}\');');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { SavedConnection } from "./store";
|
||||
|
||||
/** 只从明确允许的字段重建,绝不序列化连接对象或原始 DSN。 */
|
||||
export function connectionSummary(c: Pick<SavedConnection, "driver" | "host" | "port" | "database">, database = c.database): string {
|
||||
return `${c.driver} · ${connectionEndpoint(c)} · ${database || "未指定数据库"}`;
|
||||
}
|
||||
|
||||
export function connectionEndpoint(c: Pick<SavedConnection, "host" | "port"> & Partial<Pick<SavedConnection, "driver" | "redisMode" | "redisNodes">>): string {
|
||||
if (c.driver === "redis" && (c.redisMode === "cluster" || c.redisMode === "sentinel")) {
|
||||
const seed = c.redisNodes?.split(/[\s,;]+/).find(Boolean);
|
||||
if (seed) return seed;
|
||||
}
|
||||
const host = c.host.includes(":") && !c.host.startsWith("[") ? `[${c.host}]` : c.host;
|
||||
return c.port ? `${host}:${c.port}` : host;
|
||||
}
|
||||
|
||||
export function connectionUri(c: Pick<SavedConnection, "driver" | "host" | "port" | "database"> & Partial<Pick<SavedConnection, "redisMode" | "redisNodes">>): string {
|
||||
if (c.driver === "sqlite") return `sqlite://${encodeURI(c.database)}`;
|
||||
if (c.driver === "redis" && (c.redisMode === "cluster" || c.redisMode === "sentinel")) {
|
||||
return `redis+${c.redisMode}://${connectionEndpoint(c)}${c.redisMode === "cluster" ? "/0" : `/${encodeURIComponent(c.database)}`}`;
|
||||
}
|
||||
return `${c.driver}://${connectionEndpoint(c)}/${encodeURIComponent(c.database)}`;
|
||||
}
|
||||
|
||||
export function cellText(value: unknown): string {
|
||||
return value == null ? "NULL" : typeof value === "object" ? JSON.stringify(value) : String(value);
|
||||
}
|
||||
|
||||
/** TSV 保留 NULL 和对象含义,并对制表符/换行进行可逆的标准字段引用。 */
|
||||
export function selectionTSV(columns: string[], rows: Record<string, unknown>[]): string {
|
||||
return rows.map((row) => columns.map((col) => {
|
||||
const value = cellText(row[col]);
|
||||
return /[\t\r\n"]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
||||
}).join("\t")).join("\n");
|
||||
}
|
||||
|
||||
/** 仅识别明确的标识符;跳过字符串和注释,不猜测别名/CTE。 */
|
||||
export function identifierAt(text: string, pos: number): string[] | null {
|
||||
const ident = '(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|\\[(?:[^\\]]|\\]\\])+\\]|[\\p{L}_][\\p{L}\\p{N}_$]*)';
|
||||
const tokens = new RegExp(`--[^\\n]*|/\\*[\\s\\S]*?\\*/|'(?:[^']|'')*'|${ident}(?:\\s*\\.\\s*${ident})*`, "gu");
|
||||
for (const match of text.matchAll(tokens)) {
|
||||
const start = match.index!;
|
||||
if (pos < start || pos >= start + match[0].length) continue;
|
||||
if (/^(--|\/\*|')/.test(match[0])) return null;
|
||||
return Array.from(match[0].matchAll(new RegExp(ident, "gu")), (part) => {
|
||||
const s = part[0];
|
||||
if (s.startsWith('"')) return s.slice(1, -1).replace(/""/g, '"');
|
||||
if (s.startsWith('`')) return s.slice(1, -1).replace(/``/g, '`');
|
||||
if (s.startsWith('[')) return s.slice(1, -1).replace(/\]\]/g, ']');
|
||||
return s;
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Vite-bundled driver icons — works in Tauri/web without depending on /public path. */
|
||||
const modules = import.meta.glob("../assets/databases/*.{svg,png}", {
|
||||
const modules = import.meta.glob("./assets/databases/*.{svg,png}", {
|
||||
eager: true,
|
||||
query: "?url",
|
||||
import: "default",
|
||||
|
||||
+21
-6
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { OpsxWorkspace } from "@opsxdb/ui";
|
||||
import "./styles.css";
|
||||
|
||||
const root = document.getElementById("root")!;
|
||||
@@ -37,9 +36,25 @@ if (!isTauri && import.meta.env.PROD) {
|
||||
</div>,
|
||||
);
|
||||
} else {
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<OpsxWorkspace />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
// api.ts 在首次加载时确定 API 地址。先取得 Tauri sidecar 的动态端口与
|
||||
// token,再加载工作台,避免把自启动后端误判为固定的 8788。
|
||||
void (async () => {
|
||||
if (isTauri) {
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const cfg = await invoke<{ addr: string; token: string }>("get_backend_config");
|
||||
window.__OPSXDB_API__ = cfg.addr;
|
||||
window.__OPSXDB_TOKEN__ = cfg.token;
|
||||
} catch (error) {
|
||||
// 仍渲染工作台的重试/诊断入口;sidecar 启动失败时可能没有配置。
|
||||
window.__OPSXDB_BOOT_ERROR__ = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
const { OpsxWorkspace } = await import("@opsxdb/ui");
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<OpsxWorkspace />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
|
||||
const previous = useWorkspaceStore.getState();
|
||||
afterEach(() => useWorkspaceStore.setState(previous, true));
|
||||
|
||||
it("常规成功反馈静默,任务完成与警告仍可见", () => {
|
||||
useWorkspaceStore.setState({ notifyToasts: [] });
|
||||
const state = useWorkspaceStore.getState();
|
||||
state.notify({ kind: "success", title: "已复制", message: "SQL 已复制", mode: "toast" });
|
||||
state.notify({ kind: "success", title: "已格式化", message: "可撤销", mode: "toast" });
|
||||
expect(useWorkspaceStore.getState().notifyToasts).toHaveLength(0);
|
||||
|
||||
state.notify({ kind: "success", title: "导出完成", message: "结果.csv", mode: "toast", feedback: "visible" });
|
||||
state.notify({ kind: "warning", title: "行数无效", message: "请输入整数", mode: "toast" });
|
||||
expect(useWorkspaceStore.getState().notifyToasts.map(n => n.title)).toEqual(["导出完成", "行数无效"]);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/** 外部链接统一打开:桌面走 opener 插件(Tauri 2 拦截 <a target=_blank>),失败再 window.open */
|
||||
export async function openExternal(href: string): Promise<void> {
|
||||
try {
|
||||
const { openUrl } = await import("@tauri-apps/plugin-opener");
|
||||
await openUrl(href);
|
||||
} catch {
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { beforeAll, beforeEach, afterAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("查询标签的数据库上下文", () => {
|
||||
let store: typeof import("./store").useWorkspaceStore;
|
||||
const saved = new Map<string, string>();
|
||||
const c = { id: "conn", name: "test", driver: "mysql" as const, host: "localhost", port: 3306, user: "", password: "", database: "default_db" };
|
||||
beforeAll(async () => {
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (key: string) => saved.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => saved.set(key, value),
|
||||
removeItem: (key: string) => saved.delete(key),
|
||||
});
|
||||
store = (await import("./store")).useWorkspaceStore;
|
||||
});
|
||||
afterAll(() => vi.unstubAllGlobals());
|
||||
beforeEach(() => {
|
||||
saved.clear();
|
||||
store.setState({ connections: [c], activeConnectionId: c.id, tabs: [], activeTabId: "" });
|
||||
});
|
||||
it("库右键新查询绑定所点库,连接默认库变化不影响已有标签", () => {
|
||||
store.getState().newQueryTab("clicked_db");
|
||||
store.setState({ connections: [{ ...c, database: "other_db" }] });
|
||||
expect(store.getState().tabs[0]).toMatchObject({ connectionId: c.id, database: "clicked_db" });
|
||||
expect([...saved.values()].some((value) => value.includes('"database":"clicked_db"'))).toBe(true);
|
||||
});
|
||||
it("普通新查询继承连接默认库,不继承其他标签的库", () => {
|
||||
store.getState().newQueryTab("clicked_db");
|
||||
store.getState().newQueryTab();
|
||||
expect(store.getState().tabs.at(-1)?.database).toBe("default_db");
|
||||
expect(store.getState().tabs[0].database).toBe("clicked_db");
|
||||
});
|
||||
it("Redis 前缀搜索不带到另一连接,重选当前连接保留编辑中的过滤词", () => {
|
||||
const redis = { ...c, id: "redis-search", driver: "redis" as const, database: "0" };
|
||||
store.setState({ connections: [c, redis], activeConnectionId: redis.id, sidebarFilter: "decoy:*" });
|
||||
store.getState().selectConnection(redis.id);
|
||||
expect(store.getState().sidebarFilter).toBe("decoy:*");
|
||||
store.getState().selectConnection(c.id);
|
||||
expect(store.getState().sidebarFilter).toBe("");
|
||||
store.getState().set("sidebarFilter", "users");
|
||||
store.getState().set("activeConnectionId", redis.id);
|
||||
expect(store.getState().sidebarFilter).toBe("");
|
||||
});
|
||||
it("USE 执行期间切到另一标签时只修改发起执行的标签", () => {
|
||||
store.getState().newQueryTab("first_db");
|
||||
const original = store.getState().activeTabId;
|
||||
store.getState().newQueryTab("second_db");
|
||||
store.getState().setTabDatabase("third_db", original);
|
||||
expect(store.getState().tabs.find(t => t.id === original)?.database).toBe("third_db");
|
||||
expect(store.getState().tabs.find(t => t.id === store.getState().activeTabId)?.database).toBe("second_db");
|
||||
expect(saved.get("opsxdb.tabs.v1")).toContain('"database":"third_db"');
|
||||
});
|
||||
it("新建查询替换空白启动占位;恢复旧标签时清掉无内容的查询 1", () => {
|
||||
const placeholder = { id: "placeholder", title: "查询 1", sql: "", savedSql: "", dirty: false };
|
||||
store.setState({ tabs: [placeholder], activeTabId: placeholder.id });
|
||||
store.getState().newQueryTab();
|
||||
expect(store.getState().tabs).toHaveLength(1);
|
||||
expect(store.getState().tabs[0]).toMatchObject({ connectionId: c.id, database: c.database });
|
||||
|
||||
const real = store.getState().tabs[0];
|
||||
saved.set("opsxdb.tabs.v1", JSON.stringify({ tabs: [placeholder, real], activeTabId: placeholder.id }));
|
||||
saved.set("opsxdb.connections.v1", JSON.stringify([c]));
|
||||
store.getState().loadConnections();
|
||||
expect(store.getState().tabs.map(t => t.id)).toEqual([real.id]);
|
||||
expect(store.getState().activeTabId).toBe(real.id);
|
||||
});
|
||||
it("更新旧结果集不覆盖当前结果,切回后可看到更新", () => {
|
||||
const first = { columns: ["id"], rows: [{ id: 1 }], rowCount: 1, timeMs: 0 };
|
||||
const second = { ...first, rows: [{ id: 2 }] };
|
||||
store.getState().setResultSets([
|
||||
{ id: "a", label: "a", sql: "SELECT 1", result: first },
|
||||
{ id: "b", label: "b", sql: "SELECT 2", result: second },
|
||||
]);
|
||||
store.getState().selectResultSet("b");
|
||||
const updated = { ...first, rows: [{ id: 3 }] };
|
||||
store.getState().updateResultSet("a", { result: updated });
|
||||
expect(store.getState().result).toEqual(second);
|
||||
store.getState().selectResultSet("a");
|
||||
expect(store.getState().result).toEqual(updated);
|
||||
});
|
||||
it("AI 晚到的 SQL 只修改原标签并标为未保存;切库或编辑后不覆盖", () => {
|
||||
store.getState().newQueryTab("first_db");
|
||||
const origin = store.getState().tabs[0];
|
||||
store.getState().newQueryTab("second_db");
|
||||
const update = store.getState().updateTabSqlIfUnchanged;
|
||||
expect(update(origin.id, origin.sql, origin.connectionId, origin.database, "SELECT 1")).toBe(true);
|
||||
expect(store.getState().tabs[0]).toMatchObject({ sql: "SELECT 1", dirty: true });
|
||||
expect(store.getState().tabs[1].sql).toBe("");
|
||||
expect(saved.get("opsxdb.tabs.v1")).toContain('"sql":"SELECT 1"');
|
||||
expect(update(origin.id, origin.sql, origin.connectionId, origin.database, "SELECT 2")).toBe(false);
|
||||
store.setState({ tabs: store.getState().tabs.map(t => t.id === origin.id ? { ...t, database: "changed_db" } : t) });
|
||||
expect(update(origin.id, "SELECT 1", origin.connectionId, origin.database, "SELECT 3")).toBe(false);
|
||||
});
|
||||
it("MySQL 转 Redis 选中对应查询,不把旧库和 SQL 带给新连接", () => {
|
||||
store.getState().newQueryTab("opsx_edi");
|
||||
store.getState().updateActiveSql("SELECT * FROM orders");
|
||||
const mysqlId = store.getState().activeTabId;
|
||||
const redis = { ...c, id: "redis", driver: "redis" as const, database: "0", redisMode: "standalone" as const };
|
||||
store.setState({ connections: [c, redis] });
|
||||
store.getState().selectConnection(redis.id);
|
||||
expect(store.getState().tabs.find(t => t.id === store.getState().activeTabId)).toMatchObject({ connectionId: redis.id, database: "0", sql: "" });
|
||||
store.getState().setTabDatabase("1");
|
||||
store.getState().selectConnection(c.id);
|
||||
expect(store.getState().activeTabId).toBe(mysqlId);
|
||||
expect(store.getState().tabs.find(t => t.id === mysqlId)).toMatchObject({ sql: "SELECT * FROM orders", database: "opsx_edi" });
|
||||
store.getState().selectConnection(redis.id);
|
||||
expect(store.getState().tabs.find(t => t.id === store.getState().activeTabId)?.database).toBe("1");
|
||||
});
|
||||
it("集群旧库名与旧标签库号统一归到 DB0,单机无效库号也归到 DB0", () => {
|
||||
const cluster = { ...c, id: "cluster", driver: "redis" as const, database: "opsx_edi", redisMode: "cluster" as const };
|
||||
store.setState({ connections: [c, cluster] });
|
||||
store.getState().selectConnection(cluster.id);
|
||||
const id = store.getState().activeTabId;
|
||||
expect(store.getState().tabs.find(t => t.id === id)?.database).toBe("0");
|
||||
store.getState().setTabDatabase("1");
|
||||
expect(store.getState().tabs.find(t => t.id === id)?.database).toBe("0");
|
||||
store.setState({ tabs: store.getState().tabs.map(t => t.id === id ? { ...t, database: "opsx_edi" } : t) });
|
||||
store.getState().selectConnection(c.id);
|
||||
store.getState().selectConnection(cluster.id);
|
||||
expect(store.getState().tabs.find(t => t.id === id)?.database).toBe("0");
|
||||
expect(saved.get("opsxdb.tabs.v1")).toContain('"database":"0"');
|
||||
store.getState().newQueryTab("15");
|
||||
expect(store.getState().tabs.at(-1)?.database).toBe("0");
|
||||
store.setState({ connections: [c, { ...cluster, redisMode: "standalone" }] });
|
||||
store.getState().newQueryTab("16");
|
||||
expect(store.getState().tabs.at(-1)?.database).toBe("0");
|
||||
store.getState().setTabDatabase("2");
|
||||
expect(store.getState().tabs.at(-1)?.database).toBe("2");
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,20 @@ describe("buildRedisKeyTree", () => {
|
||||
expect(tree.map((n) => n.name)).toEqual(["alpha", "beta"]);
|
||||
});
|
||||
|
||||
it("exact 命中置顶到根层", () => {
|
||||
const tree = buildRedisKeyTree(
|
||||
[
|
||||
{ name: "USER:GATEWAY:NO:AUTH:SET", type: "set", ttl: -1, exact: true },
|
||||
{ name: "USER:GATEWAY:OTHER", type: "string", ttl: -1 },
|
||||
],
|
||||
":",
|
||||
);
|
||||
expect(tree[0]?.kind).toBe("key");
|
||||
expect(tree[0]?.name).toBe("USER:GATEWAY:NO:AUTH:SET");
|
||||
expect(tree[0]?.exact).toBe(true);
|
||||
expect(tree.some((n) => n.kind === "folder" && n.name === "USER")).toBe(true);
|
||||
});
|
||||
|
||||
it("删除键后收起空文件夹", () => {
|
||||
const tree = buildRedisKeyTree(
|
||||
[
|
||||
@@ -96,6 +110,9 @@ describe("misc", () => {
|
||||
});
|
||||
it("JSON 美化", () => {
|
||||
expect(tryPrettyJson('{"a":1}')).toBe('{\n "a": 1\n}');
|
||||
expect(tryPrettyJson("null")).toBe("null");
|
||||
expect(tryPrettyJson("42")).toBe("42");
|
||||
expect(tryPrettyJson("true")).toBe("true");
|
||||
expect(tryPrettyJson("not-json")).toBeNull();
|
||||
});
|
||||
it("leaf label", () => {
|
||||
|
||||
+17
-5
@@ -1,6 +1,6 @@
|
||||
import type { TreeNode } from "./store";
|
||||
|
||||
export type RedisKeyMeta = { name: string; type: string; ttl: number };
|
||||
export type RedisKeyMeta = { name: string; type: string; ttl: number; exact?: boolean };
|
||||
|
||||
/** 类型点颜色,对齐常见 Redis GUI */
|
||||
export function redisTypeColor(type?: string): string {
|
||||
@@ -73,11 +73,22 @@ function keyCount(n: TreeNode): number {
|
||||
return n.count ?? (n.children ?? []).reduce((a, c) => a + keyCount(c), 0);
|
||||
}
|
||||
|
||||
/** 按分隔符把 SCAN 结果编成文件夹树(默认 `:`,对标 Another Redis Desktop Manager)。 */
|
||||
/** 按分隔符把 SCAN 结果编成文件夹树(默认 `:`,对标 Another Redis Desktop Manager)。
|
||||
* 精确命中(exact)提到根层置顶,避免埋在深层文件夹里看不见。 */
|
||||
export function buildRedisKeyTree(keys: RedisKeyMeta[], separator = ":"): TreeNode[] {
|
||||
const sep = separator || ":";
|
||||
const exactNodes: TreeNode[] = keys
|
||||
.filter((k) => k.exact)
|
||||
.map((k) => ({
|
||||
name: k.name,
|
||||
kind: "key" as const,
|
||||
type: k.type,
|
||||
ttl: k.ttl,
|
||||
exact: true,
|
||||
}));
|
||||
const rest = keys.filter((k) => !k.exact);
|
||||
const root: Bucket = { folders: new Map(), keys: [] };
|
||||
for (const k of keys) {
|
||||
for (const k of rest) {
|
||||
const parts = k.name.split(sep);
|
||||
if (parts.length <= 1) {
|
||||
root.keys.push(k);
|
||||
@@ -95,7 +106,7 @@ export function buildRedisKeyTree(keys: RedisKeyMeta[], separator = ":"): TreeNo
|
||||
}
|
||||
cur.keys.push(k);
|
||||
}
|
||||
return bucketToNodes(root, "", sep);
|
||||
return [...exactNodes, ...bucketToNodes(root, "", sep)];
|
||||
}
|
||||
|
||||
function bucketToNodes(b: Bucket, prefix: string, sep: string): TreeNode[] {
|
||||
@@ -121,6 +132,7 @@ function bucketToNodes(b: Bucket, prefix: string, sep: string): TreeNode[] {
|
||||
kind: "key" as const,
|
||||
type: k.type,
|
||||
ttl: k.ttl,
|
||||
exact: k.exact,
|
||||
}));
|
||||
return [...folders, ...keys];
|
||||
}
|
||||
@@ -192,7 +204,7 @@ export function tryPrettyJson(raw: unknown): string | null {
|
||||
}
|
||||
}
|
||||
const s = String(raw).trim();
|
||||
if (!s || (s[0] !== "{" && s[0] !== "[" && s[0] !== '"')) return null;
|
||||
if (!s) return null;
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(s), null, 2);
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { editorDatabase, isRedisCluster } from "./redisContext";
|
||||
import type { QueryTab, SavedConnection } from "./store";
|
||||
|
||||
const redis = { id: "redis", driver: "redis", database: "1" } as SavedConnection;
|
||||
const mysql = { id: "mysql", driver: "mysql", database: "opsx_edi" } as SavedConnection;
|
||||
const tab = (connectionId: string, database: string) => ({ id: "query", title: "查询", sql: "", connectionId, database } as QueryTab);
|
||||
|
||||
describe("编辑器与 AI 的当前库", () => {
|
||||
it("Redis 不沿用旧 MySQL 标签的库名", () => {
|
||||
expect(editorDatabase(redis, tab("mysql", "opsx_edi"))).toBe("1");
|
||||
expect(editorDatabase(redis, tab("redis", "0"))).toBe("0");
|
||||
expect(editorDatabase(mysql, tab("redis", "1"))).toBe("opsx_edi");
|
||||
});
|
||||
it("Cluster 始终使用 DB0,非法 Redis 库号回退 DB0", () => {
|
||||
expect(editorDatabase({ ...redis, redisMode: "cluster" }, tab("redis", "1"))).toBe("0");
|
||||
expect(editorDatabase(redis, tab("redis", "opsx_edi"))).toBe("0");
|
||||
const inferred = { ...redis, redisNodes: "127.0.0.1:7001\n127.0.0.1:7002" };
|
||||
expect(isRedisCluster(inferred)).toBe(true);
|
||||
expect(editorDatabase(inferred, tab("redis", "1"))).toBe("0");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { QueryTab, SavedConnection } from "./store";
|
||||
|
||||
/** 与后端默认拓扑一致:未指定模式且配置了多个种子节点时按集群处理。 */
|
||||
export function isRedisCluster(conn: SavedConnection): boolean {
|
||||
return conn.redisMode === "cluster" || !conn.redisMode && (conn.redisNodes?.trim().split(/[\s,;]+/).filter(Boolean).length ?? 0) > 1;
|
||||
}
|
||||
|
||||
/** 标签只在绑定当前连接时提供库上下文;Cluster 始终只有 DB0。 */
|
||||
export function editorDatabase(conn?: SavedConnection, tab?: QueryTab): string {
|
||||
if (!conn) return "";
|
||||
const redis = conn.driver.toLowerCase() === "redis";
|
||||
if (redis && isRedisCluster(conn)) return "0";
|
||||
const candidate = tab?.connectionId === conn.id ? tab.database : undefined;
|
||||
if (!redis) return candidate || conn.database || "";
|
||||
return normalizeRedisDatabase(candidate ?? conn.database, conn);
|
||||
}
|
||||
|
||||
export function normalizeRedisDatabase(database: string | undefined, conn: SavedConnection): string {
|
||||
if (isRedisCluster(conn)) return "0";
|
||||
const value = database || "0";
|
||||
return /^\d+$/.test(value) && Number(value) <= 15 ? String(Number(value)) : "0";
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cellPreview, compareCells, filterRows, suggestedColumnWidth } from "./resultView";
|
||||
|
||||
describe("结果展示边界", () => {
|
||||
it("大整数文本排序不丢精度", () => {
|
||||
expect(compareCells("9007199254740993", "9007199254740992")).toBe(1);
|
||||
expect(compareCells("-9007199254740993", "-9007199254740992")).toBe(-1);
|
||||
expect(compareCells(null, "")).toBe(-1);
|
||||
expect(compareCells("1.2", "1.11")).toBeGreaterThan(0);
|
||||
expect(compareCells("1e3", "999")).toBeGreaterThan(0);
|
||||
});
|
||||
it("本地筛选覆盖 NULL、JSON、布尔值,不修改原数组", () => {
|
||||
const rows = [{ x: null }, { x: { name: "Alice" } }, { x: false }, { x: "" }];
|
||||
expect(filterRows(rows, ["x"], "alice")).toEqual([rows[1]]);
|
||||
expect(filterRows(rows, ["x"], "NULL")).toEqual([rows[0]]);
|
||||
expect(filterRows(rows, ["x"], "false")).toEqual([rows[2]]);
|
||||
expect(filterRows(rows, ["x"], " ")).toBe(rows);
|
||||
expect(rows).toHaveLength(4);
|
||||
});
|
||||
it("长 JSON 与文本都截断预览,原值保留", () => {
|
||||
const value = { text: "x".repeat(500) };
|
||||
expect(cellPreview(value)).toHaveLength(121);
|
||||
expect(value.text).toHaveLength(500);
|
||||
expect(cellPreview(false)).toBe("false");
|
||||
expect(cellPreview(0)).toBe("0");
|
||||
});
|
||||
it("自动列宽有上下限并考虑中文宽度", () => {
|
||||
expect(suggestedColumnWidth("id", [])).toBe(88);
|
||||
expect(suggestedColumnWidth("数据", [{ 数据: "中".repeat(100) }])).toBe(480);
|
||||
expect(suggestedColumnWidth("name", [{ name: "中国数据库" }])).toBeGreaterThan(88);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { cellText } from "./contextActions";
|
||||
|
||||
export const RESULT_BATCH = 500;
|
||||
|
||||
export function filterRows(rows: Record<string, unknown>[], columns: string[], query: string) {
|
||||
const q = query.trim().toLocaleLowerCase();
|
||||
return q ? rows.filter((row) => columns.some((col) => cellText(row[col]).toLocaleLowerCase().includes(q))) : rows;
|
||||
}
|
||||
|
||||
/** 大整数文本不经 Number 转换,避免精度丢失后排序相等。 */
|
||||
export function compareCells(a: unknown, b: unknown): number {
|
||||
if (a == null || b == null) return a == null ? (b == null ? 0 : -1) : 1;
|
||||
if (typeof a === "number" && typeof b === "number") return a - b;
|
||||
const sa = cellText(a), sb = cellText(b);
|
||||
if (/^-?\d+$/.test(sa) && /^-?\d+$/.test(sb)) {
|
||||
const ia = BigInt(sa), ib = BigInt(sb);
|
||||
return ia < ib ? -1 : ia > ib ? 1 : 0;
|
||||
}
|
||||
const numeric = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
|
||||
if (numeric.test(sa) && numeric.test(sb) && Number.isFinite(Number(sa)) && Number.isFinite(Number(sb))) return Number(sa) - Number(sb);
|
||||
return sa.localeCompare(sb, undefined, { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
|
||||
export function cellPreview(value: unknown, max = 120): string {
|
||||
const text = cellText(value);
|
||||
return text.length > max ? `${text.slice(0, max)}…` : text;
|
||||
}
|
||||
|
||||
export function suggestedColumnWidth(name: string, rows: Record<string, unknown>[]): number {
|
||||
// 有界采样;宽字符按两个单位估算,长 JSON 不撑满窗口。
|
||||
const units = (s: string) => Array.from(s).reduce((n, ch) => n + (ch.charCodeAt(0) > 255 ? 2 : 1), 0);
|
||||
const count = Math.max(units(name), ...rows.slice(0, 100).map((r) => units(cellPreview(r[name], 60))));
|
||||
return Math.min(480, Math.max(88, count * 7 + 28));
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isDangerousSQL,
|
||||
isMysqlUseCommand,
|
||||
parseMysqlUse,
|
||||
parseSqlServerUse,
|
||||
parseClipboardGrid,
|
||||
quoteIdent,
|
||||
rowsToCSV,
|
||||
@@ -10,6 +13,26 @@ import {
|
||||
statementAt,
|
||||
} from "./sql";
|
||||
|
||||
describe("MySQL USE 的库上下文", () => {
|
||||
it("识别明确库名和转义的反引号,并跳过前置注释", () => {
|
||||
expect(parseMysqlUse("USE shop")).toBe("shop");
|
||||
expect(parseMysqlUse("-- 切换库\nUSE `my``shop`; ")).toBe("my`shop");
|
||||
expect(parseMysqlUse("/* 注释 */ USE `orders-db` ")).toBe("orders-db");
|
||||
});
|
||||
it("不把无效或额外语句当作可靠的切库", () => {
|
||||
expect(isMysqlUseCommand("/* 注释 */ USE shop.invalid")).toBe(true);
|
||||
expect(parseMysqlUse("USE shop.invalid")).toBeNull();
|
||||
expect(parseMysqlUse("USE shop SELECT 1")).toBeNull();
|
||||
expect(isMysqlUseCommand("SELECT 'USE shop'")).toBe(false);
|
||||
});
|
||||
it("SQL Server 支持带空格或转义右括号的库名", () => {
|
||||
expect(parseSqlServerUse("USE [my shop] ")).toBe("my shop");
|
||||
expect(parseSqlServerUse("USE [my]]shop]")).toBe("my]shop");
|
||||
expect(parseSqlServerUse('USE "my""shop"')).toBe('my"shop');
|
||||
expect(parseSqlServerUse("USE my.shop")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rowsToCSV", () => {
|
||||
it("转义引号/逗号/换行", () => {
|
||||
const out = rowsToCSV(
|
||||
|
||||
+34
-3
@@ -37,6 +37,28 @@ function stripLead(sql: string): string {
|
||||
return s;
|
||||
}
|
||||
|
||||
export function isMysqlUseCommand(sql: string): boolean {
|
||||
return /^USE\b/i.test(stripLead(sql));
|
||||
}
|
||||
|
||||
/** MySQL USE 只接受明确的库标识符;由编辑器验证目标库并切换标签上下文。 */
|
||||
export function parseMysqlUse(sql: string): string | null {
|
||||
const stmt = stripLead(sql).trim().replace(/;\s*$/, "");
|
||||
const match = /^USE\s+(`(?:``|[^`])+`|[A-Za-z_][\w$]*)\s*$/i.exec(stmt);
|
||||
if (!match) return null;
|
||||
const name = match[1];
|
||||
return name.startsWith("`") ? name.slice(1, -1).replace(/``/g, "`") : name;
|
||||
}
|
||||
|
||||
export function parseSqlServerUse(sql: string): string | null {
|
||||
const stmt = stripLead(sql).trim().replace(/;\s*$/, "");
|
||||
const match = /^USE\s+(\[(?:\]\]|[^\]])+\]|"(?:""|[^"])+"|[A-Za-z_][\w$]*)\s*$/i.exec(stmt);
|
||||
if (!match) return null;
|
||||
const name = match[1];
|
||||
if (name.startsWith("[")) return name.slice(1, -1).replace(/\]\]/g, "]");
|
||||
return name.startsWith('"') ? name.slice(1, -1).replace(/""/g, '"') : name;
|
||||
}
|
||||
|
||||
/** 选区优先;否则取光标所在语句。Redis 按行,SQL 按分号。 */
|
||||
export function statementAt(sql: string, cursor: number, selection?: string, driver?: string): string {
|
||||
const sel = selection?.trim();
|
||||
@@ -165,24 +187,33 @@ export function rowsToCSV(columns: string[], rows: Record<string, unknown>[]): s
|
||||
/** R2-F2:表名/列名按方言 quoteIdent(修 01-P1-1 恒反引号);库.表 形式逐段加引号 */
|
||||
export function rowsToInsert(
|
||||
driver: string | undefined,
|
||||
table: string,
|
||||
table: string | string[],
|
||||
columns: string[],
|
||||
rows: Record<string, unknown>[],
|
||||
): string {
|
||||
const ident = table
|
||||
? table.split(".").map((p) => quoteIdent(driver, p)).join(".")
|
||||
? (Array.isArray(table) ? table : table.split(".")).map((p) => quoteIdent(driver, p)).join(".")
|
||||
: "t";
|
||||
const cols = columns.map((c) => quoteIdent(driver, c)).join(", ");
|
||||
const lit = (v: unknown) => {
|
||||
if (v == null) return "NULL";
|
||||
if (typeof v === "number" && Number.isFinite(v)) return String(v);
|
||||
return `'${String(v).replace(/'/g, "''")}'`;
|
||||
return `'${(typeof v === "object" ? JSON.stringify(v) : String(v)).replace(/'/g, "''")}'`;
|
||||
};
|
||||
return rows
|
||||
.map((r) => `INSERT INTO ${ident} (${cols}) VALUES (${columns.map((c) => lit(r[c])).join(", ")});`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** 当前驱动返回的表名约定:PG/MSSQL 可带 schema;MySQL 名称是原始表名。 */
|
||||
export function relationParts(driver: string | undefined, database: string, table: string): string[] {
|
||||
const d = (driver ?? "mysql").toLowerCase();
|
||||
if (["postgresql", "postgres", "gaussdb", "kingbase", "opengauss"].includes(d)) return table.includes(".") ? table.split(".") : ["public", table];
|
||||
if (["sqlserver", "mssql"].includes(d)) return table.includes(".") ? table.split(".") : ["dbo", table];
|
||||
if (["sqlite", "oracle", "dameng"].includes(d)) return [table];
|
||||
return database ? [database, table] : [table];
|
||||
}
|
||||
|
||||
|
||||
export function isWritableDriver(driver: string | undefined): boolean {
|
||||
const d = (driver || "").toLowerCase();
|
||||
|
||||
+7
-1
@@ -216,6 +216,10 @@ export type AiMessage = {
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
at: string;
|
||||
connectionId?: string;
|
||||
connectionName?: string;
|
||||
database?: string;
|
||||
sqlSuggestion?: string;
|
||||
runId?: string;
|
||||
sessionId?: string;
|
||||
runOutcome?: string;
|
||||
@@ -622,7 +626,9 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(encoded));
|
||||
set({ connections: encoded });
|
||||
// 双写 ~/.opsxdb/connections.json(经本机 Gin),供 opsxdb-mcp 读取。 // Logan.Li
|
||||
void syncConnectionsToDisk(encoded);
|
||||
void syncConnectionsToDisk(encoded).catch(() => {
|
||||
get().notify({ kind: "error", title: "连接同步失败", message: "连接已保存在桌面,尚未同步至本机服务。发送 Agent 任务时会重试同步。", mode: "toast" });
|
||||
});
|
||||
},
|
||||
|
||||
upsertConnection: (c) => {
|
||||
|
||||
+61
-22
@@ -689,17 +689,61 @@
|
||||
.ops-tree-meta-note { padding: 4px 8px; font-size: 11px; color: var(--ops-muted); }
|
||||
.ops-tree-meta-note button { color: var(--ops-accent); margin-left: 6px; }
|
||||
|
||||
.ops-ai-composer { margin: 8px; padding: 10px; border: 1px solid var(--ops-border); border-radius: 10px; background: var(--ops-surface); }
|
||||
.ops-ai-context { display: flex; gap: 8px; padding-bottom: 10px; font-size: 12px; color: var(--ops-muted); overflow-wrap: anywhere; }
|
||||
.ops-ai-actions { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
|
||||
.ops-ai-actions button { border: 1px solid var(--ops-border); border-radius: 6px; padding: 3px 7px; font-size: 11px; color: var(--ops-muted); }
|
||||
.ops-ai-actions button:hover, .ops-ai-actions .ops-ai-action-active { color: var(--ops-accent); border-color: var(--ops-accent); background: var(--ops-accent-soft); }
|
||||
|
||||
/* Ask | Agent 分段(阶段 2) */
|
||||
.ops-ai-mode { display: flex; gap: 0; margin-bottom: 8px; border: 1px solid var(--ops-border); border-radius: 6px; overflow: hidden; width: fit-content; }
|
||||
.ops-ai-mode button { border: 0; background: transparent; padding: 3px 12px; font-size: 11px; color: var(--ops-muted); min-width: 52px; }
|
||||
.ops-ai-mode button:hover { color: var(--ops-text); }
|
||||
.ops-ai-mode .ops-ai-mode-active { color: var(--ops-accent); background: var(--ops-accent-soft); font-weight: 600; }
|
||||
.ops-ai-transcript { min-height: 0; flex: 1 1 0; overflow-y: auto; scrollbar-gutter: stable; }
|
||||
.ops-ai-empty { display: flex; min-height: 100%; flex-direction: column; align-items: flex-start; justify-content: center; gap: 7px; padding: 12px; color: var(--ops-muted); font-size: 12px; }
|
||||
.ops-ai-empty-title { color: var(--ops-text); font-size: 14px; font-weight: 600; }
|
||||
.ops-ai-empty p { margin: 0; line-height: 1.55; }
|
||||
.ops-ai-empty button { display: inline-flex; align-items: center; gap: 5px; margin-top: 7px; padding: 5px 8px; border: 1px solid var(--ops-border); border-radius: 7px; color: var(--ops-accent); text-align: left; }
|
||||
.ops-ai-empty button:hover { background: var(--ops-accent-soft); }
|
||||
.ops-settings-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(820px, calc(100vw - 32px));
|
||||
max-width: none;
|
||||
height: min(640px, calc(100dvh - 48px));
|
||||
}
|
||||
.ops-settings-modal .ops-modal-header, .ops-settings-modal .ops-modal-footer { flex: 0 0 auto; }
|
||||
.ops-settings-modal .ops-modal-body { flex: 1; min-height: 0; overflow: hidden; }
|
||||
.ops-settings-layout { display: flex; height: 100%; min-height: 0; }
|
||||
.ops-settings-panel { overscroll-behavior: contain; scrollbar-gutter: stable; }
|
||||
.ops-settings-modal .ops-modal-footer { align-items: center; min-height: 56px; }
|
||||
.ops-settings-modal .ops-modal-footer > button { flex: 0 0 auto; min-height: 32px; padding-inline: 14px; white-space: nowrap; }
|
||||
.ops-settings-feedback { flex: 1; min-width: 0; max-height: 56px; overflow: auto; overflow-wrap: anywhere; font-size: 11px; line-height: 1.5; }
|
||||
.ops-ai-settings { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 14px; margin: 0; padding: 2px 0 16px; font-size: 12px; }
|
||||
.ops-ai-settings > :nth-child(n+3) { grid-column: 1 / -1; }
|
||||
.ops-ai-settings .ops-ai-settings-advanced { justify-self: start; }
|
||||
@media (max-width: 680px) {
|
||||
.ops-settings-layout > nav { width: 140px; }
|
||||
.ops-ai-settings { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
.ops-ai-settings-field { display: flex; flex-direction: column; gap: 5px; min-width: 0; color: var(--ops-text); font-size: 11px; font-weight: 500; }
|
||||
.ops-ai-settings-field .ops-input { width: 100%; min-width: 0; height: 34px; padding-inline: 10px; font-size: 12px; font-weight: 400; }
|
||||
.ops-ai-settings-advanced { display: inline-flex; align-items: center; gap: 6px; padding: 4px 0; color: var(--ops-muted); font-size: 11px; }
|
||||
.ops-ai-settings-advanced:hover { color: var(--ops-text); }
|
||||
.ops-ai-settings-extra { display: grid; gap: 10px; padding: 10px; border: 1px solid var(--ops-border-soft); border-radius: 8px; background: var(--ops-surface-2); }
|
||||
.ops-ai-settings-check { display: flex; align-items: center; gap: 6px; color: var(--ops-muted); font-size: 11px; }
|
||||
.ops-ai-message { padding: 9px 11px; border-radius: 9px; font-size: 12px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.ops-ai-message-user { max-width: 94%; margin-left: auto; background: color-mix(in srgb, var(--ops-accent) 9%, var(--ops-surface)); }
|
||||
.ops-ai-message-assistant { border-left: 2px solid var(--ops-border); border-radius: 2px; padding-left: 11px; }
|
||||
.ops-ai-composer { flex: 0 0 auto; max-height: min(45vh, 260px); overflow-y: auto; margin: 0; padding: 9px 11px 11px; border-top: 1px solid var(--ops-border); background: var(--ops-surface); }
|
||||
.ops-ai-context { display: flex; align-items: center; gap: 5px; min-width: 0; padding: 0 2px 7px; font-size: 11px; color: var(--ops-muted); }
|
||||
.ops-ai-context strong { min-width: 0; color: var(--ops-text); font-weight: 500; }
|
||||
.ops-ai-context-separator { opacity: .5; }
|
||||
.ops-ai-context-policy { flex-shrink: 0; margin-left: auto; font-size: 10px; }
|
||||
.ops-ai-textarea { display: block; width: 100%; min-height: 68px; max-height: 180px; padding: 9px 10px; resize: vertical; border: 1px solid var(--ops-border); border-radius: 8px; background: var(--ops-surface-2); color: var(--ops-text); font: inherit; font-size: 12px; line-height: 1.5; outline: none; }
|
||||
.ops-ai-textarea:focus { border-color: var(--ops-accent); box-shadow: 0 0 0 2px var(--ops-accent-soft); }
|
||||
.ops-ai-textarea::placeholder { color: var(--ops-muted); }
|
||||
.ops-ai-composer-footer { display: flex; align-items: center; gap: 5px; min-width: 0; margin-top: 8px; }
|
||||
.ops-ai-composer-spacer { flex: 1; }
|
||||
.ops-ai-mode-select { flex-shrink: 0; max-width: 122px; padding: 5px 3px; border: 1px solid var(--ops-border); border-radius: 6px; background: var(--ops-surface-2); color: var(--ops-text); font-size: 11px; cursor: pointer; }
|
||||
.ops-ai-action-select { min-width: 0; max-width: 105px; padding: 5px 3px; border: 0; border-radius: 5px; background: transparent; color: var(--ops-muted); font-size: 11px; cursor: pointer; }
|
||||
.ops-ai-action-select:hover, .ops-ai-action-select:focus { background: var(--ops-surface-2); color: var(--ops-text); }
|
||||
.ops-ai-stop, .ops-ai-send { display: inline-flex; flex-shrink: 0; align-items: center; justify-content: center; gap: 4px; min-height: 28px; padding: 0 8px; border-radius: 6px; font-size: 11px; }
|
||||
.ops-ai-stop { color: var(--ops-muted); }
|
||||
.ops-ai-stop:hover { background: var(--ops-surface-2); color: var(--ops-text); }
|
||||
.ops-ai-send { background: var(--ops-accent); color: white; font-weight: 600; }
|
||||
.ops-ai-send:hover:not(:disabled) { filter: brightness(1.08); }
|
||||
.ops-ai-send:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.ops-ai-steps { font-variant-numeric: tabular-nums; }
|
||||
|
||||
|
||||
@@ -723,24 +767,20 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 2px 0 4px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--ops-accent) 28%, var(--ops-border));
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--ops-accent) 8%, var(--ops-surface-2));
|
||||
padding: 7px 9px;
|
||||
border: 1px solid var(--ops-border);
|
||||
border-radius: 8px;
|
||||
background: var(--ops-surface-2);
|
||||
}
|
||||
.ops-ai-queue-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.ops-ai-queue-grip {
|
||||
color: var(--ops-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
user-select: none;
|
||||
}
|
||||
.ops-ai-queue-label { color: var(--ops-muted); font-size: 10px; }
|
||||
.ops-ai-queue-text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@@ -759,4 +799,3 @@
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildAlterStatements,
|
||||
buildCreateSql,
|
||||
columnTypeSql,
|
||||
designerSupport,
|
||||
splitBaseType,
|
||||
splitLength,
|
||||
toDesignerColumns,
|
||||
validateColumns,
|
||||
type DesignerColumn,
|
||||
} from "./tableDdl";
|
||||
|
||||
function col(p: Partial<DesignerColumn> = {}): DesignerColumn {
|
||||
return { id: crypto.randomUUID(), name: "name", type: "VARCHAR", length: "64", nullable: true, defaultValue: "", comment: "", primary: false, autoIncrement: false, ...p };
|
||||
}
|
||||
|
||||
describe("类型拆分", () => {
|
||||
it("VARCHAR(255)/DECIMAL(10,2)/无长度", () => {
|
||||
expect(splitBaseType("varchar(255)")).toBe("varchar");
|
||||
expect(splitLength("varchar(255)")).toBe("255");
|
||||
expect(splitLength("DECIMAL(10,2)")).toBe("10,2");
|
||||
expect(splitBaseType("INT")).toBe("INT");
|
||||
expect(splitLength("TEXT")).toBe("");
|
||||
});
|
||||
it("columnTypeSql 拼接长度", () => {
|
||||
expect(columnTypeSql(col({ type: "DECIMAL", length: "10,2" }))).toBe("DECIMAL(10,2)");
|
||||
expect(columnTypeSql(col({ type: "TEXT", length: "" }))).toBe("TEXT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("建表 DDL", () => {
|
||||
it("MySQL 族:反引号 + AUTO_INCREMENT + 主键 + 注释", () => {
|
||||
const sql = buildCreateSql("mysql", "shop", "users", [
|
||||
col({ name: "id", type: "BIGINT", length: "", nullable: false, primary: true, autoIncrement: true }),
|
||||
col({ name: "name", type: "VARCHAR", length: "64", nullable: false, defaultValue: "", comment: "姓名" }),
|
||||
]);
|
||||
expect(sql).toContain("CREATE TABLE `shop`.`users`");
|
||||
expect(sql).toContain("`id` BIGINT AUTO_INCREMENT NOT NULL");
|
||||
expect(sql).toContain("PRIMARY KEY (`id`)");
|
||||
expect(sql).toContain("`name` VARCHAR(64) NOT NULL COMMENT '姓名'");
|
||||
expect(sql).toContain("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
});
|
||||
it("PG 族:双引号 + 库前缀、无 ENGINE 尾巴", () => {
|
||||
const sql = buildCreateSql("postgresql", "public", "t1", [col({ name: "id", type: "INT", length: "", nullable: false, primary: true })]);
|
||||
expect(sql).toContain('CREATE TABLE "public"."t1"');
|
||||
expect(sql).toContain('"id" INT NOT NULL');
|
||||
expect(sql).not.toContain("ENGINE=");
|
||||
});
|
||||
it("默认值:数字不加引号,字符串转义单引号", () => {
|
||||
const sql = buildCreateSql("mysql", undefined, "t", [col({ defaultValue: "0" }), col({ name: "note", defaultValue: "it's" })]);
|
||||
expect(sql).toContain("DEFAULT 0");
|
||||
expect(sql).toContain("DEFAULT 'it''s'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("校验", () => {
|
||||
it("空表名/重名/缺类型报错", () => {
|
||||
const errs = validateColumns("", [col(), col()]);
|
||||
expect(errs.join()).toContain("表名");
|
||||
const dup = validateColumns("t", [col({ name: "a" }), col({ name: "A" })]);
|
||||
expect(dup.join()).toContain("重名");
|
||||
expect(validateColumns("t", [col({ type: "" })]).join()).toContain("类型");
|
||||
expect(validateColumns("t", [col()])).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ALTER diff", () => {
|
||||
it("MySQL:新增/删除/改名/修改", () => {
|
||||
const before = [
|
||||
col({ name: "id", type: "BIGINT", length: "", nullable: false, primary: true }),
|
||||
col({ name: "drop_me" }),
|
||||
col({ name: "old_name", type: "VARCHAR", length: "32" }),
|
||||
];
|
||||
const after = [
|
||||
before[0],
|
||||
{ ...before[2], name: "new_name", length: "128" },
|
||||
col({ name: "extra", type: "INT", length: "" }),
|
||||
];
|
||||
const stmts = buildAlterStatements("mysql", "db", "t", before, after);
|
||||
expect(stmts.some((s) => s.includes("DROP COLUMN `drop_me`"))).toBe(true);
|
||||
expect(stmts.some((s) => s.includes("ADD COLUMN `extra` INT"))).toBe(true);
|
||||
expect(stmts.some((s) => s.startsWith("ALTER TABLE `db`.`t` CHANGE COLUMN `old_name`"))).toBe(true);
|
||||
expect(stmts.some((s) => s.includes("`new_name` VARCHAR(128)"))).toBe(true);
|
||||
});
|
||||
it("PG:类型/可空/默认逐项生成,改名单独一句", () => {
|
||||
const before = [col({ name: "age", type: "INT", length: "", nullable: false, defaultValue: "0" })];
|
||||
const after = [{ ...before[0], nullable: true, defaultValue: "" }];
|
||||
const stmts = buildAlterStatements("postgresql", "public", "t", before, after);
|
||||
expect(stmts).toContain('ALTER TABLE "public"."t" ALTER COLUMN "age" DROP NOT NULL;');
|
||||
expect(stmts).toContain('ALTER TABLE "public"."t" ALTER COLUMN "age" DROP DEFAULT;');
|
||||
});
|
||||
it("无变更返回空;SQLite 不生成 DROP/MODIFY", () => {
|
||||
const before = [col()];
|
||||
expect(buildAlterStatements("mysql", "db", "t", before, [before[0]])).toHaveLength(0);
|
||||
const stmts = buildAlterStatements("sqlite", undefined, "t", [col({ name: "a" })], []);
|
||||
expect(stmts.every((s) => s.startsWith("--") || s.includes("ADD COLUMN"))).toBe(true);
|
||||
});
|
||||
it("已有生成列修改不会生成丢失表达式的 MODIFY", () => {
|
||||
const before = [col({ name: "computed", generated: true })];
|
||||
const sql = buildAlterStatements("mysql", "db", "t", before, [{ ...before[0], length: "128" }]);
|
||||
expect(sql).toHaveLength(1);
|
||||
expect(sql[0]).toMatch(/^-- 生成列/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("驱动支持级别", () => {
|
||||
it("mysql/pg 全支持,sqlite 有限,redis 不支持", () => {
|
||||
expect(designerSupport("mysql").alter).toBe(true);
|
||||
expect(designerSupport("kingbase").alter).toBe(true);
|
||||
expect(designerSupport("sqlite").note).toBeTruthy();
|
||||
expect(designerSupport("redis").create).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("列元数据转换", () => {
|
||||
it("PRI/PK 识别主键,类型拆长度", () => {
|
||||
const rows = toDesignerColumns([
|
||||
{ name: "id", type: "bigint(20)", nullable: false, key: "PRI", extra: "auto_increment" },
|
||||
{ name: "name", type: "varchar(64)", nullable: true, comment: "姓名" },
|
||||
]);
|
||||
expect(rows[0].primary).toBe(true);
|
||||
expect(rows[0].autoIncrement).toBe(true);
|
||||
expect(rows[0].type).toBe("bigint");
|
||||
expect(rows[0].length).toBe("20");
|
||||
expect(rows[1].comment).toBe("姓名");
|
||||
});
|
||||
});
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
import { quoteIdent } from "./sql";
|
||||
import type { ColumnInfo } from "./store";
|
||||
import { mysqlIndexDefinition, type DesignerIndex } from "./tableIndex";
|
||||
|
||||
/**
|
||||
* 表设计器 v1 的 DDL 生成:新建表(CREATE)与既有表列级变更(ALTER)。
|
||||
* 范围:字段(名/类型/长度/NULL/默认/注释/主键/自增);索引/外键编辑不在 v1。
|
||||
* ALTER 只做列级 diff(新增/删除/修改/改名),主键变更生成独立语句;SQLite 仅
|
||||
* 支持 ADD COLUMN,其余变更返回不支持说明,不静默生成错误 DDL。
|
||||
*/
|
||||
|
||||
export interface DesignerColumn {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
length: string;
|
||||
nullable: boolean;
|
||||
defaultValue: string;
|
||||
comment: string;
|
||||
primary: boolean;
|
||||
autoIncrement: boolean;
|
||||
/** SHOW FULL COLUMNS 标记的生成列不能通过普通 MODIFY 重定义。 */
|
||||
generated?: boolean;
|
||||
}
|
||||
|
||||
const MYSQL_FAMILY = ["mysql", "mariadb", "tidb", "oceanbase", "polardb", "tdsql"];
|
||||
const PG_FAMILY = ["postgresql", "postgres", "gaussdb", "kingbase", "opengauss"];
|
||||
|
||||
function key(driver: string | undefined): string {
|
||||
return (driver || "mysql").toLowerCase();
|
||||
}
|
||||
export function isMysqlFamily(driver: string | undefined): boolean {
|
||||
return MYSQL_FAMILY.includes(key(driver));
|
||||
}
|
||||
export function isPgFamily(driver: string | undefined): boolean {
|
||||
return PG_FAMILY.includes(key(driver));
|
||||
}
|
||||
function isSqlite(driver: string | undefined): boolean {
|
||||
return key(driver) === "sqlite";
|
||||
}
|
||||
|
||||
/** 列元数据 → 设计器行(listColumns 结果;key==="PRI"/pk 视为主键) */
|
||||
export function toDesignerColumns(cols: ColumnInfo[]): DesignerColumn[] {
|
||||
return cols.map((c) => ({
|
||||
id: crypto.randomUUID(),
|
||||
name: c.name,
|
||||
type: splitBaseType(c.type),
|
||||
length: splitLength(c.type),
|
||||
nullable: !!c.nullable,
|
||||
defaultValue: c.default == null ? "" : String(c.default),
|
||||
comment: c.comment || "",
|
||||
primary: (c.key || "").toUpperCase() === "PRI" || (c.key || "").toUpperCase() === "PK",
|
||||
autoIncrement: /auto_increment/i.test(c.extra ?? ""),
|
||||
generated: /\bGENERATED\b/i.test(c.extra ?? ""),
|
||||
}));
|
||||
}
|
||||
|
||||
/** VARCHAR(255) → VARCHAR;类型文本原样保留大小写 */
|
||||
export function splitBaseType(type: string): string {
|
||||
const m = /^([A-Za-z_]+)\s*(?:\([^)]*\))?\s*( unsigned| zerofill)?.*$/.exec(type.trim());
|
||||
if (!m) return type.trim();
|
||||
return (m[1] + (m[2] || "")).trim();
|
||||
}
|
||||
|
||||
/** VARCHAR(255) → 255;DECIMAL(10,2) → 10,2;无长度 → 空串 */
|
||||
export function splitLength(type: string): string {
|
||||
const m = /\(([^)]*)\)/.exec(type.trim());
|
||||
return m ? m[1].trim() : "";
|
||||
}
|
||||
|
||||
export function columnTypeSql(c: DesignerColumn): string {
|
||||
const len = c.length.trim();
|
||||
return len ? `${c.type.trim()}(${len})` : c.type.trim();
|
||||
}
|
||||
|
||||
function defaultSql(c: DesignerColumn): string {
|
||||
const v = c.defaultValue.trim();
|
||||
if (!v) return "";
|
||||
if (/^(NULL|CURRENT_TIMESTAMP|CURRENT_DATE|CURRENT_TIME|now\(\)|nextval\(.*\))$/i.test(v)) return `DEFAULT ${v}`;
|
||||
if (/^-?\d+(\.\d+)?$/.test(v)) return `DEFAULT ${v}`;
|
||||
return `DEFAULT '${v.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
/** 校验设计器输入;返回错误列表(空 = 可生成) */
|
||||
export function validateColumns(table: string, cols: DesignerColumn[]): string[] {
|
||||
const errs: string[] = [];
|
||||
if (!table.trim()) errs.push("表名不能为空");
|
||||
const names = new Set<string>();
|
||||
for (const c of cols) {
|
||||
if (!c.name.trim()) errs.push("存在未命名字段");
|
||||
else if (names.has(c.name.trim().toLowerCase())) errs.push(`字段重名:${c.name.trim()}`);
|
||||
else names.add(c.name.trim().toLowerCase());
|
||||
if (!c.type.trim()) errs.push(`字段 ${c.name || "(未命名)"} 缺少类型`);
|
||||
}
|
||||
if (cols.length === 0) errs.push("至少需要一个字段");
|
||||
return errs;
|
||||
}
|
||||
|
||||
function qualified(driver: string | undefined, database: string | undefined, table: string): string {
|
||||
const t = quoteIdent(driver, table.trim());
|
||||
return database?.trim() ? `${quoteIdent(driver, database.trim())}.${t}` : t;
|
||||
}
|
||||
|
||||
/** 新建表 CREATE 语句;调用前先 validateColumns */
|
||||
export function buildCreateSql(
|
||||
driver: string | undefined,
|
||||
database: string | undefined,
|
||||
table: string,
|
||||
cols: DesignerColumn[],
|
||||
indexes: DesignerIndex[] = [],
|
||||
tableComment = "",
|
||||
): string {
|
||||
const mysql = isMysqlFamily(driver);
|
||||
const lines = cols.map((c) => {
|
||||
let line = ` ${quoteIdent(driver, c.name.trim())} ${columnTypeSql(c)}`;
|
||||
if (c.autoIncrement && mysql) line += " AUTO_INCREMENT";
|
||||
line += c.nullable ? " NULL" : " NOT NULL";
|
||||
if (defaultSql(c)) line += ` ${defaultSql(c)}`;
|
||||
if (mysql && c.comment.trim()) line += ` COMMENT '${c.comment.replace(/'/g, "''")}'`;
|
||||
return line;
|
||||
});
|
||||
const pks = cols.filter((c) => c.primary).map((c) => quoteIdent(driver, c.name.trim()));
|
||||
if (pks.length) lines.push(` PRIMARY KEY (${pks.join(", ")})`);
|
||||
if (mysql) lines.push(...indexes.map(index => ` ${mysqlIndexDefinition(index)}`));
|
||||
const tail = mysql ? ` ENGINE=InnoDB DEFAULT CHARSET=utf8mb4${tableComment.trim() ? ` COMMENT='${tableComment.replace(/'/g, "''")}'` : ""}` : "";
|
||||
return `CREATE TABLE ${qualified(driver, database, table)} (\n${lines.join(",\n")}\n)${tail};`;
|
||||
}
|
||||
|
||||
/** 既有表列级 diff → ALTER 语句列表(顺序执行);无变更返回空数组 */
|
||||
export function buildAlterStatements(
|
||||
driver: string | undefined,
|
||||
database: string | undefined,
|
||||
table: string,
|
||||
before: DesignerColumn[],
|
||||
after: DesignerColumn[],
|
||||
): string[] {
|
||||
const mysql = isMysqlFamily(driver);
|
||||
const pg = isPgFamily(driver);
|
||||
const sqlite = isSqlite(driver);
|
||||
if (!mysql && !pg && !sqlite) return [];
|
||||
const t = qualified(driver, database, table);
|
||||
const stmts: string[] = [];
|
||||
const beforeById = new Map(before.map((c) => [c.id, c]));
|
||||
const afterIds = new Set(after.map((c) => c.id));
|
||||
|
||||
// 删除列(SQLite 旧版本可能不支持 DROP COLUMN,由数据库执行时报错兜底)
|
||||
for (const b of before) {
|
||||
if (!afterIds.has(b.id)) {
|
||||
if (sqlite) continue; // SQLite DROP 语义复杂,v1 不生成
|
||||
stmts.push(`ALTER TABLE ${t} DROP COLUMN ${quoteIdent(driver, b.name)};`);
|
||||
}
|
||||
}
|
||||
for (const a of after) {
|
||||
const b = beforeById.get(a.id);
|
||||
if (!b) {
|
||||
// 新增列(SQLite 只支持 ADD COLUMN)
|
||||
if (sqlite && (!a.nullable || a.primary || a.autoIncrement)) {
|
||||
stmts.push(`-- SQLite 新增列必须可空且无主键/自增,请调整:${a.name}`);
|
||||
continue;
|
||||
}
|
||||
let line = `ALTER TABLE ${t} ADD COLUMN ${quoteIdent(driver, a.name.trim())} ${columnTypeSql(a)}`;
|
||||
line += a.nullable ? " NULL" : " NOT NULL";
|
||||
if (defaultSql(a)) line += ` ${defaultSql(a)}`;
|
||||
if (mysql && a.comment.trim()) line += ` COMMENT '${a.comment.replace(/'/g, "''")}'`;
|
||||
stmts.push(line + ";");
|
||||
continue;
|
||||
}
|
||||
if (b.generated && (b.name !== a.name || b.type !== a.type || b.length !== a.length || b.nullable !== a.nullable || b.defaultValue !== a.defaultValue || b.comment !== a.comment || b.primary !== a.primary)) {
|
||||
stmts.push(`-- 生成列 ${b.name} 的修改需保留生成表达式,请使用 SQL 工作台`);
|
||||
continue;
|
||||
}
|
||||
// 修改列:类型/长度/NULL/默认/自增 变化才生成
|
||||
const defChanged = b.defaultValue.trim() !== a.defaultValue.trim();
|
||||
const typeChanged =
|
||||
columnTypeSql(b).toLowerCase() !== columnTypeSql(a).toLowerCase() ||
|
||||
b.nullable !== a.nullable ||
|
||||
b.autoIncrement !== a.autoIncrement ||
|
||||
(mysql && b.comment.trim() !== a.comment.trim());
|
||||
if (b.name.trim() !== a.name.trim()) {
|
||||
// 改名:MySQL CHANGE 顺带重定义;PG RENAME 单独一句,其余属性变化再补 ALTER
|
||||
if (mysql) {
|
||||
stmts.push(`ALTER TABLE ${t} CHANGE COLUMN ${quoteIdent(driver, b.name)} ${columnDefFull(driver, a)};`);
|
||||
continue;
|
||||
}
|
||||
if (pg) {
|
||||
stmts.push(`ALTER TABLE ${t} RENAME COLUMN ${quoteIdent(driver, b.name)} TO ${quoteIdent(driver, a.name.trim())};`);
|
||||
} else {
|
||||
stmts.push(`-- ${key(driver)} 改名列不在 v1 支持范围:${b.name} → ${a.name}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!typeChanged && !defChanged) continue;
|
||||
if (sqlite) {
|
||||
stmts.push(`-- SQLite 不支持 MODIFY 列,需重建表:${a.name}`);
|
||||
continue;
|
||||
}
|
||||
if (mysql) {
|
||||
stmts.push(`ALTER TABLE ${t} MODIFY COLUMN ${columnDefFull(driver, a)};`);
|
||||
} else if (pg) {
|
||||
const name = quoteIdent(driver, a.name.trim());
|
||||
if (columnTypeSql(b).toLowerCase() !== columnTypeSql(a).toLowerCase()) {
|
||||
stmts.push(`ALTER TABLE ${t} ALTER COLUMN ${name} TYPE ${columnTypeSql(a)};`);
|
||||
}
|
||||
if (b.nullable !== a.nullable) {
|
||||
stmts.push(`ALTER TABLE ${t} ALTER COLUMN ${name} ${a.nullable ? "DROP NOT NULL" : "SET NOT NULL"};`);
|
||||
}
|
||||
if (defChanged) {
|
||||
stmts.push(`ALTER TABLE ${t} ALTER COLUMN ${name} ${defaultSql(a) || "DROP DEFAULT"};`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 主键变更(v1:整组对比;SQLite 不生成)
|
||||
const pkBefore = before.filter((c) => c.primary).map((c) => c.name).sort().join(",");
|
||||
const pkAfter = after.filter((c) => c.primary).map((c) => c.name).sort().join(",");
|
||||
if (pkBefore !== pkAfter && !sqlite) {
|
||||
const stmtPk = (pk: string[]) => pk.map((n) => quoteIdent(driver, n)).join(", ");
|
||||
if (mysql) {
|
||||
if (pkBefore) stmts.push(`ALTER TABLE ${t} DROP PRIMARY KEY;`);
|
||||
if (pkAfter) stmts.push(`ALTER TABLE ${t} ADD PRIMARY KEY (${stmtPk(after.filter((c) => c.primary).map((c) => c.name))});`);
|
||||
} else if (pg) {
|
||||
if (pkBefore) stmts.push(`ALTER TABLE ${t} DROP CONSTRAINT ${t}_pkey;`);
|
||||
if (pkAfter) stmts.push(`ALTER TABLE ${t} ADD PRIMARY KEY (${stmtPk(after.filter((c) => c.primary).map((c) => c.name))});`);
|
||||
}
|
||||
}
|
||||
return stmts;
|
||||
}
|
||||
|
||||
/** MySQL 族完整列定义(CHANGE/MODIFY 用) */
|
||||
function columnDefFull(driver: string | undefined, c: DesignerColumn): string {
|
||||
let line = `${quoteIdent(driver, c.name.trim())} ${columnTypeSql(c)}`;
|
||||
if (c.autoIncrement && isMysqlFamily(driver)) line += " AUTO_INCREMENT";
|
||||
line += c.nullable ? " NULL" : " NOT NULL";
|
||||
if (defaultSql(c)) line += ` ${defaultSql(c)}`;
|
||||
if (isMysqlFamily(driver) && c.comment.trim()) line += ` COMMENT '${c.comment.replace(/'/g, "''")}'`;
|
||||
return line.trim();
|
||||
}
|
||||
|
||||
/** 设计器对驱动的支持级别 */
|
||||
export function designerSupport(driver: string | undefined): { create: boolean; alter: boolean; note?: string } {
|
||||
if (isMysqlFamily(driver) || isPgFamily(driver)) return { create: true, alter: true };
|
||||
if (isSqlite(driver)) return { create: true, alter: true, note: "SQLite 修改列需重建表,v1 仅支持新增列与建表" };
|
||||
if (key(driver) === "sqlserver" || key(driver) === "mssql" || key(driver) === "oracle") {
|
||||
return { create: true, alter: false, note: `${key(driver)} 方言 ALTER 生成暂未接入,可预览建表语句` };
|
||||
}
|
||||
return { create: false, alter: false, note: "该驱动不支持表设计 DDL 生成" };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildMysqlIndexAlter, mysqlIndexQuery, toDesignerIndexes, validateIndexes } from "./tableIndex";
|
||||
import { buildCreateSql, type DesignerColumn } from "./tableDdl";
|
||||
|
||||
describe("表设计索引", () => {
|
||||
it("读取复合索引并跳过由字段页管理的主键", () => {
|
||||
const items = toDesignerIndexes([
|
||||
{ name: "PRIMARY", columnName: "id", nonUnique: 0 },
|
||||
{ name: "idx_order", columnName: "user_id", nonUnique: 1 },
|
||||
{ name: "idx_order", columnName: "created_at", nonUnique: 1 },
|
||||
]);
|
||||
expect(items).toMatchObject([{ name: "idx_order", columns: ["user_id", "created_at"], unique: false }]);
|
||||
expect(mysqlIndexQuery("a'b", "orders")).not.toContain("a'b");
|
||||
const special = toDesignerIndexes([{ name: "idx_prefix", columnName: "email", nonUnique: 1, indexType: "BTREE", prefixLength: 10 }]);
|
||||
expect(special[0].readOnly).toBe(true);
|
||||
expect(validateIndexes(special, ["email"])).toEqual([]);
|
||||
expect(validateIndexes(special, [])).toHaveLength(1);
|
||||
});
|
||||
it("修改索引先删除旧定义再新增,拒绝不存在的列", () => {
|
||||
const initial = [{ id: "idx", name: "idx_user", columns: ["user_id"], unique: false }];
|
||||
const after = [{ ...initial[0], columns: ["user_id", "created_at"], unique: true }];
|
||||
expect(buildMysqlIndexAlter("shop", "orders", initial, after)).toEqual({
|
||||
drop: ["ALTER TABLE `shop`.`orders` DROP INDEX `idx_user`;"],
|
||||
add: ["ALTER TABLE `shop`.`orders` ADD UNIQUE KEY `idx_user` (`user_id`, `created_at`);"],
|
||||
});
|
||||
expect(validateIndexes(after, ["user_id"])).toContain("索引 idx_user 的字段 created_at 不在当前表中");
|
||||
const columns: DesignerColumn[] = [{ id: "id", name: "user_id", type: "INT", length: "", nullable: false, defaultValue: "", comment: "", primary: false, autoIncrement: false }];
|
||||
expect(buildCreateSql("mysql", "shop", "orders", columns, initial)).toContain("KEY `idx_user` (`user_id`)");
|
||||
expect(buildCreateSql("mysql", "shop", "orders", columns, initial, "O'Brien")).toContain("COMMENT='O''Brien'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { quoteIdent } from "./sql";
|
||||
|
||||
export type DesignerIndex = {
|
||||
id: string;
|
||||
name: string;
|
||||
columns: string[];
|
||||
unique: boolean;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
const sqlText = (s: string) => `CONVERT(X'${Array.from(new TextEncoder().encode(s), b => b.toString(16).padStart(2, "0")).join("")}' USING utf8mb4)`;
|
||||
|
||||
export function mysqlIndexQuery(database: string, table: string): string {
|
||||
return `SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, SEQ_IN_INDEX AS sequence, NON_UNIQUE AS nonUnique, INDEX_TYPE AS indexType, SUB_PART AS prefixLength, COLLATION AS sortDirection FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=${sqlText(database)} AND TABLE_NAME=${sqlText(table)} ORDER BY INDEX_NAME, SEQ_IN_INDEX`;
|
||||
}
|
||||
|
||||
export function toDesignerIndexes(rows: Record<string, unknown>[]): DesignerIndex[] {
|
||||
const byName = new Map<string, DesignerIndex>();
|
||||
for (const row of rows) {
|
||||
const name = String(row.name ?? "");
|
||||
if (!name || name.toUpperCase() === "PRIMARY") continue;
|
||||
let index = byName.get(name);
|
||||
if (!index) {
|
||||
index = { id: crypto.randomUUID(), name, columns: [], unique: Number(row.nonUnique) === 0 };
|
||||
byName.set(name, index);
|
||||
}
|
||||
if (row.columnName == null || row.prefixLength != null || String(row.indexType ?? "BTREE").toUpperCase() !== "BTREE" || row.sortDirection === "D") index.readOnly = true;
|
||||
if (row.columnName != null) index.columns.push(String(row.columnName));
|
||||
}
|
||||
return [...byName.values()];
|
||||
}
|
||||
|
||||
export function validateIndexes(indexes: DesignerIndex[], columnNames: string[]): string[] {
|
||||
const errors: string[] = [];
|
||||
const names = new Set<string>();
|
||||
const columns = new Set(columnNames.map(n => n.trim().toLowerCase()));
|
||||
for (const index of indexes) {
|
||||
if (index.readOnly) {
|
||||
if (index.columns.some(c => !columns.has(c.toLowerCase()))) errors.push(`特殊索引 ${index.name} 依赖已移除的字段,请使用 SQL 工作台处理`);
|
||||
continue;
|
||||
}
|
||||
const name = index.name.trim();
|
||||
if (!name || name.toUpperCase() === "PRIMARY") errors.push("索引名不能为空,也不能使用 PRIMARY");
|
||||
if (names.has(name.toLowerCase())) errors.push(`索引重名:${name}`);
|
||||
names.add(name.toLowerCase());
|
||||
if (!index.columns.length) errors.push(`索引 ${name || "(未命名)"} 需要至少一个字段`);
|
||||
for (const column of index.columns) {
|
||||
if (!columns.has(column.trim().toLowerCase())) errors.push(`索引 ${name} 的字段 ${column} 不在当前表中`);
|
||||
}
|
||||
if (new Set(index.columns.map(c => c.toLowerCase())).size !== index.columns.length) errors.push(`索引 ${name} 存在重复字段`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function mysqlIndexDefinition(index: DesignerIndex): string {
|
||||
return `${index.unique ? "UNIQUE " : ""}KEY ${quoteIdent("mysql", index.name.trim())} (${index.columns.map(c => quoteIdent("mysql", c.trim())).join(", ")})`;
|
||||
}
|
||||
|
||||
export function buildMysqlIndexAlter(database: string, table: string, before: DesignerIndex[], after: DesignerIndex[]): { drop: string[]; add: string[] } {
|
||||
const target = `${quoteIdent("mysql", database)}.${quoteIdent("mysql", table)}`;
|
||||
const initial = new Map(before.map(i => [i.id, i]));
|
||||
const current = new Map(after.map(i => [i.id, i]));
|
||||
const changed = (a: DesignerIndex, b: DesignerIndex) => a.name !== b.name || a.unique !== b.unique || a.columns.join("\0") !== b.columns.join("\0");
|
||||
return {
|
||||
drop: before.filter(i => !current.has(i.id) || changed(i, current.get(i.id)!)).map(i => `ALTER TABLE ${target} DROP INDEX ${quoteIdent("mysql", i.name)};`),
|
||||
add: after.filter(i => !initial.has(i.id) || changed(i, initial.get(i.id)!)).map(i => `ALTER TABLE ${target} ADD ${mysqlIndexDefinition(i)};`),
|
||||
};
|
||||
}
|
||||
|
||||
export function canEditIndexes(driver: string): boolean {
|
||||
return ["mysql", "mariadb", "tidb", "oceanbase", "polardb", "tdsql"].includes(driver.toLowerCase());
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { tableMetadataSql } from './tableMetadata';
|
||||
|
||||
describe('table metadata queries', () => {
|
||||
it('escapes database and object names as values', () => {
|
||||
const sql = tableMetadataSql('mysql', "a'b", "t' OR 1=1 --", 'indexes');
|
||||
expect(sql).toContain("TABLE_SCHEMA=CONVERT(X'612762' USING utf8mb4)");
|
||||
expect(sql).not.toContain("OR 1=1");
|
||||
expect(sql).toContain("TABLE_NAME=CONVERT(X'");
|
||||
});
|
||||
it('uses PostgreSQL schema rather than database as namespace', () => {
|
||||
const sql = tableMetadataSql('postgresql', 'production', 'sales.orders', 'foreignKeys');
|
||||
expect(sql).toContain("n.nspname='sales'");
|
||||
expect(sql).toContain("c.relname='orders'");
|
||||
expect(sql).toContain("contype='f'");
|
||||
expect(sql).not.toContain('production');
|
||||
});
|
||||
it('excludes internal PostgreSQL triggers', () => {
|
||||
expect(tableMetadataSql('postgresql', 'db', 'orders', 'triggers')).toContain('NOT x.tgisinternal');
|
||||
});
|
||||
it('groups composite SQLite foreign keys and includes implicit primary key references', () => {
|
||||
const sql = tableMetadataSql('sqlite', '', 'orders', 'foreignKeys');
|
||||
expect(sql).toContain('GROUP BY id');
|
||||
expect(sql).toContain('coalesce("to",');
|
||||
});
|
||||
it('does not invent queries for unsupported engines', () => {
|
||||
expect(tableMetadataSql('redis', '', 'key', 'indexes')).toBeNull();
|
||||
expect(tableMetadataSql('oracle', '', 'orders', 'triggers')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { relationParts } from './sql';
|
||||
|
||||
export type MetadataKind = 'indexes' | 'foreignKeys' | 'triggers';
|
||||
export const metadataGroups: { kind: MetadataKind; label: string; icon: string; color: string }[] = [
|
||||
{ kind: 'indexes', label: '索引', icon: '◇', color: '#f59e0b' },
|
||||
{ kind: 'foreignKeys', label: '外键', icon: '↗', color: '#3b82f6' },
|
||||
{ kind: 'triggers', label: '触发器', icon: 'ϟ', color: '#f97316' },
|
||||
];
|
||||
const literal = (s: string) => `'${s.replace(/'/g, "''")}'`;
|
||||
|
||||
/** 仅生成只读目录查询;未适配的引擎返回 null,不伪装成空列表。 */
|
||||
export function tableMetadataSql(driver: string, database: string, table: string, kind: MetadataKind): string | null {
|
||||
const d = driver.toLowerCase();
|
||||
const t = literal(table);
|
||||
if (['mysql', 'mariadb', 'tidb', 'oceanbase', 'polardb', 'tdsql'].includes(d)) {
|
||||
// 使用十六进制文本值,避免 MySQL SQL 模式对反斜线转义的差异。
|
||||
const hex = (value: string) => `CONVERT(X'${Array.from(new TextEncoder().encode(value), b => b.toString(16).padStart(2, '0')).join('')}' USING utf8mb4)`;
|
||||
const t = hex(table), db = hex(database);
|
||||
if (kind === 'indexes') return `SELECT INDEX_NAME AS name, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ', ') AS detail FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=${db} AND TABLE_NAME=${t} GROUP BY INDEX_NAME ORDER BY INDEX_NAME`;
|
||||
if (kind === 'foreignKeys') return `SELECT CONSTRAINT_NAME AS name, GROUP_CONCAT(CONCAT(COLUMN_NAME, ' → ', REFERENCED_TABLE_SCHEMA, '.', REFERENCED_TABLE_NAME, '.', REFERENCED_COLUMN_NAME) ORDER BY ORDINAL_POSITION SEPARATOR ', ') AS detail FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=${db} AND TABLE_NAME=${t} AND REFERENCED_TABLE_NAME IS NOT NULL GROUP BY CONSTRAINT_NAME ORDER BY CONSTRAINT_NAME`;
|
||||
return `SELECT TRIGGER_NAME AS name, CONCAT(ACTION_TIMING, ' ', EVENT_MANIPULATION, ': ', ACTION_STATEMENT) AS detail FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=${db} AND EVENT_OBJECT_TABLE=${t} ORDER BY TRIGGER_NAME`;
|
||||
}
|
||||
if (['postgresql', 'postgres', 'gaussdb', 'kingbase', 'opengauss'].includes(d)) {
|
||||
const parts = relationParts(d, database, table);
|
||||
if (parts.length !== 2) return null;
|
||||
const schema = literal(parts[0]), name = literal(parts[1]);
|
||||
if (kind === 'indexes') return `SELECT indexname AS name, indexdef AS detail FROM pg_catalog.pg_indexes WHERE schemaname=${schema} AND tablename=${name} ORDER BY indexname`;
|
||||
const target = `JOIN pg_catalog.pg_class c ON c.oid=x.${kind === 'foreignKeys' ? 'conrelid' : 'tgrelid'} JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=${schema} AND c.relname=${name}`;
|
||||
if (kind === 'foreignKeys') return `SELECT x.conname AS name, pg_catalog.pg_get_constraintdef(x.oid) AS detail FROM pg_catalog.pg_constraint x ${target} AND x.contype='f' ORDER BY x.conname`;
|
||||
return `SELECT x.tgname AS name, pg_catalog.pg_get_triggerdef(x.oid) AS detail FROM pg_catalog.pg_trigger x ${target} AND NOT x.tgisinternal ORDER BY x.tgname`;
|
||||
}
|
||||
if (d === 'sqlite') {
|
||||
if (kind === 'indexes') return `SELECT name, CASE WHEN "unique"=1 THEN 'UNIQUE' ELSE '' END AS detail FROM pragma_index_list(${t}) ORDER BY name`;
|
||||
if (kind === 'foreignKeys') return `SELECT 'FK ' || id AS name, group_concat("from" || ' → ' || "table" || '.' || coalesce("to", '(主键)'), ', ') AS detail FROM pragma_foreign_key_list(${t}) GROUP BY id ORDER BY id`;
|
||||
return `SELECT name, sql AS detail FROM sqlite_master WHERE type='trigger' AND tbl_name=${t} ORDER BY name`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user