terminal-bridge-setup 2.9.1 → 3.0.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.
@@ -0,0 +1,307 @@
1
+ // Terminal Bridge - Yearning SQL 平台自动化 content script (ISOLATED world)
2
+ //
3
+ // 只在 Yearning(sql.meiyunji.net)页面工作。配合 background 的 tap 模式:
4
+ // Agent → 代理 → background → 本脚本:注入 SQL 到编辑器、点「查询」按钮
5
+ // 查询结果通过 tap 通道(WS 帧)回到 Agent
6
+ //
7
+ // 消息(均由 background 转发,frameId=0 顶层文档):
8
+ // yr-ping 探测编辑器类型和查询按钮,返回结构化信息
9
+ // yr-sql-set {sql} 注入 SQL(按探测到的编辑器类型选策略)
10
+ // yr-query-click 找「查询」按钮并点击
11
+
12
+ (function () {
13
+ const TAG = "[terminal-bridge-yr]";
14
+ if (window.__terminalBridgeYearning) return;
15
+ window.__terminalBridgeYearning = true;
16
+
17
+ // ---------- MAIN world 桥(CodeMirror/monaco API 只在页面上下文可达)----------
18
+ let mainMsgId = 0;
19
+ const mainWaiters = new Map();
20
+
21
+ window.addEventListener("message", (event) => {
22
+ if (event.source !== window) return;
23
+ const msg = event.data;
24
+ if (!msg || msg.source !== "tb-yr-main") return;
25
+ const waiter = mainWaiters.get(msg.id);
26
+ if (waiter) {
27
+ mainWaiters.delete(msg.id);
28
+ waiter(msg);
29
+ }
30
+ });
31
+
32
+ function callMain(kind, payload, timeoutMs = 300) {
33
+ return new Promise((resolve) => {
34
+ const id = ++mainMsgId;
35
+ const timer = setTimeout(() => {
36
+ mainWaiters.delete(id);
37
+ resolve(null); // 超时 = MAIN world 没装或没处理
38
+ }, timeoutMs);
39
+ mainWaiters.set(id, (reply) => {
40
+ clearTimeout(timer);
41
+ resolve(reply);
42
+ });
43
+ window.postMessage({ source: "tb-yr-iso", id, kind, ...payload }, "*");
44
+ });
45
+ }
46
+
47
+ // ---------- 编辑器探测 ----------
48
+ async function detectEditor() {
49
+ const info = {
50
+ url: location.href,
51
+ codeMirrorDom: !!document.querySelector(".CodeMirror"),
52
+ monacoDom: !!document.querySelector(".monaco-editor"),
53
+ mainWorld: null,
54
+ textareas: [],
55
+ contentEditables: [],
56
+ };
57
+ const main = await callMain("detect", {}, 200);
58
+ info.mainWorld = main ? main.editors : "unreachable";
59
+ document.querySelectorAll("textarea").forEach((ta, i) => {
60
+ if (i < 5) info.textareas.push({
61
+ cls: (ta.className || "").slice(0, 60),
62
+ placeholder: (ta.placeholder || "").slice(0, 40),
63
+ visible: ta.offsetParent !== null,
64
+ });
65
+ });
66
+ document.querySelectorAll('[contenteditable="true"]').forEach((el, i) => {
67
+ if (i < 5) info.contentEditables.push({
68
+ tag: el.tagName,
69
+ cls: (el.className || "").slice(0, 60),
70
+ visible: el.offsetParent !== null,
71
+ });
72
+ });
73
+ return info;
74
+ }
75
+
76
+ // ---------- 数据库/数据源元信息 ----------
77
+ // 三层读取策略:
78
+ // 1. 数据库(所选库):精确 XPath(form 下 div[2]/div[2] 的选择器文本)——
79
+ // 未选库时查询会报错,必须准确知道
80
+ // 2. 数据源:URL hash 的 source/idc 参数(#/apply/query?source=xxx&idc=xxx)
81
+ // 3. 兜底:整个 form 的 input/select 值启发式
82
+ const FORM_XPATH = "/html/body/div[1]/div/section/section/div[2]/main/div/div/div[2]/div[2]/div/div/div/div/div[2]/div/div[1]/div/div[2]/div/div/div/form";
83
+ const DATABASE_XPATH = FORM_XPATH + "/div[2]/div[2]/div/div/div";
84
+
85
+ function xpathNode(path) {
86
+ try { return document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; } catch { return null; }
87
+ }
88
+
89
+ function readYearningMeta() {
90
+ let database = "";
91
+ let dataSource = "";
92
+
93
+ // 1. 所选数据库:精确 XPath 读取(antd Select 的显示文本)
94
+ const dbNode = xpathNode(DATABASE_XPATH);
95
+ if (dbNode) {
96
+ const dbText = (dbNode.textContent || "").trim();
97
+ // Select 未选择时 antd 显示 placeholder("请选择..."),排除
98
+ if (dbText && !/请选择|placeholder/i.test(dbText)) database = dbText;
99
+ }
100
+
101
+ // 2. 数据源:URL hash
102
+ try {
103
+ const h = location.hash.replace(/^#/, "");
104
+ const q = new URLSearchParams(h.split("?")[1] || "");
105
+ dataSource = q.get("source") || "";
106
+ const idc = q.get("idc") || "";
107
+ if (idc && idc !== dataSource) dataSource = dataSource ? `${dataSource} · ${idc}` : idc;
108
+ } catch {}
109
+
110
+ // 3. 兜底:form 启发式
111
+ if (!database || !dataSource) {
112
+ const form = document.querySelector("form") || xpathNode(FORM_XPATH);
113
+ if (form) {
114
+ const values = [];
115
+ form.querySelectorAll("input, select").forEach(el => {
116
+ if (el.offsetParent === null) return;
117
+ const value = el.tagName === "SELECT" ? el.options[el.selectedIndex]?.textContent : el.value;
118
+ if (value?.trim()) values.push(value.trim());
119
+ });
120
+ const unique = [...new Set(values)].filter(v => !/^(查询|执行|取消|确定|SQL)$/i.test(v));
121
+ if (!dataSource) dataSource = unique.find(v => /source|实例|数据源|tdsql|mysql|prod|test/i.test(v)) || "";
122
+ if (!database) database = unique.find(v => /database|db|库|schema/i.test(v)) || "";
123
+ }
124
+ }
125
+
126
+ const label = [dataSource, database].filter(Boolean).join(" · ")
127
+ || [database, dataSource].filter(Boolean).join(" · ")
128
+ || document.title
129
+ || "Yearning";
130
+ return { ok: true, database, dataSource, label, formFound: !!(database || dataSource) };
131
+ }
132
+
133
+ // ---------- 查询按钮探测 ----------
134
+ function findQueryButtons() {
135
+ const buttons = [];
136
+ document.querySelectorAll("button").forEach((b) => {
137
+ const text = (b.textContent || "").trim();
138
+ if (!text || text.length > 8) return;
139
+ buttons.push({ text, visible: b.offsetParent !== null, disabled: b.disabled });
140
+ });
141
+ return buttons;
142
+ }
143
+
144
+ // ---------- monaco 编辑器内容读取(注入验证用,无 API 时从 DOM 读)----------
145
+ function readMonacoText() {
146
+ const lines = [...document.querySelectorAll(".monaco-editor .view-lines .view-line")]
147
+ .map(l => l.textContent || "");
148
+ return lines.join("\n");
149
+ }
150
+
151
+ // ---------- SQL 注入(先 MAIN world API,后 DOM 策略,注入后验证)----------
152
+ async function setSql(sql) {
153
+ // 策略 1:CodeMirror/monaco(MAIN world,官方 API 状态一定同步)
154
+ const main = await callMain("set-sql", { sql });
155
+ if (main && main.ok) return { ok: true, via: main.via };
156
+
157
+ // 策略 2:monaco DOM 注入——合成 paste 事件(monaco 官方输入路径)。
158
+ // 实测教训:execCommand("insertText") 在 inputarea 上静默失败(假阳性),
159
+ // 而 ClipboardEvent("paste") + DataTransfer 是 monaco 粘贴处理器认的通道。
160
+ const monacoTa = document.querySelector(".monaco-editor textarea.inputarea");
161
+ if (monacoTa) {
162
+ monacoTa.focus();
163
+ // 全选旧内容(paste 会替换选区)
164
+ monacoTa.dispatchEvent(new KeyboardEvent("keydown", {
165
+ key: "a", code: "KeyA", keyCode: 65, which: 65,
166
+ ctrlKey: true, bubbles: true, cancelable: true
167
+ }));
168
+ const dt = new DataTransfer();
169
+ dt.setData("text/plain", sql);
170
+ monacoTa.dispatchEvent(new ClipboardEvent("paste", {
171
+ clipboardData: dt, bubbles: true, cancelable: true
172
+ }));
173
+
174
+ // 注入后验证:等 monaco 渲染,读回 view-lines 内容比对(杜绝假阳性)
175
+ await new Promise(r => setTimeout(r, 250));
176
+ const current = readMonacoText();
177
+ const norm = s => s.replace(/\s+/g, "");
178
+ if (norm(current).includes(norm(sql).slice(0, 40))) {
179
+ return { ok: true, via: "monaco-paste" };
180
+ }
181
+ // paste 失败再试 execCommand(检查返回值)
182
+ monacoTa.focus();
183
+ const ok2 = document.execCommand("insertText", false, sql);
184
+ await new Promise(r => setTimeout(r, 250));
185
+ const current2 = readMonacoText();
186
+ if (ok2 && norm(current2).includes(norm(sql).slice(0, 40))) {
187
+ return { ok: true, via: "monaco-execcmd" };
188
+ }
189
+ return {
190
+ ok: false,
191
+ error: "monaco inject failed (paste+execCommand 均未生效)",
192
+ editorText: current2.slice(0, 120),
193
+ };
194
+ }
195
+
196
+ // 策略 3:普通可见 textarea(value + input 事件)
197
+ const tas = [...document.querySelectorAll("textarea")].filter(ta => ta.offsetParent !== null);
198
+ if (tas.length > 0) {
199
+ const ta = tas[0];
200
+ ta.focus();
201
+ ta.value = sql;
202
+ ta.dispatchEvent(new InputEvent("input", {
203
+ inputType: "insertText", data: sql, bubbles: true, cancelable: true
204
+ }));
205
+ return { ok: true, via: "textarea" };
206
+ }
207
+
208
+ // 策略 4:contenteditable(focus + 全选 + 插入)
209
+ const ces = [...document.querySelectorAll('[contenteditable="true"]')].filter(el => el.offsetParent !== null);
210
+ if (ces.length > 0) {
211
+ const el = ces[0];
212
+ el.focus();
213
+ document.execCommand("selectAll", false, null);
214
+ document.execCommand("insertText", false, sql);
215
+ return { ok: true, via: "contenteditable" };
216
+ }
217
+
218
+ return { ok: false, error: "no editor found (main-world/monaco/textarea/contenteditable 均未命中)" };
219
+ }
220
+
221
+ // ---------- 点「查询」按钮 ----------
222
+ function clickQuery() {
223
+ // 文本匹配时去空白(实测按钮文案是「查 询」,中间带空格)
224
+ const norm = (s) => (s || "").replace(/\s+/g, "").trim();
225
+ const candidates = [...document.querySelectorAll("button")]
226
+ .filter(b => b.offsetParent !== null && !b.disabled)
227
+ .map(b => ({ b, text: (b.textContent || "").trim(), key: norm(b.textContent) }));
228
+ const exact = candidates.find(c => c.key === "查询") ||
229
+ candidates.find(c => c.key === "执行") ||
230
+ candidates.find(c => /^查询|^执行|^运行/.test(c.key));
231
+ if (!exact) {
232
+ return { ok: false, error: "query button not found", buttons: candidates.slice(0, 15).map(c => c.text) };
233
+ }
234
+ exact.b.scrollIntoView({ block: "center" });
235
+ exact.b.click();
236
+ return { ok: true, via: exact.text };
237
+ }
238
+
239
+ // ---------- 消息处理 ----------
240
+ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
241
+ if (!msg || !msg.type) return;
242
+
243
+ if (msg.type === "yr-meta") {
244
+ sendResponse(readYearningMeta());
245
+ return true;
246
+ }
247
+ if (msg.type === "yr-ping") {
248
+ detectEditor().then(editor => {
249
+ sendResponse({
250
+ ok: true,
251
+ editor,
252
+ buttons: findQueryButtons().slice(0, 20),
253
+ });
254
+ });
255
+ return true; // 异步响应
256
+ }
257
+ if (msg.type === "yr-sql-set") {
258
+ setSql(msg.sql || "").then(sendResponse);
259
+ return true;
260
+ }
261
+ if (msg.type === "yr-query-click") {
262
+ sendResponse(clickQuery());
263
+ return true;
264
+ }
265
+ if (msg.type === "yr-new-sql") {
266
+ // 新建 SQL 窗口:点工具栏新建按钮,等新编辑器渲染。
267
+ // 避免把 SQL 注入用户正在看/正在用的已有编辑器。
268
+ // 按钮定位:优先 XPath(用户给的路径),兜底找文本含"新建"的可见按钮。
269
+ const btn = xpathNode("/html/body/div[1]/div/section/section/div[2]/main/div/div/div[2]/div[2]/div/div/div/div/div[2]/div/div[1]/div/div[1]/div[1]/div/button")
270
+ || [...document.querySelectorAll("button")].find(b =>
271
+ b.offsetParent !== null && /新建|new/i.test((b.textContent || "").trim()));
272
+ if (!btn) { sendResponse({ ok: false, error: "新建按钮未找到" }); return true; }
273
+ const before = document.querySelectorAll(".monaco-editor").length;
274
+ btn.click();
275
+ // 等新编辑器出现(最多 4s;tab 页签式 UI 时编辑器数不变,退化为等 800ms)
276
+ const deadline = Date.now() + 4000;
277
+ (function waitFor() {
278
+ const now = document.querySelectorAll(".monaco-editor").length;
279
+ if (now > before || Date.now() > deadline) {
280
+ setTimeout(() => sendResponse({ ok: true, editors: now, via: "new-sql-btn" }), 800);
281
+ return;
282
+ }
283
+ setTimeout(waitFor, 200);
284
+ })();
285
+ return true;
286
+ }
287
+ if (msg.type === "yr-focus-editor") {
288
+ // CDP 注入前置:聚焦 monaco 的 inputarea(Input.insertText 作用于焦点元素)
289
+ const ta = document.querySelector(".monaco-editor textarea.inputarea")
290
+ || document.querySelector("textarea");
291
+ if (!ta) { sendResponse({ ok: false, error: "no inputarea" }); return true; }
292
+ ta.focus();
293
+ sendResponse({ ok: true });
294
+ return true;
295
+ }
296
+ if (msg.type === "yr-verify-sql") {
297
+ // CDP 注入后验证:读回 monaco view-lines 内容比对
298
+ const current = readMonacoText();
299
+ const norm = s => s.replace(/\s+/g, "");
300
+ const hit = norm(current).indexOf(norm(msg.sql || "").slice(0, 40)) !== -1;
301
+ sendResponse({ ok: hit, editorText: current.slice(0, 120) });
302
+ return true;
303
+ }
304
+ });
305
+
306
+ console.log(TAG, "Yearning content script loaded at", location.href);
307
+ })();
@@ -4,7 +4,7 @@
4
4
  "version": "2.0.0",
5
5
  "description": "桥接 JumpServer Web 终端,让 Agent 能远程执行命令并获取输出",
6
6
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtsnqR6PcFUueZwYria79tVbstvjk+tM7PpvIXILm5xbd6bAdjDIhzg3lsnKioVfvxjfvT+s6vJsiOYa9ojVZyJMFc5m/05TYqr770ovYwQmz0e88fmiy6dUoSulbtKvBCSLbN6OOL7u+ul8ixLZ/HautxSmou/eNgAFPmhE+4UueE7wfCqcgMYvjLvEzlqTVumMW+5LKw9YsRk6WhHPghY1a3MVUn3eQOWXBtQTEUy3wBM3v4wHxLwDeinVOR4f/P87IlUNo84C5DeimoFit0qCj3K04hS8MIYCLCYZc3v9ftRJDJBkAoah6Eaqj7JajbS3KvLR1ctOYfDhubgmURQIDAQAB",
7
- "permissions": ["debugger", "tabs", "alarms", "webNavigation", "nativeMessaging", "scripting"],
7
+ "permissions": ["debugger", "tabs", "alarms", "webNavigation", "nativeMessaging", "scripting", "downloads", "storage"],
8
8
  "background": {
9
9
  "service_worker": "background.js"
10
10
  },
@@ -18,6 +18,19 @@
18
18
  "js": ["content.js"],
19
19
  "run_at": "document_start",
20
20
  "all_frames": true
21
+ },
22
+ {
23
+ "matches": ["*://sql.meiyunji.net/*"],
24
+ "js": ["content-yearning-main.js"],
25
+ "run_at": "document_start",
26
+ "all_frames": false,
27
+ "world": "MAIN"
28
+ },
29
+ {
30
+ "matches": ["*://sql.meiyunji.net/*"],
31
+ "js": ["content-yearning.js"],
32
+ "run_at": "document_idle",
33
+ "all_frames": false
21
34
  }
22
35
  ],
23
36
  "host_permissions": ["<all_urls>"]
@@ -115,6 +115,22 @@
115
115
  margin-left: 6px;
116
116
  }
117
117
  .tab-item .tab-row { display: flex; align-items: center; margin-left: 6px; flex: 1; min-width: 0; }
118
+ .yearning-item {
119
+ padding: 6px 8px; margin: 3px 0; border-radius: 4px;
120
+ background: #f5f5f5; cursor: pointer; font-size: 11px;
121
+ }
122
+ .yearning-item.active { background: #e3f2fd; border-left: 3px solid #2196F3; }
123
+ .yearning-item .yr-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
124
+ .yearning-item .yr-meta { color: #666; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
125
+ .yearning-item .yr-badge { color: #4CAF50; font-size: 10px; margin-left: 4px; }
126
+ .csv-item {
127
+ display: flex; align-items: center; gap: 6px;
128
+ padding: 5px 8px; margin: 3px 0; border-radius: 4px;
129
+ background: #f0f7ff; cursor: pointer; font-size: 11px;
130
+ }
131
+ .csv-item:hover { background: #dcebfd; }
132
+ .csv-item .csv-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
133
+ .csv-item .csv-rows { color: #888; font-size: 10px; flex-shrink: 0; }
118
134
  .guide {
119
135
  font-size: 11px;
120
136
  color: #666;
@@ -177,6 +193,19 @@
177
193
  <div id="tabList" style="margin-top:6px;"></div>
178
194
  </div>
179
195
 
196
+ <div class="section">
197
+ <div class="section-title">Yearning 监听</div>
198
+ <div class="hint" style="margin-bottom:6px;">监听 Yearning SQL 查询结果。先监听页面,再选择一个页面作为 SQL 注入和结果接收目标。</div>
199
+ <button class="scan" id="btnWsTap">📡 监听当前 Yearning 页</button>
200
+ <div class="hint" id="wsTapStatus">未监听 Yearning 页面</div>
201
+ <div id="yearningTabList" style="margin-top:6px;"></div>
202
+ <div id="csvSection" style="margin-top:8px;display:none;">
203
+ <div class="section-title" style="margin-bottom:4px;">CSV 导出记录</div>
204
+ <div id="csvList"></div>
205
+ <div class="hint">点击记录可重新下载(仅保留最近 20 条,插件刷新后清空)</div>
206
+ </div>
207
+ </div>
208
+
180
209
  <div class="section">
181
210
  <div class="section-title">桥接代理</div>
182
211
  <div class="status-line">
@@ -75,6 +75,97 @@ document.getElementById('btnXtermScan').onclick = () => {
75
75
  });
76
76
  };
77
77
 
78
+ // ============== Yearning 监听(多 tab 绑定)==============
79
+ const btnWsTap = document.getElementById('btnWsTap');
80
+ const wsTapStatus = document.getElementById('wsTapStatus');
81
+ const yearningTabList = document.getElementById('yearningTabList');
82
+
83
+ function refreshYearningTabs() {
84
+ chrome.runtime.sendMessage({ type: 'YR_TAP_STATUS' }, (res) => {
85
+ if (chrome.runtime.lastError || !res || !res.ok) return;
86
+ const tabs = res.tabs || [];
87
+ wsTapStatus.textContent = tabs.length
88
+ ? `${tabs.length} 个 Yearning 页面已监听,当前选中 1 个`
89
+ : '未监听 Yearning 页面';
90
+ yearningTabList.innerHTML = tabs.map(t => {
91
+ const title = escapeHtml(t.title || 'Yearning');
92
+ const label = escapeHtml(t.label || [t.database, t.dataSource].filter(Boolean).join(' · ') || t.host || '数据库信息读取中');
93
+ const host = escapeHtml(t.host || `tab ${t.tabId}`);
94
+ return `<div class="yearning-item ${t.active ? 'active' : ''}" data-tabid="${t.tabId}">
95
+ <div class="yr-title">${t.active ? '◉' : '○'} ${title}${t.active ? '<span class="yr-badge">✓ 当前 Yearning 页面</span>' : ''}${t.isCurrent ? '<span class="yr-badge">● 当前浏览器页</span>' : ''}</div>
96
+ <div class="yr-meta">${label} · ${host}</div>
97
+ </div>`;
98
+ }).join('');
99
+ yearningTabList.querySelectorAll('.yearning-item').forEach(el => {
100
+ el.onclick = () => {
101
+ chrome.runtime.sendMessage({ type: 'YR_TAP_SELECT', tabId: Number(el.dataset.tabid) }, () => refreshYearningTabs());
102
+ };
103
+ });
104
+ // 按钮文案按「当前浏览器页是否在监听」决定(不是看 active 选中页)
105
+ const thisTabWatched = tabs.some(t => t.isCurrent);
106
+ btnWsTap.textContent = thisTabWatched ? '⏹ 停止监听当前 Yearning 页' : '📡 监听当前 Yearning 页';
107
+ });
108
+ }
109
+
110
+ function escapeHtml(value) {
111
+ return String(value).replace(/[&<>"']/g, ch => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[ch]));
112
+ }
113
+
114
+ btnWsTap.onclick = () => {
115
+ chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
116
+ const tab = tabs && tabs[0];
117
+ if (!tab || !tab.id) { wsTapStatus.textContent = '未找到当前页'; return; }
118
+ // 先查当前状态:已在监听 → 停止;未监听 → 添加
119
+ chrome.runtime.sendMessage({ type: 'YR_TAP_STATUS' }, (st) => {
120
+ if (chrome.runtime.lastError || !st || !st.ok) {
121
+ wsTapStatus.textContent = '状态查询失败';
122
+ return;
123
+ }
124
+ const already = (st.tapTabs || []).includes(tab.id);
125
+ const type = already ? 'WS_TAP_DETACH' : 'WS_TAP_ATTACH';
126
+ chrome.runtime.sendMessage({ type, tabId: tab.id }, (res) => {
127
+ if (chrome.runtime.lastError || !res || !res.ok) {
128
+ wsTapStatus.textContent = (already ? '停止失败: ' : '监听失败: ') + (res && res.msg || '无响应');
129
+ return;
130
+ }
131
+ wsTapStatus.textContent = already ? '已停止监听该页面' : '已监听';
132
+ refreshYearningTabs();
133
+ });
134
+ });
135
+ });
136
+ };
137
+
138
+ refreshYearningTabs();
139
+
140
+ // ============== CSV 导出记录 ==============
141
+ const csvSection = document.getElementById('csvSection');
142
+ const csvList = document.getElementById('csvList');
143
+
144
+ function refreshCsvList() {
145
+ chrome.runtime.sendMessage({ type: 'CSV_LIST' }, (res) => {
146
+ if (chrome.runtime.lastError || !res || !res.ok) return;
147
+ const exports = res.exports || [];
148
+ csvSection.style.display = exports.length ? 'block' : 'none';
149
+ csvList.innerHTML = exports.map(e => {
150
+ const time = new Date(e.time).toLocaleTimeString();
151
+ return `<div class="csv-item" data-id="${e.id}" title="${escapeHtml(e.sql || e.name)}">
152
+ <span class="csv-name">📄 ${escapeHtml(e.name)}</span>
153
+ <span class="csv-rows">${e.rows} 行 · ${time}</span>
154
+ </div>`;
155
+ }).join('');
156
+ csvList.querySelectorAll('.csv-item').forEach(el => {
157
+ el.onclick = () => {
158
+ chrome.runtime.sendMessage({ type: 'CSV_DOWNLOAD', id: Number(el.dataset.id) }, (r) => {
159
+ if (chrome.runtime.lastError || !r || !r.ok) {
160
+ console.warn('重新下载失败:', r && r.msg);
161
+ }
162
+ });
163
+ };
164
+ });
165
+ });
166
+ }
167
+ refreshCsvList();
168
+
78
169
  // ============== 代理控制 ==============
79
170
  const proxyDot = document.getElementById('proxyDot');
80
171
  const proxyText = document.getElementById('proxyStatusText');
@@ -176,7 +267,7 @@ document.getElementById('btnCopyInstall').onclick = () => {
176
267
  };
177
268
 
178
269
  // 定时刷新(popup 打开期间)
179
- setInterval(() => { refreshXterm(); refreshProxy(); }, 3000);
270
+ setInterval(() => { refreshXterm(); refreshProxy(); refreshCsvList(); refreshYearningTabs(); }, 3000);
180
271
  refreshXterm();
181
272
  refreshProxy();
182
273
  // 启动时主动探测一次 native host:发个 status,失败就显示安装提示
@@ -10,6 +10,7 @@
10
10
  "example": "node client-example.mjs"
11
11
  },
12
12
  "dependencies": {
13
+ "@msgpack/msgpack": "^3.1.3",
13
14
  "ws": "^8.18.0"
14
15
  }
15
16
  }