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.
@@ -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: true },
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 帧:终端激活 tab 的进代理配对;tap tab 的进代理 tap 通道(都带 url)
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: "ping"|"sql-set"|"query-click", sql, reqId }
580
+ // { type: "yr-cmd", sub, sql, reqId, tabId, skipNew }
538
581
  // Yearning 自动化:代理编排(yr-run),本插件转发到 tap tab 的 content script。
539
- // sql-set CDP Input.insertText(浏览器信任层级,monaco 等任何编辑器都接受);
540
- // 合成 paste/execCommand monaco 无效(isTrusted=false 被忽略,实测)。
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
- const newWin = await sendFrameMessage(tabId, { type: "yr-new-sql" }, 0);
587
- if (!newWin || !newWin.ok) {
588
- console.warn("[bg] 新建 SQL 窗口失败(继续在当前编辑器注入):", newWin?.error);
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
- // (Yearning SHOW INDEX 会推两份相同结果,取一即可)
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
- const table = obj.results.find(t => t && Array.isArray(t.field) && t.field.length > 0)
687
- || obj.results[0];
688
- if (!table || !Array.isArray(table.field)) return null;
689
- const headers = table.field.map(f => csvCell(f.title || f.dataIndex || ""));
690
- const rows = (table.data || []).map(row =>
691
- table.field.map(f => csvCell(row[f.dataIndex])).join(",")
692
- );
693
- return "\uFEFF" + [headers.join(","), ...rows].join("\r\n") + "\r\n";
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
- function buildCsvFileName(sql, tabId) {
699
- const sqlText = sql || "";
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] : "query").slice(0, 40);
981
+ const table = (tableMatch ? tableMatch[1] : "").slice(0, 40) || "query";
702
982
  const meta = tapTabMeta.get(tabId) || {};
703
- const database = (meta.database || "").replace(/[^\w.-]+/g, "") || "nodb";
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(),