chore: 恢复旧布局基线(迁移改动丢失后重建,apps/packages 布局暂存)
This commit is contained in:
1 parent
a9510235bb
commit
456fa6954b
203 files changed
+18787
-48
No files matched your search
+950
@@ -0,0 +1,950 @@
|
||||
import { create } from "zustand";
|
||||
import type { DriverId } from "./catalog";
|
||||
import { driverById } from "./catalog";
|
||||
import { formatDbError, notifyId, type NotifyKind, type NotifyPayload } from "./notify";
|
||||
import { decodeSecret, encodeSecret } from "./secrets";
|
||||
import { dropSchemaCache } from "./api";
|
||||
|
||||
export type SavedConnection = {
|
||||
id: string;
|
||||
name: string;
|
||||
driver: DriverId;
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
password: string;
|
||||
database: string;
|
||||
lastUsedAt?: number;
|
||||
/** 侧栏分组 id,缺省 default */
|
||||
groupId?: string;
|
||||
/** 组内排序,越小越靠前 */
|
||||
sortOrder?: number;
|
||||
ssl?: boolean;
|
||||
/** disable|require|skip-verify|verify-ca;关 SSL 时视为 disable */
|
||||
sslMode?: "disable" | "require" | "skip-verify" | "verify-ca";
|
||||
sslCA?: string;
|
||||
sslCert?: string;
|
||||
sslKey?: string;
|
||||
service?: string;
|
||||
color?: string;
|
||||
/** 连库页默认连接 */
|
||||
isDefault?: boolean;
|
||||
/** 是否持久化密码,默认 true */
|
||||
savePassword?: boolean;
|
||||
/** Redis key 分隔符,默认 : */
|
||||
keySeparator?: string;
|
||||
redisMode?: "standalone" | "sentinel" | "cluster";
|
||||
/** Sentinel/Cluster 节点列表 host:port(多行) */
|
||||
redisNodes?: string;
|
||||
/** Sentinel 主节点名 */
|
||||
masterName?: string;
|
||||
/** Sentinel 密码(可空) */
|
||||
sentinelPassword?: string;
|
||||
/** Oracle */
|
||||
oracleConnType?: "basic";
|
||||
oracleIdKind?: "service" | "sid";
|
||||
oracleRole?: string;
|
||||
/** SQL Server 验证方式 */
|
||||
authMode?: "sql" | "windows";
|
||||
/** SSH 隧道 */
|
||||
sshEnabled?: boolean;
|
||||
sshHost?: string;
|
||||
sshPort?: number;
|
||||
sshUser?: string;
|
||||
sshAuth?: "password" | "privateKey";
|
||||
sshPassword?: string;
|
||||
sshPrivateKey?: string;
|
||||
sshPassphrase?: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_CONN_GROUP = "default";
|
||||
|
||||
export type ConnectionGroup = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type QueryTab = {
|
||||
id: string;
|
||||
title: string;
|
||||
sql: string;
|
||||
connectionId?: string;
|
||||
database?: string;
|
||||
/** 相对打开/保存基线有改动 */
|
||||
dirty?: boolean;
|
||||
savedSql?: string;
|
||||
};
|
||||
|
||||
export type QueryResult = {
|
||||
columns: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
rowCount: number;
|
||||
timeMs: number;
|
||||
error?: string;
|
||||
truncated?: boolean;
|
||||
limit?: number;
|
||||
/** C5:服务端回传的分页偏移 */
|
||||
offset?: number;
|
||||
/** Redis 值游标分页:>0 还有更多(hash/set=SCAN 游标;list/zset=偏移) */
|
||||
nextCursor?: number;
|
||||
/** C1:命中危险语句,需确认后带 confirmDanger 重发 */
|
||||
needConfirm?: boolean;
|
||||
};
|
||||
|
||||
export type SchemaView = {
|
||||
database: string;
|
||||
table: string;
|
||||
columns: ColumnInfo[];
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ResultMeta = {
|
||||
database?: string;
|
||||
table?: string;
|
||||
pkCols?: string[];
|
||||
/** Redis 键详情:打开键时带类型/TTL,命令结果不带 */
|
||||
redisType?: string;
|
||||
redisTtl?: number;
|
||||
};
|
||||
|
||||
export type ConnState = "ok" | "down" | "reconnecting";
|
||||
|
||||
export type ResultSetItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
sql: string;
|
||||
result: QueryResult;
|
||||
/** C5:分页状态(仅单条 SELECT 启用);page 从 0 起 */
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
/** 执行时使用的库(翻页重发需要) */
|
||||
database?: string;
|
||||
/** 列宽/选中态等视图持久化(修 G3,resultSet 级) */
|
||||
viewState?: {
|
||||
colWidths?: Record<string, number>;
|
||||
selectedIdx?: number | null;
|
||||
selectedCol?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type Bookmark = {
|
||||
id: string;
|
||||
name: string;
|
||||
sql: string;
|
||||
at: number;
|
||||
};
|
||||
|
||||
export type ColumnInfo = {
|
||||
name: string;
|
||||
type: string;
|
||||
nullable: boolean;
|
||||
key?: string;
|
||||
default?: unknown;
|
||||
comment?: string;
|
||||
};
|
||||
|
||||
export type TreeNode = {
|
||||
name: string;
|
||||
kind: "database" | "group" | "table" | "view" | "procedure" | "function" | "column" | "key" | "folder";
|
||||
/** group id: tables|views|procedures|functions|keys|folder */
|
||||
groupId?: string;
|
||||
count?: number;
|
||||
children?: TreeNode[];
|
||||
loaded?: boolean;
|
||||
loading?: boolean;
|
||||
type?: string;
|
||||
/** Redis TTL seconds; -1 permanent; -2 missing */
|
||||
ttl?: number;
|
||||
/** Redis 文件夹前缀(含分隔符),如 CEX:FOLLOW: */
|
||||
fullName?: string;
|
||||
nullable?: boolean;
|
||||
key?: string;
|
||||
};
|
||||
|
||||
export type QueryHistoryItem = {
|
||||
id: string;
|
||||
sql: string;
|
||||
at: number;
|
||||
connectionId?: string;
|
||||
rowCount?: number;
|
||||
timeMs?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ConfirmRequest = {
|
||||
title: string;
|
||||
message: string;
|
||||
okLabel?: string;
|
||||
danger?: boolean;
|
||||
/** C6:将执行的语句清单(危险确认二段流展示,前 3 条 + 折叠其余) */
|
||||
items?: string[];
|
||||
};
|
||||
|
||||
export type AiMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
at: string;
|
||||
};
|
||||
|
||||
export type ViewMode = "workbench" | "picker";
|
||||
export type PickerMode = "list" | "drivers";
|
||||
|
||||
type State = {
|
||||
theme: "dark" | "light";
|
||||
view: ViewMode;
|
||||
connections: SavedConnection[];
|
||||
connGroups: ConnectionGroup[];
|
||||
expandedConnGroups: string[];
|
||||
activeConnectionId: string | null;
|
||||
tree: TreeNode[];
|
||||
treeLoading: boolean;
|
||||
tabs: QueryTab[];
|
||||
activeTabId: string;
|
||||
result: QueryResult | null;
|
||||
resultSets: ResultSetItem[];
|
||||
activeResultSetId: string | null;
|
||||
resultMeta: ResultMeta | null;
|
||||
schemaView: SchemaView | null;
|
||||
resultTab: "data" | "schema" | "explain";
|
||||
explainText: string | null;
|
||||
bookmarks: Bookmark[];
|
||||
showBookmarks: boolean;
|
||||
connState: ConnState;
|
||||
/** 左栏底部统一搜索:非 Redis=过滤库/表;Redis=过滤键(回车服务端模糊扫描) */
|
||||
sidebarFilter: string;
|
||||
/** Redis 类型筛选 */
|
||||
sidebarTypeFilter: string;
|
||||
running: boolean;
|
||||
pickerSearch: string;
|
||||
pickerCategory: string;
|
||||
pickerDriver: DriverId | null;
|
||||
pickerMode: PickerMode;
|
||||
form: Partial<SavedConnection>;
|
||||
aiMessages: AiMessage[];
|
||||
aiAsk: string;
|
||||
aiModel: string;
|
||||
aiBusy: boolean;
|
||||
status: string;
|
||||
notifyModal: NotifyPayload | null;
|
||||
notifyToasts: NotifyPayload[];
|
||||
sessions: Record<string, string>;
|
||||
queryHistory: QueryHistoryItem[];
|
||||
leftWidth: number;
|
||||
aiWidth: number;
|
||||
resultRatio: number;
|
||||
showAi: boolean;
|
||||
showHelp: boolean;
|
||||
showHistory: boolean;
|
||||
confirm: ConfirmRequest | null;
|
||||
/** R2-F1:多语句执行遇错即停(默认 true);false 时全部执行完再汇总 */
|
||||
stopOnError: boolean;
|
||||
/** R2-F8:语句执行超时(ms),0 = 不超时;持久化到 prefs */
|
||||
execTimeoutMs: number;
|
||||
/** 对象树最近聚焦的表/视图(编辑器右键「查看数据/DDL」用) */
|
||||
focusedRelation: { database: string; table: string } | null;
|
||||
/** 请求 ObjectTree 打开 DDL 弹层 */
|
||||
ddlRequest: { database: string; table: string; token: number } | null;
|
||||
/** 请求 ObjectTree 打开表数据预览 */
|
||||
viewDataRequest: { database: string; table: string; token: number } | null;
|
||||
|
||||
set: <K extends keyof State>(key: K, value: State[K]) => void;
|
||||
notify: (input: {
|
||||
kind?: NotifyKind;
|
||||
title?: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
tips?: string[];
|
||||
mode?: "modal" | "toast";
|
||||
durationMs?: number;
|
||||
raw?: unknown;
|
||||
}) => void;
|
||||
notifyError: (raw: unknown, mode?: "modal" | "toast") => void;
|
||||
dismissNotify: (id: string) => void;
|
||||
loadConnections: () => void;
|
||||
saveConnections: (list: SavedConnection[]) => void;
|
||||
upsertConnection: (c: SavedConnection) => void;
|
||||
removeConnection: (id: string) => void;
|
||||
setDefaultConnection: (id: string) => void;
|
||||
addConnGroup: (name: string) => string;
|
||||
/** R2-F5:导入连接时并入分组(冲突/缺失 id 重新生成);默认分组不重复创建 */
|
||||
importConnGroups: (groups: ConnectionGroup[]) => void;
|
||||
renameConnGroup: (id: string, name: string) => void;
|
||||
removeConnGroup: (id: string) => void;
|
||||
moveConnection: (id: string, groupId: string, index: number) => void;
|
||||
toggleConnGroup: (id: string) => void;
|
||||
openPicker: (driver?: DriverId | null) => void;
|
||||
openConnectionList: () => void;
|
||||
editConnection: (c: SavedConnection) => void;
|
||||
newQueryTab: () => void;
|
||||
/** 重命名当前激活的查询 Tab(如打开表后标题设为表名) */
|
||||
setActiveTabTitle: (title: string) => void;
|
||||
/** 设置当前 Tab 的库上下文(Redis 编辑器 SELECT/USE 切库用) */
|
||||
setTabDatabase: (database: string) => void;
|
||||
closeTab: (id: string) => void;
|
||||
/** R2-F6:Tab 右键菜单批量关闭(脏确认由调用侧负责) */
|
||||
closeOtherTabs: (id: string) => void;
|
||||
closeRightTabs: (id: string) => void;
|
||||
closeAllTabs: () => void;
|
||||
setStopOnError: (v: boolean) => void;
|
||||
setExecTimeoutMs: (v: number) => void;
|
||||
updateActiveSql: (sql: string) => void;
|
||||
pushAi: (role: AiMessage["role"], content: string) => void;
|
||||
setSession: (connectionId: string, sessionId: string | null) => void;
|
||||
pushHistory: (item: Omit<QueryHistoryItem, "id" | "at"> & { at?: number }) => void;
|
||||
clearHistory: () => void;
|
||||
setResultSets: (items: ResultSetItem[]) => void;
|
||||
selectResultSet: (id: string) => void;
|
||||
/** C5/G3:局部更新某个结果集(翻页结果、视图持久化);若为激活项同步 result */
|
||||
updateResultSet: (id: string, patch: Partial<Omit<ResultSetItem, "id">>) => void;
|
||||
loadBookmarks: () => void;
|
||||
saveBookmark: (name: string, sql: string) => void;
|
||||
renameBookmark: (id: string, name: string) => void;
|
||||
removeBookmark: (id: string) => void;
|
||||
askConfirm: (req: ConfirmRequest) => Promise<boolean>;
|
||||
resolveConfirm: (ok: boolean) => void;
|
||||
persistPrefs: () => void;
|
||||
};
|
||||
|
||||
const LS_KEY = "opsxdb.connections.v1";
|
||||
const LS_GROUPS = "opsxdb.conn-groups.v1";
|
||||
const LS_EXPANDED = "opsxdb.conn-groups-expanded.v1";
|
||||
const LS_PREFS = "opsxdb.prefs.v1";
|
||||
const LS_TABS = "opsxdb.tabs.v1";
|
||||
const LS_HISTORY = "opsxdb.history.v1";
|
||||
const LS_BOOKMARKS = "opsxdb.bookmarks.v1";
|
||||
|
||||
let confirmResolver: ((ok: boolean) => void) | null = null;
|
||||
|
||||
function loadPrefs(): Partial<Pick<State, "theme" | "leftWidth" | "aiWidth" | "resultRatio" | "showAi" | "stopOnError" | "execTimeoutMs">> {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_PREFS);
|
||||
if (!raw) return {};
|
||||
const p = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
theme: p.theme === "light" ? "light" : "dark",
|
||||
leftWidth: typeof p.leftWidth === "number" ? Math.min(360, Math.max(200, p.leftWidth)) : undefined,
|
||||
aiWidth: typeof p.aiWidth === "number" ? p.aiWidth : undefined,
|
||||
resultRatio: typeof p.resultRatio === "number" ? p.resultRatio : undefined,
|
||||
showAi: typeof p.showAi === "boolean" ? p.showAi : undefined,
|
||||
stopOnError: typeof p.stopOnError === "boolean" ? p.stopOnError : undefined,
|
||||
execTimeoutMs: typeof p.execTimeoutMs === "number" ? Math.min(3_600_000, Math.max(0, p.execTimeoutMs)) : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function loadTabs(): { tabs: QueryTab[]; activeTabId: string } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_TABS);
|
||||
if (!raw) return null;
|
||||
const p = JSON.parse(raw) as { tabs?: QueryTab[]; activeTabId?: string };
|
||||
if (!Array.isArray(p.tabs) || !p.tabs.length) return null;
|
||||
const tabs = p.tabs.map((tab) => {
|
||||
const savedSql = tab.savedSql ?? tab.sql ?? "";
|
||||
const sql = tab.sql ?? "";
|
||||
return { ...tab, sql, savedSql, dirty: sql !== savedSql };
|
||||
});
|
||||
return { tabs, activeTabId: p.activeTabId || tabs[0].id };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadHistory(): QueryHistoryItem[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_HISTORY);
|
||||
if (!raw) return [];
|
||||
const p = JSON.parse(raw);
|
||||
return Array.isArray(p) ? p.slice(0, 50) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function loadGroups(): ConnectionGroup[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_GROUPS);
|
||||
if (!raw) return [{ id: DEFAULT_CONN_GROUP, name: "默认" }];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed) || !parsed.length) return [{ id: DEFAULT_CONN_GROUP, name: "默认" }];
|
||||
const list = parsed.filter((g: ConnectionGroup) => g && g.id && g.name);
|
||||
if (!list.some((g: ConnectionGroup) => g.id === DEFAULT_CONN_GROUP)) {
|
||||
list.unshift({ id: DEFAULT_CONN_GROUP, name: "默认" });
|
||||
}
|
||||
return list;
|
||||
} catch {
|
||||
return [{ id: DEFAULT_CONN_GROUP, name: "默认" }];
|
||||
}
|
||||
}
|
||||
|
||||
function saveGroups(list: ConnectionGroup[]) {
|
||||
localStorage.setItem(LS_GROUPS, JSON.stringify(list));
|
||||
}
|
||||
|
||||
function loadExpanded(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_EXPANDED);
|
||||
if (!raw) return [DEFAULT_CONN_GROUP];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [DEFAULT_CONN_GROUP];
|
||||
} catch {
|
||||
return [DEFAULT_CONN_GROUP];
|
||||
}
|
||||
}
|
||||
|
||||
function loadLs(): SavedConnection[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function defaultTab(): QueryTab {
|
||||
// 新 Tab 默认空白(修 T3),不再预置 SELECT 1 AS ok;
|
||||
return { id: crypto.randomUUID(), title: "查询 1", sql: "", savedSql: "", dirty: false };
|
||||
}
|
||||
|
||||
export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
theme: "dark",
|
||||
view: "workbench",
|
||||
connections: [],
|
||||
connGroups: [{ id: DEFAULT_CONN_GROUP, name: "默认" }],
|
||||
expandedConnGroups: [DEFAULT_CONN_GROUP],
|
||||
activeConnectionId: null,
|
||||
tree: [],
|
||||
treeLoading: false,
|
||||
tabs: [defaultTab()],
|
||||
activeTabId: "",
|
||||
result: null,
|
||||
resultSets: [],
|
||||
activeResultSetId: null,
|
||||
resultMeta: null,
|
||||
schemaView: null,
|
||||
resultTab: "data",
|
||||
explainText: null,
|
||||
bookmarks: [],
|
||||
showBookmarks: false,
|
||||
connState: "ok",
|
||||
sidebarFilter: "",
|
||||
sidebarTypeFilter: "all",
|
||||
running: false,
|
||||
pickerSearch: "",
|
||||
pickerCategory: "全部",
|
||||
pickerDriver: null,
|
||||
pickerMode: "list",
|
||||
form: {},
|
||||
aiMessages: [],
|
||||
aiAsk: "",
|
||||
aiModel: "opsxdb-stub",
|
||||
aiBusy: false,
|
||||
status: "就绪",
|
||||
notifyModal: null,
|
||||
notifyToasts: [],
|
||||
sessions: {},
|
||||
queryHistory: [],
|
||||
leftWidth: 260,
|
||||
aiWidth: 280,
|
||||
resultRatio: 0.38,
|
||||
showAi: true,
|
||||
showHelp: false,
|
||||
showHistory: false,
|
||||
confirm: null,
|
||||
stopOnError: true,
|
||||
execTimeoutMs: 0,
|
||||
focusedRelation: null,
|
||||
ddlRequest: null,
|
||||
viewDataRequest: null,
|
||||
|
||||
set: (key, value) => {
|
||||
set({ [key]: value } as Partial<State>);
|
||||
if (key === "theme" || key === "leftWidth" || key === "aiWidth" || key === "resultRatio" || key === "showAi" || key === "stopOnError" || key === "execTimeoutMs") {
|
||||
get().persistPrefs();
|
||||
}
|
||||
if (key === "tabs" || key === "activeTabId") {
|
||||
const s = get();
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: s.tabs, activeTabId: s.activeTabId }));
|
||||
}
|
||||
},
|
||||
|
||||
notify: (input) => {
|
||||
const parsed = input.raw !== undefined ? formatDbError(input.raw) : null;
|
||||
const kind = input.kind ?? parsed?.kind ?? "info";
|
||||
const mode = input.mode ?? (kind === "error" ? "modal" : "toast");
|
||||
const payload: NotifyPayload = {
|
||||
id: notifyId(),
|
||||
kind,
|
||||
title: input.title ?? parsed?.title ?? (kind === "error" ? "错误" : "提示"),
|
||||
message: input.message || parsed?.message || "",
|
||||
detail: input.detail ?? parsed?.detail,
|
||||
tips: input.tips ?? parsed?.tips,
|
||||
mode,
|
||||
durationMs: input.durationMs,
|
||||
};
|
||||
if (mode === "modal") {
|
||||
set({ notifyModal: payload });
|
||||
} else {
|
||||
set((s) => ({ notifyToasts: [...s.notifyToasts.slice(-4), payload] }));
|
||||
}
|
||||
},
|
||||
|
||||
notifyError: (raw, mode = "modal") => {
|
||||
const parsed = formatDbError(raw);
|
||||
const payload: NotifyPayload = {
|
||||
id: notifyId(),
|
||||
kind: parsed.kind,
|
||||
title: parsed.title,
|
||||
message: parsed.message,
|
||||
detail: parsed.detail,
|
||||
tips: parsed.tips,
|
||||
mode,
|
||||
};
|
||||
if (mode === "modal") set({ notifyModal: payload });
|
||||
else set((s) => ({ notifyToasts: [...s.notifyToasts.slice(-4), payload] }));
|
||||
},
|
||||
|
||||
dismissNotify: (id) => {
|
||||
const s = get();
|
||||
if (s.notifyModal?.id === id) set({ notifyModal: null });
|
||||
set({ notifyToasts: get().notifyToasts.filter((t) => t.id !== id) });
|
||||
},
|
||||
|
||||
loadConnections: () => {
|
||||
const list = loadLs().map((c) => ({
|
||||
...c,
|
||||
groupId: c.groupId || DEFAULT_CONN_GROUP,
|
||||
sortOrder: c.sortOrder ?? 0,
|
||||
}));
|
||||
const prefs = loadPrefs();
|
||||
const savedTabs = loadTabs();
|
||||
const tabs = savedTabs?.tabs ?? get().tabs;
|
||||
const activeTabId = savedTabs?.activeTabId || get().activeTabId || tabs[0]?.id || "";
|
||||
set({
|
||||
connections: list,
|
||||
connGroups: loadGroups(),
|
||||
expandedConnGroups: loadExpanded(),
|
||||
tabs,
|
||||
activeTabId,
|
||||
queryHistory: loadHistory(),
|
||||
...prefs,
|
||||
});
|
||||
},
|
||||
|
||||
saveConnections: (list) => {
|
||||
const encoded = list.map((c) => ({
|
||||
...c,
|
||||
password: encodeSecret(c.password || ""),
|
||||
sshPassword: c.sshPassword ? encodeSecret(c.sshPassword) : c.sshPassword,
|
||||
sshPassphrase: c.sshPassphrase ? encodeSecret(c.sshPassphrase) : c.sshPassphrase,
|
||||
}));
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(encoded));
|
||||
set({ connections: encoded });
|
||||
},
|
||||
|
||||
upsertConnection: (c) => {
|
||||
const next = {
|
||||
...c,
|
||||
groupId: c.groupId || DEFAULT_CONN_GROUP,
|
||||
sortOrder: c.sortOrder ?? Date.now(),
|
||||
password: c.savePassword === false ? "" : c.password,
|
||||
sshPassword: c.savePassword === false ? "" : c.sshPassword,
|
||||
};
|
||||
let list = [...get().connections];
|
||||
// 同名防护:不同 id 同名时自动加序号后缀,避免侧栏出现重名条目
|
||||
const nameTaken = (n: string) => list.some((x) => x.id !== next.id && x.name === n);
|
||||
if (nameTaken(next.name)) {
|
||||
let k = 2;
|
||||
while (nameTaken(`${next.name} ${k}`)) k++;
|
||||
next.name = `${next.name} ${k}`;
|
||||
}
|
||||
if (next.isDefault) {
|
||||
list = list.map((x) => ({ ...x, isDefault: x.id === next.id ? true : false }));
|
||||
}
|
||||
const i = list.findIndex((x) => x.id === next.id);
|
||||
if (i >= 0) list[i] = { ...list[i], ...next };
|
||||
else list.unshift(next);
|
||||
get().saveConnections(list);
|
||||
},
|
||||
|
||||
removeConnection: (id) => {
|
||||
get().saveConnections(get().connections.filter((c) => c.id !== id));
|
||||
dropSchemaCache(id);
|
||||
const sessions = { ...get().sessions };
|
||||
delete sessions[id];
|
||||
if (get().activeConnectionId === id) set({ activeConnectionId: null, tree: [], sessions });
|
||||
else set({ sessions });
|
||||
},
|
||||
|
||||
setDefaultConnection: (id) => {
|
||||
const list = get().connections.map((c) => ({ ...c, isDefault: c.id === id }));
|
||||
get().saveConnections(list);
|
||||
},
|
||||
|
||||
|
||||
addConnGroup: (name) => {
|
||||
const n = name.trim() || "新分组";
|
||||
const id = crypto.randomUUID();
|
||||
const list = [...get().connGroups, { id, name: n }];
|
||||
saveGroups(list);
|
||||
const expanded = Array.from(new Set([...get().expandedConnGroups, id]));
|
||||
localStorage.setItem(LS_EXPANDED, JSON.stringify(expanded));
|
||||
set({ connGroups: list, expandedConnGroups: expanded });
|
||||
return id;
|
||||
},
|
||||
|
||||
/** R2-F5:导入连接时并入分组;与现有 id 冲突或缺失时重新生成,避免串组 */
|
||||
importConnGroups: (groups) => {
|
||||
if (!Array.isArray(groups) || !groups.length) return;
|
||||
const cur = get().connGroups;
|
||||
const ids = new Set(cur.map((g) => g.id));
|
||||
const merged = [...cur];
|
||||
for (const g of groups) {
|
||||
if (!g || typeof g.name !== "string" || !g.name.trim()) continue;
|
||||
if (g.id === DEFAULT_CONN_GROUP) continue;
|
||||
const item = { id: g.id && !ids.has(g.id) ? g.id : crypto.randomUUID(), name: g.name.trim() };
|
||||
ids.add(item.id);
|
||||
merged.push(item);
|
||||
}
|
||||
saveGroups(merged);
|
||||
set({ connGroups: merged });
|
||||
},
|
||||
|
||||
renameConnGroup: (id, name) => {
|
||||
if (id === DEFAULT_CONN_GROUP) return;
|
||||
const n = name.trim();
|
||||
if (!n) return;
|
||||
const list = get().connGroups.map((g) => (g.id === id ? { ...g, name: n } : g));
|
||||
saveGroups(list);
|
||||
set({ connGroups: list });
|
||||
},
|
||||
|
||||
removeConnGroup: (id) => {
|
||||
if (id === DEFAULT_CONN_GROUP) return;
|
||||
const list = get().connGroups.filter((g) => g.id !== id);
|
||||
saveGroups(list);
|
||||
const connections = get().connections.map((c) =>
|
||||
(c.groupId || DEFAULT_CONN_GROUP) === id ? { ...c, groupId: DEFAULT_CONN_GROUP } : c,
|
||||
);
|
||||
get().saveConnections(connections);
|
||||
const expanded = get().expandedConnGroups.filter((x) => x !== id);
|
||||
localStorage.setItem(LS_EXPANDED, JSON.stringify(expanded));
|
||||
set({ connGroups: list, expandedConnGroups: expanded });
|
||||
},
|
||||
|
||||
moveConnection: (id, groupId, index) => {
|
||||
const gid = groupId || DEFAULT_CONN_GROUP;
|
||||
const all = [...get().connections];
|
||||
const moving = all.find((c) => c.id === id);
|
||||
if (!moving) return;
|
||||
const othersInGroup = all
|
||||
.filter((c) => c.id !== id && (c.groupId || DEFAULT_CONN_GROUP) === gid)
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
const clamped = Math.max(0, Math.min(index, othersInGroup.length));
|
||||
othersInGroup.splice(clamped, 0, { ...moving, groupId: gid });
|
||||
othersInGroup.forEach((c, i) => {
|
||||
c.sortOrder = i;
|
||||
});
|
||||
const rest = all.filter((c) => c.id !== id && (c.groupId || DEFAULT_CONN_GROUP) !== gid);
|
||||
get().saveConnections([...rest, ...othersInGroup]);
|
||||
if (!get().expandedConnGroups.includes(gid)) {
|
||||
const expanded = [...get().expandedConnGroups, gid];
|
||||
localStorage.setItem(LS_EXPANDED, JSON.stringify(expanded));
|
||||
set({ expandedConnGroups: expanded });
|
||||
}
|
||||
},
|
||||
|
||||
toggleConnGroup: (id) => {
|
||||
const cur = get().expandedConnGroups;
|
||||
const expanded = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
|
||||
localStorage.setItem(LS_EXPANDED, JSON.stringify(expanded));
|
||||
set({ expandedConnGroups: expanded });
|
||||
},
|
||||
|
||||
openPicker: (driver = null) => {
|
||||
const d = driver ? driverById(driver) : null;
|
||||
set({
|
||||
view: "picker",
|
||||
pickerMode: d ? "drivers" : "drivers",
|
||||
pickerDriver: driver,
|
||||
form: d
|
||||
? {
|
||||
id: crypto.randomUUID(),
|
||||
name: `${d.name} 连接`,
|
||||
driver: d.id,
|
||||
host: d.hints?.host ?? "127.0.0.1",
|
||||
port: d.defaultPort,
|
||||
user: d.hints?.user ?? (d.id === "redis" ? "" : "root"),
|
||||
password: "",
|
||||
database: d.hints?.database ?? "",
|
||||
ssl: false,
|
||||
sslMode: "disable",
|
||||
sslCA: "",
|
||||
sslCert: "",
|
||||
sslKey: "",
|
||||
service: d.id === "oracle" ? (d.hints?.database ?? "ORCL") : "",
|
||||
savePassword: true,
|
||||
keySeparator: d.id === "redis" ? ":" : undefined,
|
||||
redisMode: d.id === "redis" ? "standalone" : undefined,
|
||||
oracleConnType: d.id === "oracle" ? "basic" : undefined,
|
||||
oracleIdKind: d.id === "oracle" ? "service" : undefined,
|
||||
oracleRole: d.id === "oracle" ? "default" : undefined,
|
||||
authMode: d.id === "sqlserver" ? "sql" : undefined,
|
||||
sshEnabled: false,
|
||||
sshPort: 22,
|
||||
sshAuth: "password",
|
||||
}
|
||||
: {},
|
||||
});
|
||||
},
|
||||
|
||||
openConnectionList: () => {
|
||||
set({ view: "picker", pickerMode: "list", pickerDriver: null, form: {} });
|
||||
},
|
||||
|
||||
editConnection: (c) => {
|
||||
set({
|
||||
view: "picker",
|
||||
pickerMode: "drivers",
|
||||
pickerDriver: c.driver,
|
||||
form: {
|
||||
...c,
|
||||
password: decodeSecret(c.password),
|
||||
sshPassword: c.sshPassword ? decodeSecret(c.sshPassword) : "",
|
||||
sshPassphrase: c.sshPassphrase ? decodeSecret(c.sshPassphrase) : "",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
newQueryTab: () => {
|
||||
const n = get().tabs.length + 1;
|
||||
const tab: QueryTab = {
|
||||
id: crypto.randomUUID(),
|
||||
title: `查询 ${n}`,
|
||||
sql: "",
|
||||
savedSql: "",
|
||||
dirty: false,
|
||||
connectionId: get().activeConnectionId ?? undefined,
|
||||
};
|
||||
const tabs = [...get().tabs, tab];
|
||||
set({ tabs, activeTabId: tab.id });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: tab.id }));
|
||||
},
|
||||
|
||||
setActiveTabTitle: (title) => {
|
||||
const s = get();
|
||||
const tabs = s.tabs.map((t) => (t.id === s.activeTabId ? { ...t, title } : t));
|
||||
set({ tabs });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: s.activeTabId }));
|
||||
},
|
||||
|
||||
setTabDatabase: (database) => {
|
||||
const s = get();
|
||||
const tabs = s.tabs.map((t) => (t.id === s.activeTabId ? { ...t, database } : t));
|
||||
set({ tabs });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: s.activeTabId }));
|
||||
},
|
||||
|
||||
closeTab: (id) => {
|
||||
const tabs = get().tabs.filter((t) => t.id !== id);
|
||||
const next = tabs.length ? tabs : [defaultTab()];
|
||||
const activeTabId = get().activeTabId === id ? next[0].id : get().activeTabId;
|
||||
set({ tabs: next, activeTabId });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: next, activeTabId }));
|
||||
},
|
||||
|
||||
/** R2-F6:右键菜单「关闭其他」——只保留指定 Tab */
|
||||
closeOtherTabs: (id) => {
|
||||
const tabs = get().tabs.filter((t) => t.id === id);
|
||||
const next = tabs.length ? tabs : [defaultTab()];
|
||||
const activeTabId = next.some((t) => t.id === get().activeTabId) ? get().activeTabId : next[0].id;
|
||||
set({ tabs: next, activeTabId });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: next, activeTabId }));
|
||||
},
|
||||
|
||||
/** R2-F6:右键菜单「关闭右侧」——保留指定 Tab 及其左侧全部 */
|
||||
closeRightTabs: (id) => {
|
||||
const idx = get().tabs.findIndex((t) => t.id === id);
|
||||
if (idx < 0) return;
|
||||
const next = get().tabs.slice(0, idx + 1);
|
||||
const activeTabId = next.some((t) => t.id === get().activeTabId) ? get().activeTabId : next[next.length - 1].id;
|
||||
set({ tabs: next, activeTabId });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: next, activeTabId }));
|
||||
},
|
||||
|
||||
/** R2-F6:右键菜单「关闭全部」——回落一个空白 Tab(与 closeTab 语义一致) */
|
||||
closeAllTabs: () => {
|
||||
const next = [defaultTab()];
|
||||
set({ tabs: next, activeTabId: next[0].id });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: next, activeTabId: next[0].id }));
|
||||
},
|
||||
|
||||
setStopOnError: (v) => {
|
||||
set({ stopOnError: v });
|
||||
get().persistPrefs();
|
||||
},
|
||||
|
||||
setExecTimeoutMs: (v) => {
|
||||
set({ execTimeoutMs: Math.min(3_600_000, Math.max(0, v || 0)) });
|
||||
get().persistPrefs();
|
||||
},
|
||||
|
||||
updateActiveSql: (sql) => {
|
||||
const id = get().activeTabId;
|
||||
const tabs = get().tabs.map((t) => {
|
||||
if (t.id !== id) return t;
|
||||
const saved = t.savedSql ?? "";
|
||||
return { ...t, sql, dirty: sql !== saved };
|
||||
});
|
||||
set({ tabs });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: id }));
|
||||
},
|
||||
|
||||
pushAi: (role, content) =>
|
||||
set((s) => ({
|
||||
aiMessages: [
|
||||
...s.aiMessages,
|
||||
{ id: crypto.randomUUID(), role, content, at: new Date().toLocaleTimeString() },
|
||||
],
|
||||
})),
|
||||
|
||||
setSession: (connectionId, sessionId) => {
|
||||
const sessions = { ...get().sessions };
|
||||
if (sessionId) sessions[connectionId] = sessionId;
|
||||
else delete sessions[connectionId];
|
||||
set({ sessions });
|
||||
},
|
||||
|
||||
pushHistory: (item) => {
|
||||
const norm = (s: string) => s.replace(/\s+/g, " ").trim().toLowerCase();
|
||||
const key = norm(item.sql);
|
||||
const entry: QueryHistoryItem = {
|
||||
id: crypto.randomUUID(),
|
||||
at: item.at ?? Date.now(),
|
||||
sql: item.sql,
|
||||
connectionId: item.connectionId,
|
||||
rowCount: item.rowCount,
|
||||
timeMs: item.timeMs,
|
||||
error: item.error,
|
||||
};
|
||||
// 相同语句去重置顶:重复执行只留最新一次,历史即「最近用过的不同语句」
|
||||
const next = [entry, ...get().queryHistory.filter((h) => norm(h.sql) !== key)].slice(0, 200);
|
||||
localStorage.setItem(LS_HISTORY, JSON.stringify(next));
|
||||
set({ queryHistory: next });
|
||||
},
|
||||
|
||||
clearHistory: () => {
|
||||
set({ queryHistory: [] });
|
||||
localStorage.setItem(LS_HISTORY, JSON.stringify([]));
|
||||
},
|
||||
|
||||
|
||||
setResultSets: (items) => {
|
||||
const active = items[0]?.id ?? null;
|
||||
set({
|
||||
resultSets: items,
|
||||
activeResultSetId: active,
|
||||
result: items[0]?.result ?? null,
|
||||
resultTab: "data",
|
||||
});
|
||||
},
|
||||
|
||||
selectResultSet: (id) => {
|
||||
const item = get().resultSets.find((x) => x.id === id);
|
||||
if (!item) return;
|
||||
set({ activeResultSetId: id, result: item.result, resultTab: "data" });
|
||||
},
|
||||
|
||||
updateResultSet: (id, patch) => {
|
||||
const items = get().resultSets.map((x) => (x.id === id ? { ...x, ...patch } : x));
|
||||
const next: Partial<State> = { resultSets: items };
|
||||
if (get().activeResultSetId === id) {
|
||||
const item = items.find((x) => x.id === id);
|
||||
if (item) next.result = item.result;
|
||||
}
|
||||
set(next);
|
||||
},
|
||||
|
||||
loadBookmarks: () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_BOOKMARKS);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) set({ bookmarks: parsed });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
saveBookmark: (name, sql) => {
|
||||
const n = name.trim();
|
||||
if (!n) return;
|
||||
const list = [{ id: crypto.randomUUID(), name: n, sql, at: Date.now() }, ...get().bookmarks].slice(0, 100);
|
||||
localStorage.setItem(LS_BOOKMARKS, JSON.stringify(list));
|
||||
set({ bookmarks: list });
|
||||
},
|
||||
|
||||
renameBookmark: (id, name) => {
|
||||
const n = name.trim();
|
||||
if (!n) return;
|
||||
const list = get().bookmarks.map((b) => (b.id === id ? { ...b, name: n } : b));
|
||||
localStorage.setItem(LS_BOOKMARKS, JSON.stringify(list));
|
||||
set({ bookmarks: list });
|
||||
},
|
||||
|
||||
removeBookmark: (id) => {
|
||||
const list = get().bookmarks.filter((b) => b.id !== id);
|
||||
localStorage.setItem(LS_BOOKMARKS, JSON.stringify(list));
|
||||
set({ bookmarks: list });
|
||||
},
|
||||
|
||||
askConfirm: (req) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
confirmResolver?.(false);
|
||||
confirmResolver = resolve;
|
||||
set({ confirm: req });
|
||||
}),
|
||||
|
||||
resolveConfirm: (ok) => {
|
||||
const r = confirmResolver;
|
||||
confirmResolver = null;
|
||||
set({ confirm: null });
|
||||
r?.(ok);
|
||||
},
|
||||
|
||||
persistPrefs: () => {
|
||||
const s = get();
|
||||
localStorage.setItem(
|
||||
LS_PREFS,
|
||||
JSON.stringify({
|
||||
theme: s.theme,
|
||||
leftWidth: s.leftWidth,
|
||||
aiWidth: s.aiWidth,
|
||||
resultRatio: s.resultRatio,
|
||||
showAi: s.showAi,
|
||||
stopOnError: s.stopOnError,
|
||||
execTimeoutMs: s.execTimeoutMs,
|
||||
}),
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// init bookmarks
|
||||
try {
|
||||
const _bm = localStorage.getItem(LS_BOOKMARKS);
|
||||
if (_bm) {
|
||||
const parsed = JSON.parse(_bm);
|
||||
if (Array.isArray(parsed)) useWorkspaceStore.setState({ bookmarks: parsed });
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// init activeTabId after create
|
||||
const boot = useWorkspaceStore.getState();
|
||||
if (!boot.activeTabId && boot.tabs[0]) {
|
||||
useWorkspaceStore.setState({ activeTabId: boot.tabs[0].id });
|
||||
}
|
||||
Reference in new issue
Block a user