terminal-bridge-setup 3.1.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/files/extension/background.js +311 -30
- package/files/extension/content-yearning.js +552 -8
- package/files/extension/manifest.json +2 -2
- package/files/extension/popup.html +6 -2
- package/files/extension/popup.js +23 -1
- package/files/proxy/server.js +133 -36
- package/files/proxy/yr-example.mjs +41 -5
- package/files/proxy/yr-sql-guard.js +43 -0
- package/files/skill/SKILL.md +73 -0
- package/files/skill/references/protocol.md +27 -0
- package/package.json +2 -2
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// content script:在 koko connect iframe 里运行(见 manifest content_scripts)
|
|
10
10
|
|
|
11
11
|
// ================= 状态 =================
|
|
12
|
+
// 版本显性化:service worker 每次启动打印,便于确认 Chrome 加载的是最新构建
|
|
13
|
+
console.log("[bg] Terminal Bridge v" + chrome.runtime.getManifest().version + " service worker 启动");
|
|
12
14
|
const attached = {}; // 标记哪些 tab 当前已 attach debugger
|
|
13
15
|
const BRIDGE_WS = "ws://127.0.0.1:8787/ssh";
|
|
14
16
|
let bridgeWs = null;
|
|
@@ -148,13 +150,38 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
|
148
150
|
return;
|
|
149
151
|
}
|
|
150
152
|
chrome.downloads.download(
|
|
151
|
-
{ url: record.dataUrl, filename: record.name, saveAs:
|
|
153
|
+
{ url: record.dataUrl, filename: "yearning-csv/" + record.name, saveAs: false },
|
|
152
154
|
(downloadId) => {
|
|
153
155
|
if (chrome.runtime.lastError) {
|
|
154
156
|
sendResponse({ ok: false, msg: chrome.runtime.lastError.message });
|
|
155
157
|
return;
|
|
156
158
|
}
|
|
157
|
-
sendResponse({ ok: true, downloadId });
|
|
159
|
+
sendResponse({ ok: true, downloadId, path: "~/Downloads/yearning-csv/" + record.name });
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
return true; // 异步
|
|
164
|
+
}
|
|
165
|
+
if (msg.type === "CSV_PROMPT") {
|
|
166
|
+
// 🤖 按钮:静默下载该文件到固定目录,返回让 Agent 读文件的 prompt
|
|
167
|
+
getCsvExports().then(list => {
|
|
168
|
+
const record = list.find(e => e.id === msg.id);
|
|
169
|
+
if (!record) {
|
|
170
|
+
sendResponse({ ok: false, msg: "导出记录不存在" });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
chrome.downloads.download(
|
|
174
|
+
{ url: record.dataUrl, filename: "yearning-csv/" + record.name, saveAs: false },
|
|
175
|
+
(downloadId) => {
|
|
176
|
+
if (chrome.runtime.lastError) {
|
|
177
|
+
sendResponse({ ok: false, msg: chrome.runtime.lastError.message });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const table = record.table || record.name.split("_")[0];
|
|
181
|
+
sendResponse({
|
|
182
|
+
ok: true,
|
|
183
|
+
prompt: `表名${table}的查询结果请读这个文件 ~/Downloads/yearning-csv/${record.name}`,
|
|
184
|
+
});
|
|
158
185
|
}
|
|
159
186
|
);
|
|
160
187
|
});
|
|
@@ -351,7 +378,7 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
|
351
378
|
}
|
|
352
379
|
}
|
|
353
380
|
else if (method === 'Network.webSocketFrameReceived') {
|
|
354
|
-
// recv
|
|
381
|
+
// recv 帧(下行):终端激活 tab 的进代理配对;tap tab 的进代理 tap 通道(都带 url)
|
|
355
382
|
sendToBridge({
|
|
356
383
|
type: 'ws-recv',
|
|
357
384
|
payload: {
|
|
@@ -359,11 +386,14 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
|
359
386
|
opcode: params.response && params.response.opcode,
|
|
360
387
|
url: wsUrls.get(params.requestId) || "",
|
|
361
388
|
tabId,
|
|
389
|
+
dir: 'recv',
|
|
362
390
|
t: Date.now()
|
|
363
391
|
}
|
|
364
392
|
});
|
|
365
393
|
}
|
|
366
394
|
else if (method === 'Network.webSocketFrameSent') {
|
|
395
|
+
// sent 帧(上行):终端 tab 走调试用 ws-send;tap 页面的上行帧(如 Yearning
|
|
396
|
+
// 点「查 询」发出的 msgpack 请求)也走 tap 通道转发,dir=sent 供探针观测
|
|
367
397
|
if (isActive) {
|
|
368
398
|
sendToBridge({
|
|
369
399
|
type: 'ws-send',
|
|
@@ -372,6 +402,19 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
|
372
402
|
opcode: params.response && params.response.opcode,
|
|
373
403
|
url: wsUrls.get(params.requestId) || "",
|
|
374
404
|
tabId,
|
|
405
|
+
dir: 'sent',
|
|
406
|
+
t: Date.now()
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
} else if (isTap) {
|
|
410
|
+
sendToBridge({
|
|
411
|
+
type: 'ws-recv',
|
|
412
|
+
payload: {
|
|
413
|
+
data: extractPayloadData(params.response),
|
|
414
|
+
opcode: params.response && params.response.opcode,
|
|
415
|
+
url: wsUrls.get(params.requestId) || "",
|
|
416
|
+
tabId,
|
|
417
|
+
dir: 'sent',
|
|
375
418
|
t: Date.now()
|
|
376
419
|
}
|
|
377
420
|
});
|
|
@@ -534,10 +577,11 @@ function handleBridgeCommand(frame) {
|
|
|
534
577
|
return;
|
|
535
578
|
}
|
|
536
579
|
|
|
537
|
-
// { type: "yr-cmd", sub
|
|
580
|
+
// { type: "yr-cmd", sub, sql, reqId, tabId, skipNew }
|
|
538
581
|
// Yearning 自动化:代理编排(yr-run),本插件转发到 tap tab 的 content script。
|
|
539
|
-
// sql-set
|
|
540
|
-
//
|
|
582
|
+
// sub: ping | sql-set | query-click | new-sql | db-select | source-switch
|
|
583
|
+
// 编排模式下代理已显式建过查询 tab 时,sql-set 带 skipNew=true 不再重复新建。
|
|
584
|
+
// 非 sql-set 子命令里 sql 字段承载目标参数(db-select 库名 / source-switch 源名)。
|
|
541
585
|
if (frame.type === "yr-cmd") {
|
|
542
586
|
const tabId = frame.tabId != null ? frame.tabId : activeTapTabId;
|
|
543
587
|
if (tabId == null) {
|
|
@@ -545,11 +589,31 @@ function handleBridgeCommand(frame) {
|
|
|
545
589
|
return;
|
|
546
590
|
}
|
|
547
591
|
if (frame.sub === "sql-set") {
|
|
548
|
-
yrSqlSetViaCDP(tabId, frame.sql || "", frame.reqId);
|
|
592
|
+
yrSqlSetViaCDP(tabId, frame.sql || "", frame.reqId, !!frame.skipNew);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
// 选库/切源由 bg 用 CDP 真实鼠标事件编排(合成事件对 antd 无效),不走 content 转发
|
|
596
|
+
if (frame.sub === "db-select") {
|
|
597
|
+
yearningSelectDatabase(tabId, frame.sql || "", frame.reqId);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (frame.sub === "source-switch") {
|
|
601
|
+
yearningSwitchSource(tabId, frame.sql || "", frame.reqId);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
const yrSubPayloads = {
|
|
605
|
+
ping: { type: "yr-ping" },
|
|
606
|
+
"query-click": { type: "yr-query-click" },
|
|
607
|
+
"new-sql": { type: "yr-new-sql" },
|
|
608
|
+
"db-select": { type: "yr-db-select", database: frame.sql },
|
|
609
|
+
"source-switch": { type: "yr-source-switch", target: frame.sql },
|
|
610
|
+
"dom-probe": { type: "yr-dom-probe", selector: frame.sql },
|
|
611
|
+
};
|
|
612
|
+
const payload = yrSubPayloads[frame.sub];
|
|
613
|
+
if (!payload) {
|
|
614
|
+
sendToBridge({ type: "yr-result", reqId: frame.reqId, tabId, ok: false, error: "unknown yr-cmd sub: " + frame.sub });
|
|
549
615
|
return;
|
|
550
616
|
}
|
|
551
|
-
const payload = frame.sub === "ping" ? { type: "yr-ping" }
|
|
552
|
-
: { type: "yr-query-click" };
|
|
553
617
|
chrome.tabs.sendMessage(tabId, payload, { frameId: 0 }, (res) => {
|
|
554
618
|
if (chrome.runtime.lastError) {
|
|
555
619
|
sendToBridge({ type: "yr-result", reqId: frame.reqId, tabId, ok: false, error: "content script 无响应: " + chrome.runtime.lastError.message });
|
|
@@ -574,18 +638,221 @@ function handleBridgeCommand(frame) {
|
|
|
574
638
|
}
|
|
575
639
|
}
|
|
576
640
|
|
|
641
|
+
// ===== Yearning 选库/切源:CDP 真实鼠标点击编排 =====
|
|
642
|
+
// antd Select / 弹层对 content script 的合成 mousedown 不响应(实测下拉展不开),
|
|
643
|
+
// 改用 CDP Input.dispatchMouseEvent 产生与真人一致的受信任点击:
|
|
644
|
+
// content script 只读定位元素坐标 / 枚举下拉、弹层选项;bg 负责按坐标真实点击。
|
|
645
|
+
const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
646
|
+
|
|
647
|
+
function replyYr(reqId, tabId, payload) {
|
|
648
|
+
sendToBridge({ type: "yr-result", reqId, tabId, ...payload });
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function cdpMouseClick(tabId, x, y) {
|
|
652
|
+
const opts = { x, y, button: "left", clickCount: 1 };
|
|
653
|
+
await chrome.debugger.sendCommand({ tabId }, "Input.dispatchMouseEvent", { type: "mouseMoved", ...opts });
|
|
654
|
+
await chrome.debugger.sendCommand({ tabId }, "Input.dispatchMouseEvent", { type: "mousePressed", ...opts });
|
|
655
|
+
await chrome.debugger.sendCommand({ tabId }, "Input.dispatchMouseEvent", { type: "mouseReleased", ...opts });
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function cdpPressKey(tabId, key, code, keyCode) {
|
|
659
|
+
const base = { key, code, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode };
|
|
660
|
+
await chrome.debugger.sendCommand({ tabId }, "Input.dispatchKeyEvent", { type: "keyDown", ...base });
|
|
661
|
+
await chrome.debugger.sendCommand({ tabId }, "Input.dispatchKeyEvent", { type: "keyUp", ...base });
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async function ensureYearningAttach(tabId) {
|
|
665
|
+
if (!attached[tabId]) {
|
|
666
|
+
await new Promise(resolve => attachDebugger(tabId) ?? resolve());
|
|
667
|
+
await sleepMs(400); // 等 Network.enable 完成
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// 选库:点开 Select 下拉 → 枚举选项 → 点目标 → 重读 meta 验证
|
|
672
|
+
async function yearningSelectDatabase(tabId, database, reqId) {
|
|
673
|
+
try {
|
|
674
|
+
await ensureYearningAttach(tabId);
|
|
675
|
+
const wanted = String(database || "").trim().replace(/\s+/g, "");
|
|
676
|
+
const loc = await sendFrameMessage(tabId, { type: "yr-locate", kind: "db-trigger" }, 0);
|
|
677
|
+
if (!loc?.ok || !loc.rect) {
|
|
678
|
+
replyYr(reqId, tabId, { ok: false, error: loc?.error || "库选择器未定位到" });
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
// 分级展开尝试:①程序化 focus + ArrowDown(combobox 标准开法)
|
|
682
|
+
// ②真实鼠标点击(antd 本应响应点击)③Alt+Down
|
|
683
|
+
const stages = [];
|
|
684
|
+
const focusRes = await sendFrameMessage(tabId, { type: "yr-focus-db" }, 0);
|
|
685
|
+
stages.push(`focus:${focusRes?.focused ? "hit" : (focusRes?.ok ? "miss" : "fail")}`);
|
|
686
|
+
let opts = null;
|
|
687
|
+
const pollOpen = async (times = 16) => {
|
|
688
|
+
for (let i = 0; i < times; i++) {
|
|
689
|
+
await sleepMs(150);
|
|
690
|
+
opts = await sendFrameMessage(tabId, { type: "yr-options" }, 0);
|
|
691
|
+
if (opts?.ready) return true;
|
|
692
|
+
}
|
|
693
|
+
return false;
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
if (focusRes?.ok) {
|
|
697
|
+
stages.push("arrowdown");
|
|
698
|
+
await cdpPressKey(tabId, "ArrowDown", "ArrowDown", 40);
|
|
699
|
+
if (!(await pollOpen())) {
|
|
700
|
+
// 关了?重新聚焦再来一次方向键
|
|
701
|
+
await sendFrameMessage(tabId, { type: "yr-focus-db" }, 0);
|
|
702
|
+
await cdpPressKey(tabId, "ArrowDown", "ArrowDown", 40);
|
|
703
|
+
if (!(await pollOpen())) {
|
|
704
|
+
stages.pop(); stages.push("arrowdown×2");
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
if (!opts?.ready) {
|
|
709
|
+
stages.push("click");
|
|
710
|
+
await cdpMouseClick(tabId, loc.rect.x, loc.rect.y);
|
|
711
|
+
if (!(await pollOpen())) {
|
|
712
|
+
// 点击后补一发方向键(此时焦点多半已被点击带到位)
|
|
713
|
+
await cdpPressKey(tabId, "ArrowDown", "ArrowDown", 40);
|
|
714
|
+
if (!(await pollOpen())) stages[stages.length - 1] = "click+down";
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
if (!opts?.ready) {
|
|
718
|
+
stages.push("alt-arrowdown");
|
|
719
|
+
await chrome.debugger.sendCommand({ tabId }, "Input.dispatchKeyEvent", {
|
|
720
|
+
type: "keyDown", key: "ArrowDown", code: "ArrowDown",
|
|
721
|
+
windowsVirtualKeyCode: 40, nativeVirtualKeyCode: 40, modifiers: 1,
|
|
722
|
+
});
|
|
723
|
+
await chrome.debugger.sendCommand({ tabId }, "Input.dispatchKeyEvent", {
|
|
724
|
+
type: "keyUp", key: "ArrowDown", code: "ArrowDown",
|
|
725
|
+
windowsVirtualKeyCode: 40, nativeVirtualKeyCode: 40, modifiers: 1,
|
|
726
|
+
});
|
|
727
|
+
if (!(await pollOpen())) stages[stages.length - 1] = "alt-arrowdown×";
|
|
728
|
+
}
|
|
729
|
+
if (!opts?.ready) {
|
|
730
|
+
const activeEl = await sendFrameMessage(tabId, { type: "yr-active-element" }, 0);
|
|
731
|
+
replyYr(reqId, tabId, {
|
|
732
|
+
ok: false,
|
|
733
|
+
error: "全部展开方式均失败",
|
|
734
|
+
rect: loc.rect,
|
|
735
|
+
activeElement: activeEl || null,
|
|
736
|
+
listboxInDom: opts?.listboxInDom,
|
|
737
|
+
stages,
|
|
738
|
+
});
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
const hit = opts.options.find(o => o.text.replace(/\s+/g, "") === wanted)
|
|
742
|
+
|| opts.options.find(o => o.text.replace(/\s+/g, "").includes(wanted));
|
|
743
|
+
if (!hit) {
|
|
744
|
+
replyYr(reqId, tabId, { ok: false, error: "下拉中无匹配的库", options: opts.options.map(o => o.text).slice(0, 30) });
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
if (hit.disabled) {
|
|
748
|
+
replyYr(reqId, tabId, { ok: false, error: "目标库为禁用状态(无权限)", target: database });
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
// 点选项:content script 在元素上直接派发鼠标事件(无坐标漂移;
|
|
752
|
+
// React onClick 监听根节点,合成冒泡事件有效)
|
|
753
|
+
const clicked = await sendFrameMessage(tabId, { type: "yr-db-click-option", database: wanted }, 0);
|
|
754
|
+
if (!clicked?.ok) {
|
|
755
|
+
replyYr(reqId, tabId, { ok: false, error: clicked?.error || "点击选项失败", options: clicked?.options });
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
for (let i = 0; i < 12; i++) {
|
|
759
|
+
await sleepMs(200);
|
|
760
|
+
const meta = await sendFrameMessage(tabId, { type: "yr-meta" }, 0);
|
|
761
|
+
if (meta?.ok && meta.database && meta.database.replace(/\s+/g, "") === wanted) {
|
|
762
|
+
replyYr(reqId, tabId, { ok: true, via: "cdp-mouse", database: meta.database });
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const finalMeta = await sendFrameMessage(tabId, { type: "yr-meta" }, 0);
|
|
767
|
+
replyYr(reqId, tabId, {
|
|
768
|
+
ok: false,
|
|
769
|
+
error: "已点击选项但验证失败:form 实际库名与目标不符",
|
|
770
|
+
expect: database,
|
|
771
|
+
actual: finalMeta?.database || "",
|
|
772
|
+
options: opts.options.map(o => o.text).slice(0, 30),
|
|
773
|
+
});
|
|
774
|
+
} catch (err) {
|
|
775
|
+
replyYr(reqId, tabId, { ok: false, error: "db-select 异常: " + err.message });
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// 切源:点「切换数据源」入口 → 弹层叶子项匹配目标 → 点击后以 hash 变化验证
|
|
780
|
+
async function yearningSwitchSource(tabId, sourceName, reqId) {
|
|
781
|
+
try {
|
|
782
|
+
await ensureYearningAttach(tabId);
|
|
783
|
+
const wanted = String(sourceName || "").trim().replace(/\s+/g, "");
|
|
784
|
+
// 幂等:已在目标源上(hash 的 source 参数一致)直接返回成功,避免
|
|
785
|
+
// 重复点击后 hash 不变被误判为切换失败
|
|
786
|
+
const curHash0 = (await sendFrameMessage(tabId, { type: "yr-hash" }, 0))?.hash || "";
|
|
787
|
+
if (curHash0.includes("source=" + wanted)) {
|
|
788
|
+
replyYr(reqId, tabId, { ok: true, via: "already-on-source", hash: curHash0 });
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
// 先 Escape 关掉可能残留的弹层(上次未命中的 modal 会挡住入口按钮坐标)
|
|
792
|
+
await cdpPressKey(tabId, "Escape", "Escape", 27);
|
|
793
|
+
await sleepMs(300);
|
|
794
|
+
const beforeHash = (await sendFrameMessage(tabId, { type: "yr-hash" }, 0))?.hash;
|
|
795
|
+
const loc = await sendFrameMessage(tabId, { type: "yr-locate", kind: "entry-button", arg: "切换数据源" }, 0);
|
|
796
|
+
if (!loc?.ok || !loc.rect) {
|
|
797
|
+
replyYr(reqId, tabId, { ok: false, error: "「切换数据源」入口按钮未找到" });
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
await cdpMouseClick(tabId, loc.rect.x, loc.rect.y);
|
|
801
|
+
// 枚举弹层项(元素级,不依赖坐标可见性),命中后元素级派发点击
|
|
802
|
+
let srcItems = null;
|
|
803
|
+
let items = [];
|
|
804
|
+
for (let i = 0; i < 40; i++) {
|
|
805
|
+
await sleepMs(150);
|
|
806
|
+
srcItems = await sendFrameMessage(tabId, { type: "yr-source-items" }, 0);
|
|
807
|
+
items = srcItems?.items || [];
|
|
808
|
+
if (items.length > 0) break;
|
|
809
|
+
}
|
|
810
|
+
if (items.length === 0) {
|
|
811
|
+
await cdpPressKey(tabId, "Escape", "Escape", 27);
|
|
812
|
+
replyYr(reqId, tabId, {
|
|
813
|
+
ok: false,
|
|
814
|
+
error: "弹层未出现或无可选数据源项",
|
|
815
|
+
itemsDebug: srcItems?.debug || "no-response",
|
|
816
|
+
});
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
const clicked = await sendFrameMessage(tabId, { type: "yr-source-click", target: wanted }, 0);
|
|
820
|
+
if (!clicked?.ok) {
|
|
821
|
+
await cdpPressKey(tabId, "Escape", "Escape", 27);
|
|
822
|
+
replyYr(reqId, tabId, { ok: false, error: clicked?.error || "点击数据源项失败", candidates: clicked?.candidates || items.slice(0, 30) });
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
for (let i = 0; i < 24; i++) {
|
|
826
|
+
await sleepMs(250);
|
|
827
|
+
const nowHash = (await sendFrameMessage(tabId, { type: "yr-hash" }, 0))?.hash;
|
|
828
|
+
if (nowHash !== undefined && nowHash !== beforeHash) {
|
|
829
|
+
replyYr(reqId, tabId, { ok: true, via: "element-dispatch", clicked: clicked.clicked, hash: nowHash });
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
const nowHash = (await sendFrameMessage(tabId, { type: "yr-hash" }, 0))?.hash;
|
|
834
|
+
await cdpPressKey(tabId, "Escape", "Escape", 27);
|
|
835
|
+
replyYr(reqId, tabId, { ok: false, error: "已点击候选但路由未变化(hash 不变)", clickedText: clicked.clicked, hash: nowHash });
|
|
836
|
+
} catch (err) {
|
|
837
|
+
replyYr(reqId, tabId, { ok: false, error: "source-switch 异常: " + err.message });
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
577
841
|
// Yearning SQL 通过 CDP Input 注入(monaco 等编辑器接受浏览器信任层级事件)
|
|
578
|
-
async function yrSqlSetViaCDP(tabId, sql, reqId) {
|
|
842
|
+
async function yrSqlSetViaCDP(tabId, sql, reqId, skipNew = false) {
|
|
579
843
|
try {
|
|
580
844
|
// 未 attach 的 tab 上 Input.* 命令会静默无效 → 注入校验失败,先确保 attach
|
|
581
845
|
if (!attached[tabId]) {
|
|
582
846
|
await new Promise(resolve => attachDebugger(tabId) ?? resolve());
|
|
583
847
|
await new Promise(r => setTimeout(r, 400)); // 等 Network.enable 完成
|
|
584
848
|
}
|
|
585
|
-
// 新建 SQL 窗口:避免把 SQL
|
|
586
|
-
|
|
587
|
-
if (!
|
|
588
|
-
|
|
849
|
+
// 新建 SQL 窗口:避免把 SQL 注入用户正在使用的已有编辑器。
|
|
850
|
+
// 编排模式(代理已显式建过 tab 并选好库)由 skipNew 跳过。
|
|
851
|
+
if (!skipNew) {
|
|
852
|
+
const newWin = await sendFrameMessage(tabId, { type: "yr-new-sql" }, 0);
|
|
853
|
+
if (!newWin || !newWin.ok) {
|
|
854
|
+
console.warn("[bg] 新建 SQL 窗口失败(继续在当前编辑器注入):", newWin?.error);
|
|
855
|
+
}
|
|
589
856
|
}
|
|
590
857
|
// 前置校验:数据库未选择时 Yearning 查询必报错,提前失败给明确提示
|
|
591
858
|
const meta = await sendFrameMessage(tabId, { type: "yr-meta" }, 0);
|
|
@@ -677,34 +944,47 @@ function csvCell(value) {
|
|
|
677
944
|
return s;
|
|
678
945
|
}
|
|
679
946
|
|
|
680
|
-
// Yearning 结果 JSON → CSV
|
|
681
|
-
//
|
|
947
|
+
// Yearning 结果 JSON → CSV 文本。多结果集全部写入同一个 CSV,不能只取第一张表:
|
|
948
|
+
// 一次执行多条 SQL 时 results 有多项,popup 行数是总和,文件也必须包含总和。
|
|
949
|
+
// 每组结果独立表头,组间用 Result Set 标记和空行分隔,兼容不同 SQL 的列结构。
|
|
682
950
|
function resultJsonToCsv(jsonText) {
|
|
683
951
|
let obj;
|
|
684
952
|
try { obj = JSON.parse(jsonText); } catch { return null; }
|
|
685
953
|
if (!Array.isArray(obj.results)) return null;
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
table.
|
|
692
|
-
|
|
693
|
-
|
|
954
|
+
|
|
955
|
+
const blocks = [];
|
|
956
|
+
obj.results.forEach((table, index) => {
|
|
957
|
+
if (!table || !Array.isArray(table.field) || table.field.length === 0) return;
|
|
958
|
+
const headers = table.field.map(f => csvCell(f.title || f.dataIndex || ""));
|
|
959
|
+
const rows = (table.data || []).map(row =>
|
|
960
|
+
table.field.map(f => csvCell(row[f.dataIndex])).join(",")
|
|
961
|
+
);
|
|
962
|
+
// 多结果集时保留组标记;单结果集不额外增加噪音
|
|
963
|
+
if (obj.results.filter(t => t && Array.isArray(t.field) && t.field.length > 0).length > 1) {
|
|
964
|
+
blocks.push(`-- Result Set ${index + 1} --`);
|
|
965
|
+
}
|
|
966
|
+
blocks.push(headers.join(","), ...rows, "");
|
|
967
|
+
});
|
|
968
|
+
return blocks.length > 0 ? "\uFEFF" + blocks.join("\r\n") : null;
|
|
694
969
|
}
|
|
695
970
|
|
|
696
971
|
// 文件名:表名_库_数据源_日期。表名从 SQL 提取(from/into/update 后的词),
|
|
697
972
|
// 库和数据源从目标 tab 的 meta(tapTabMeta)取。
|
|
698
|
-
|
|
699
|
-
|
|
973
|
+
// 手动查询(sql 为 manual-query)时先读编辑器里的实际 SQL 再提取表名。
|
|
974
|
+
async function buildCsvFileName(sql, tabId) {
|
|
975
|
+
let sqlText = sql || "";
|
|
976
|
+
if (!sqlText || sqlText === "manual-query") {
|
|
977
|
+
const editor = await sendFrameMessage(tabId, { type: "yr-sql-get" }, 0);
|
|
978
|
+
if (editor?.ok && editor.sql) sqlText = editor.sql; // 手动查询后编辑器里就是刚执行的 SQL
|
|
979
|
+
}
|
|
700
980
|
const tableMatch = sqlText.match(/\b(?:from|into|update|join)\s+[`"]?(\w+)[`"]?/i);
|
|
701
|
-
const table = (tableMatch ? tableMatch[1] : "
|
|
981
|
+
const table = (tableMatch ? tableMatch[1] : "").slice(0, 40) || "query";
|
|
702
982
|
const meta = tapTabMeta.get(tabId) || {};
|
|
703
|
-
const database = (meta.database || "").replace(/[^\w
|
|
983
|
+
const database = (meta.database || "").replace(/[^\w-]+/g, "") || "nodb";
|
|
704
984
|
const source = (meta.dataSource || "").split(" · ")[0].replace(/[^\w.-]+/g, "") || "nosrc";
|
|
705
985
|
const date = new Date().toISOString().slice(0, 10);
|
|
706
986
|
const time = new Date().toTimeString().slice(0, 5).replace(":", "");
|
|
707
|
-
return `${table}_${database}_${source}_${date}_${time}.csv
|
|
987
|
+
return { name: `${table}_${database}_${source}_${date}_${time}.csv`, table };
|
|
708
988
|
}
|
|
709
989
|
|
|
710
990
|
async function handleYrExportCsv(frame) {
|
|
@@ -713,11 +993,12 @@ async function handleYrExportCsv(frame) {
|
|
|
713
993
|
console.warn("[bg] yr-export-csv: 结果 JSON 解析失败或无表结构");
|
|
714
994
|
return;
|
|
715
995
|
}
|
|
716
|
-
const name = buildCsvFileName(frame.sql, frame.tabId);
|
|
996
|
+
const { name, table } = await buildCsvFileName(frame.sql, frame.tabId);
|
|
717
997
|
const dataUrl = "data:text/csv;charset=utf-8," + encodeURIComponent(csv);
|
|
718
998
|
await addCsvExport({
|
|
719
999
|
id: Date.now(),
|
|
720
1000
|
name,
|
|
1001
|
+
table,
|
|
721
1002
|
rows: frame.rows || 0,
|
|
722
1003
|
sql: frame.sql || "",
|
|
723
1004
|
time: Date.now(),
|