dsh-api-runner 0.2.1 → 0.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/README.md +84 -2
- package/client/client.js +500 -24
- package/package.json +4 -4
- package/src/db.js +958 -0
- package/src/main.js +137 -1
- package/src/ops.js +59 -0
- package/test/client.mjs +102 -1
- package/test/db.mjs +346 -0
- package/test/plugin.mjs +123 -0
package/src/main.js
CHANGED
|
@@ -42,7 +42,7 @@ const PARAMETERS = {
|
|
|
42
42
|
properties: {
|
|
43
43
|
action: {
|
|
44
44
|
type: 'string',
|
|
45
|
-
enum: ['describe', 'call', 'login', 'service', 'log', 'wait_log', 'reset_log', 'verify', 'java'],
|
|
45
|
+
enum: ['describe', 'call', 'login', 'service', 'log', 'wait_log', 'reset_log', 'verify', 'java', 'db'],
|
|
46
46
|
description: '要执行的操作',
|
|
47
47
|
},
|
|
48
48
|
api_id: { type: 'string', description: '接口 id 或 name(call、login、verify 用)' },
|
|
@@ -69,6 +69,34 @@ const PARAMETERS = {
|
|
|
69
69
|
items: { type: 'string' },
|
|
70
70
|
description: 'java_action=start_all 时只启动这些服务(省略则启动全部已保存的)',
|
|
71
71
|
},
|
|
72
|
+
db_action: {
|
|
73
|
+
type: 'string',
|
|
74
|
+
enum: ['list_connections', 'test', 'save', 'delete', 'query', 'list_databases', 'list_tables', 'describe_table'],
|
|
75
|
+
description: '数据库动作(action=db 用):list_connections 列出连接;test 试连;save 保存连接;delete 删除连接;query 执行 SQL(只读模式默认开);list_databases/list_tables/describe_table 看元数据',
|
|
76
|
+
},
|
|
77
|
+
connection_id: { type: 'string', description: '数据库连接 id(db_action=query/list_tables/describe_table/test/delete 用)' },
|
|
78
|
+
sql: { type: 'string', description: '要执行的 SQL(db_action=query 用,只允许单条语句)' },
|
|
79
|
+
params: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
additionalProperties: true,
|
|
82
|
+
description: '接口参数(action=call/login):键会替换 URL/body 里的 {key} 占位。db_action=query 时请改用 db_params。',
|
|
83
|
+
},
|
|
84
|
+
db_params: {
|
|
85
|
+
type: 'array',
|
|
86
|
+
items: {},
|
|
87
|
+
description: 'SQL 参数化占位值(db_action=query 用),按 ?/$1/:1 顺序绑定,不要拼字符串',
|
|
88
|
+
},
|
|
89
|
+
db_readonly: {
|
|
90
|
+
type: 'boolean',
|
|
91
|
+
description: 'db_action=query 时是否只读(默认 true,拒绝 INSERT/UPDATE/DELETE/DDL)',
|
|
92
|
+
},
|
|
93
|
+
connection: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
additionalProperties: true,
|
|
96
|
+
description: '连接配置(db_action=save/test 用):{ id?, name, type(sqlite|mysql|postgres|oracle), host, port, database, username, password, extra }',
|
|
97
|
+
},
|
|
98
|
+
table: { type: 'string', description: '表名(db_action=describe_table 用)' },
|
|
99
|
+
database: { type: 'string', description: '库名/模式名(db_action=list_tables 用;MySQL 必填)' },
|
|
72
100
|
pid: { type: 'number', description: '要接管的 Java 进程 pid(java_action=start 用)' },
|
|
73
101
|
java_id: { type: 'string', description: '插件托管的服务名或 pid(java_action=stop/restart/log/save/unsave/start_saved 用)' },
|
|
74
102
|
port: { type: 'number', description: '服务端口(java_action=start 用;省略则从进程参数推断)' },
|
|
@@ -210,6 +238,64 @@ async function runTool(service, args, projectPath) {
|
|
|
210
238
|
}
|
|
211
239
|
return { ok: false, code: 'BAD_ACTION', message: `不支持的 java_action: ${javaAction}` };
|
|
212
240
|
}
|
|
241
|
+
case 'db': {
|
|
242
|
+
const dbAction = String(args.db_action || 'list_connections');
|
|
243
|
+
if (dbAction === 'list_connections') return service.dbConnections();
|
|
244
|
+
if (dbAction === 'list_databases') {
|
|
245
|
+
if (!args.connection_id) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=list_databases 需要 connection_id' };
|
|
246
|
+
return service.dbDatabases(String(args.connection_id));
|
|
247
|
+
}
|
|
248
|
+
if (dbAction === 'test') {
|
|
249
|
+
const target = args.connection || args.connection_id;
|
|
250
|
+
if (!target) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=test 需要 connection_id 或 connection 配置' };
|
|
251
|
+
return service.dbTest(args.connection ? asObject(args.connection) : String(args.connection_id));
|
|
252
|
+
}
|
|
253
|
+
if (dbAction === 'save') {
|
|
254
|
+
/**
|
|
255
|
+
* connection 可能是对象,也可能被上游序列化成 JSON 字符串
|
|
256
|
+
* (工具参数走 JSON 时常见)。两种都接住,不要只认对象。
|
|
257
|
+
*/
|
|
258
|
+
const raw = args.connection;
|
|
259
|
+
let config;
|
|
260
|
+
if (typeof raw === 'string' && raw.trim() !== '') {
|
|
261
|
+
try {
|
|
262
|
+
config = JSON.parse(raw);
|
|
263
|
+
} catch {
|
|
264
|
+
return { ok: false, code: 'BAD_ARGUMENT', message: 'connection 不是合法 JSON' };
|
|
265
|
+
}
|
|
266
|
+
} else if (raw && typeof raw === 'object') {
|
|
267
|
+
config = raw;
|
|
268
|
+
}
|
|
269
|
+
if (!config || typeof config !== 'object') {
|
|
270
|
+
return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=save 需要 connection 配置(对象或 JSON 字符串)' };
|
|
271
|
+
}
|
|
272
|
+
return service.dbSave(asObject(config));
|
|
273
|
+
}
|
|
274
|
+
if (dbAction === 'delete') {
|
|
275
|
+
if (!args.connection_id) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=delete 需要 connection_id' };
|
|
276
|
+
return service.dbDelete(String(args.connection_id));
|
|
277
|
+
}
|
|
278
|
+
if (dbAction === 'query') {
|
|
279
|
+
if (!args.connection_id) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=query 需要 connection_id' };
|
|
280
|
+
if (!args.sql) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=query 需要 sql' };
|
|
281
|
+
return service.dbQuery(String(args.connection_id), String(args.sql), Array.isArray(args.db_params) ? args.db_params : [], {
|
|
282
|
+
// 默认只读:模型直接执行写操作是高风险动作,必须显式关闭
|
|
283
|
+
readonly: args.db_readonly !== false,
|
|
284
|
+
maxRows: args.max_lines ? Number(args.max_lines) : undefined,
|
|
285
|
+
timeoutMs,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
if (dbAction === 'list_tables') {
|
|
289
|
+
if (!args.connection_id) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=list_tables 需要 connection_id' };
|
|
290
|
+
return service.dbTables(String(args.connection_id), args.database ? String(args.database) : '');
|
|
291
|
+
}
|
|
292
|
+
if (dbAction === 'describe_table') {
|
|
293
|
+
if (!args.connection_id) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=describe_table 需要 connection_id' };
|
|
294
|
+
if (!args.table) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=describe_table 需要 table' };
|
|
295
|
+
return service.dbDescribeTable(String(args.connection_id), String(args.table));
|
|
296
|
+
}
|
|
297
|
+
return { ok: false, code: 'BAD_ACTION', message: `不支持的 db_action: ${dbAction}` };
|
|
298
|
+
}
|
|
213
299
|
case 'service': {
|
|
214
300
|
const serviceAction = String(args.service_action || 'status');
|
|
215
301
|
if (!args.service_id && serviceAction !== 'status') {
|
|
@@ -576,6 +662,56 @@ async function handleHttp(req, res, service, projectOf) {
|
|
|
576
662
|
if (route === 'java/start-all/status' && req.method === 'GET') {
|
|
577
663
|
return send(200, service.startBatchStatus(url.searchParams.get('batch') || ''));
|
|
578
664
|
}
|
|
665
|
+
|
|
666
|
+
// ---- 数据库:连接管理与 SQL 执行(连接是跨项目的,不按 cwd 归档) ----
|
|
667
|
+
if (route === 'db' && req.method === 'GET') {
|
|
668
|
+
return send(200, service.dbConnections());
|
|
669
|
+
}
|
|
670
|
+
if (route === 'db/test' && req.method === 'POST') {
|
|
671
|
+
const body = await readBody();
|
|
672
|
+
const target = body.connection ? asObject(body.connection) : String(body.connection_id || body.id || '');
|
|
673
|
+
if (!target || (typeof target === 'string' && target === '')) {
|
|
674
|
+
return send(400, { ok: false, code: 'MISSING_ARGUMENT', message: '需要 connection_id 或 connection 配置' });
|
|
675
|
+
}
|
|
676
|
+
return send(200, await service.dbTest(target));
|
|
677
|
+
}
|
|
678
|
+
if (route === 'db/save' && req.method === 'POST') {
|
|
679
|
+
const body = await readBody();
|
|
680
|
+
return send(200, service.dbSave(asObject(body.connection || body)));
|
|
681
|
+
}
|
|
682
|
+
if (route === 'db/delete' && req.method === 'POST') {
|
|
683
|
+
const body = await readBody();
|
|
684
|
+
return send(200, service.dbDelete(String(body.connection_id || body.id || '')));
|
|
685
|
+
}
|
|
686
|
+
if (route === 'db/query' && req.method === 'POST') {
|
|
687
|
+
const body = await readBody();
|
|
688
|
+
return send(200, await service.dbQuery(
|
|
689
|
+
String(body.connection_id || ''),
|
|
690
|
+
String(body.sql || ''),
|
|
691
|
+
Array.isArray(body.params) ? body.params : [],
|
|
692
|
+
{
|
|
693
|
+
readonly: body.readonly !== false,
|
|
694
|
+
maxRows: body.max_rows ? Number(body.max_rows) : undefined,
|
|
695
|
+
timeoutMs: body.timeout_ms ? Number(body.timeout_ms) : undefined,
|
|
696
|
+
},
|
|
697
|
+
));
|
|
698
|
+
}
|
|
699
|
+
if (route === 'db/databases' && req.method === 'GET') {
|
|
700
|
+
return send(200, await service.dbDatabases(String(url.searchParams.get('connection_id') || '')));
|
|
701
|
+
}
|
|
702
|
+
if (route === 'db/tables' && req.method === 'GET') {
|
|
703
|
+
return send(200, await service.dbTables(
|
|
704
|
+
String(url.searchParams.get('connection_id') || ''),
|
|
705
|
+
String(url.searchParams.get('database') || ''),
|
|
706
|
+
));
|
|
707
|
+
}
|
|
708
|
+
if (route === 'db/table' && req.method === 'GET') {
|
|
709
|
+
return send(200, await service.dbDescribeTable(
|
|
710
|
+
String(url.searchParams.get('connection_id') || ''),
|
|
711
|
+
String(url.searchParams.get('name') || url.searchParams.get('table') || ''),
|
|
712
|
+
));
|
|
713
|
+
}
|
|
714
|
+
|
|
579
715
|
if (route === 'settings' && req.method === 'GET') {
|
|
580
716
|
return send(200, service.settings());
|
|
581
717
|
}
|
package/src/ops.js
CHANGED
|
@@ -6,6 +6,7 @@ import fs from 'node:fs';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { ConfigStore } from './config.js';
|
|
8
8
|
import { callApi, pickPath } from './api.js';
|
|
9
|
+
import { DbManager } from './db.js';
|
|
9
10
|
import { JavaRunner } from './java-runner.js';
|
|
10
11
|
import { discoverLogs, listJavaProcesses, readFileChunk, readFileTail } from './java.js';
|
|
11
12
|
import { LogReader } from './log.js';
|
|
@@ -39,6 +40,12 @@ export class ApiRunnerService {
|
|
|
39
40
|
*/
|
|
40
41
|
this.startBatches = new Map();
|
|
41
42
|
this._batchSeq = 0;
|
|
43
|
+
/**
|
|
44
|
+
* 数据库连接管理。数据库能力从原 dsh-db-tool 合并进来,
|
|
45
|
+
* 但**不依赖任何 DSH 服务**(原插件把 betterSidebar 当硬依赖,
|
|
46
|
+
* 服务没就绪就一直 pending,拖垮整个 web 启动)。
|
|
47
|
+
*/
|
|
48
|
+
this.dbManager = options.dbManager || new DbManager({ homeDir: this.homeDir, settingsStore: this.settingsStore });
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
/** 面板配置转成 YAML 同构的覆盖层;没有面板存储时返回 undefined */
|
|
@@ -1161,9 +1168,61 @@ export class ApiRunnerService {
|
|
|
1161
1168
|
return { ok: true, message: `已取消关联 pid ${pid}` };
|
|
1162
1169
|
}
|
|
1163
1170
|
|
|
1171
|
+
// ---- 数据库(从 dsh-db-tool 合并进来的能力) ----
|
|
1172
|
+
/**
|
|
1173
|
+
* 数据库连接是**跨项目**的:同一套库通常被多个项目共用,
|
|
1174
|
+
* 按项目归档反而每次换项目都要重配。所以这里不接 cwd 参数。
|
|
1175
|
+
*/
|
|
1176
|
+
dbConnections() {
|
|
1177
|
+
if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
|
|
1178
|
+
try {
|
|
1179
|
+
const connections = this.dbManager.listConnections();
|
|
1180
|
+
return { ok: true, connections, total: connections.length };
|
|
1181
|
+
} catch (error) {
|
|
1182
|
+
return { ok: false, code: 'DB_ERROR', message: String(error?.message || error) };
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
async dbTest(target) {
|
|
1187
|
+
if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
|
|
1188
|
+
return this.dbManager.testConnection(target);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
dbSave(patch) {
|
|
1192
|
+
if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
|
|
1193
|
+
return this.dbManager.saveConnection(patch);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
dbDelete(id) {
|
|
1197
|
+
if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
|
|
1198
|
+
return this.dbManager.deleteConnection(id);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
async dbQuery(id, sql, params = [], options = {}) {
|
|
1202
|
+
if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
|
|
1203
|
+
return this.dbManager.query(id, sql, params, options);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
async dbDatabases(id) {
|
|
1207
|
+
if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
|
|
1208
|
+
return this.dbManager.listDatabases(id);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
async dbTables(id, database = '') {
|
|
1212
|
+
if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
|
|
1213
|
+
return this.dbManager.listTables(id, database);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
async dbDescribeTable(id, table) {
|
|
1217
|
+
if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
|
|
1218
|
+
return this.dbManager.describeTable(id, table);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1164
1221
|
dispose() {
|
|
1165
1222
|
this.logReader.dispose();
|
|
1166
1223
|
this.serviceManager.dispose();
|
|
1224
|
+
// 数据库连接池也要关,否则插件卸载后连接泄漏
|
|
1225
|
+
if (this.dbManager) this.dbManager.dispose();
|
|
1167
1226
|
}
|
|
1168
1227
|
}
|
|
1169
1228
|
|
package/test/client.mjs
CHANGED
|
@@ -43,8 +43,10 @@ const windowStub = {
|
|
|
43
43
|
confirm: () => true,
|
|
44
44
|
document: {
|
|
45
45
|
getElementById: () => null,
|
|
46
|
-
|
|
46
|
+
querySelectorAll: () => [],
|
|
47
|
+
createElement: () => ({ set textContent(v) {}, id: '', remove() {} }),
|
|
47
48
|
head: { appendChild: () => {} },
|
|
49
|
+
documentElement: { appendChild: () => {} },
|
|
48
50
|
},
|
|
49
51
|
};
|
|
50
52
|
// bundle 里会读 document / window,通过函数参数作用域注入
|
|
@@ -148,6 +150,105 @@ const rightPanel = byName('sidebar.right.pane.tab')[0].render({ sessionId: 'sess
|
|
|
148
150
|
assert.ok(rightPanel, '右侧栏页签正文应返回元素');
|
|
149
151
|
assert.equal(typeof rightPanel.type, 'function');
|
|
150
152
|
|
|
153
|
+
// 数据库子页:结构断言(直接扫源码,避免为渲染造一堆桩)
|
|
154
|
+
assert.ok(/id: 'db', label: '数据库'/.test(source), "面板 tabs 应包含「数据库」子页");
|
|
155
|
+
assert.ok(/tab === 'db' \? dbPane : null/.test(source), '应渲染 dbPane 分支');
|
|
156
|
+
assert.ok(/function EditDbForm/.test(source), '应有数据库连接编辑表单');
|
|
157
|
+
assert.ok(/function DbResultCard/.test(source), '应有 SQL 结果卡片');
|
|
158
|
+
assert.ok(/function TableDetail/.test(source), '应有表结构视图');
|
|
159
|
+
assert.ok(/type: 'password'/.test(source), '密码框必须是 password 类型,不能明文显示');
|
|
160
|
+
assert.ok(/只读模式(拒绝写操作)/.test(source), '应有只读模式开关');
|
|
161
|
+
assert.ok(/db\/query/.test(source), 'SQL 执行应走 db/query 路由');
|
|
162
|
+
|
|
163
|
+
// UI 层级:顶部页签用 ar-tabs,一级标题用独立胶囊 ar-segmented(参照宿主插件详情页)
|
|
164
|
+
assert.ok(/className: 'ar-tabs'/.test(source), '顶部应有页签容器 ar-tabs');
|
|
165
|
+
assert.ok(/className: 'ar-segmented'/.test(source), '一级标题应是小胶囊容器 ar-segmented');
|
|
166
|
+
assert.ok(/'ar-seg on' : 'ar-seg'/.test(source), '胶囊选中态应有独立样式');
|
|
167
|
+
assert.ok(/label: '连接'/.test(source) && /label: 'SQL'/.test(source) && /label: '表结构'/.test(source),
|
|
168
|
+
'数据库子页应有「连接 / SQL / 表结构」三个一级标题');
|
|
169
|
+
assert.ok(/const \[dbSection, setDbSection\]/.test(source), '数据库子页需要 dbSection 状态');
|
|
170
|
+
assert.ok(/\.ar-segmented \{/.test(source), '缺少 .ar-segmented 样式');
|
|
171
|
+
assert.ok(/--ar-elevated:/.test(source), '选中态需要 --ar-elevated 变量(深/浅色主题各一份)');
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 布局回归:一级标题胶囊必须横向一排。
|
|
175
|
+
* 曾用 flex-wrap: wrap,结果在窄面板(右侧栏 ~300px)里被挤成竖排,
|
|
176
|
+
* 用户看到的就是「连接 / SQL / 表结构」三个上下堆叠的方块。
|
|
177
|
+
*/
|
|
178
|
+
assert.ok(/\.ar-segmented \{[^}]*flex-wrap:\s*nowrap/s.test(source), 'ar-segmented 必须禁止换行');
|
|
179
|
+
assert.ok(/\.ar-segmented \{[^}]*overflow-x:\s*auto/s.test(source), 'ar-segmented 超出时应横向滚动');
|
|
180
|
+
assert.ok(/\.ar-seg \{[^}]*flex:\s*0 0 auto/s.test(source), '胶囊不应被压缩');
|
|
181
|
+
assert.ok(/\.ar-seg \{[^}]*white-space:\s*nowrap/s.test(source), '胶囊文字不应换行');
|
|
182
|
+
assert.ok(!/\.ar-segmented \{[^}]*flex-wrap:\s*wrap/s.test(source), 'ar-segmented 不能用 flex-wrap: wrap(会竖排)');
|
|
183
|
+
assert.ok(/\.ar-tabs \{[^}]*overflow-x:\s*auto/s.test(source), '顶部页签超出时应横向滚动');
|
|
184
|
+
assert.ok(/scrollIntoView/.test(source), '选中的胶囊应自动滚入视野');
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 样式热更新回归:installStyles 不能因为「元素已存在」就跳过,
|
|
188
|
+
* 否则插件升级后页面里还是旧 CSS(胶囊退化成原生 button、竖排),刷新也无效。
|
|
189
|
+
*/
|
|
190
|
+
assert.ok(/style\[id\^="dsh-api-runner-style"\]/.test(source), '应清理历史版本的 style 元素');
|
|
191
|
+
assert.ok(/el\.textContent !== CSS/.test(source), '样式内容变化时应替换,而不是跳过');
|
|
192
|
+
assert.ok(!/if \(document\.getElementById\(STYLE_ID\)\) return;/.test(source), '不能因 id 已存在就提前返回');
|
|
193
|
+
assert.ok(/const STYLE_ID = '[^']*-v\d+'/.test(source), 'style id 应带版本后缀,避免被按 id 去重');
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 回归:useEffect 的依赖数组里引用了「在它之后才 const 声明」的变量,
|
|
197
|
+
* 会因暂时性死区抛 `Cannot access 'x' before initialization`,
|
|
198
|
+
* 整个 Panel 崩溃 -> 面板白屏。这个 bug 真实发生过(refreshDb)。
|
|
199
|
+
*
|
|
200
|
+
* 做法:把 Panel 函数体抽出来,逐行累积「已声明的标识符」,
|
|
201
|
+
* 遇到 `}, [...])` 形式的依赖数组就检查里面的名字是否都已声明。
|
|
202
|
+
*/
|
|
203
|
+
const panelStart = source.indexOf('function Panel(props) {');
|
|
204
|
+
assert.ok(panelStart > 0, '应能定位 Panel 函数');
|
|
205
|
+
let depth = 0;
|
|
206
|
+
let panelEnd = -1;
|
|
207
|
+
for (let i = source.indexOf('{', panelStart); i < source.length; i++) {
|
|
208
|
+
if (source[i] === '{') depth += 1;
|
|
209
|
+
else if (source[i] === '}') { depth -= 1; if (depth === 0) { panelEnd = i + 1; break; } }
|
|
210
|
+
}
|
|
211
|
+
const panelBody = source.slice(panelStart, panelEnd);
|
|
212
|
+
const panelLines = panelBody.split('\n');
|
|
213
|
+
const declaredBefore = new Set();
|
|
214
|
+
const tdzIssues = [];
|
|
215
|
+
/** 全局可用的名字,不需要在 Panel 内声明 */
|
|
216
|
+
const GLOBALS = new Set(['visible', 'props', 'React', 'h', 'window', 'document', 'setInterval', 'clearInterval', 'setTimeout', 'console', 'undefined', 'null', 'true', 'false']);
|
|
217
|
+
|
|
218
|
+
for (let i = 0; i < panelLines.length; i += 1) {
|
|
219
|
+
const line = panelLines[i];
|
|
220
|
+
// 依赖数组行:`}, [a, b, c]);` 或 `}, [a, b]);`
|
|
221
|
+
const depMatch = line.match(/\},\s*\[([^\]]*)\]\s*\)/);
|
|
222
|
+
if (depMatch) {
|
|
223
|
+
for (const raw of depMatch[1].split(',')) {
|
|
224
|
+
const name = raw.trim();
|
|
225
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(name)) continue;
|
|
226
|
+
if (GLOBALS.has(name)) continue;
|
|
227
|
+
if (!declaredBefore.has(name)) {
|
|
228
|
+
tdzIssues.push(`Panel 第 ${i + 1} 行:依赖 "${name}" 在该行之前未声明(TDZ 会导致面板白屏)`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// 记录本行声明:const/let/var 单变量
|
|
233
|
+
for (const m of line.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g)) declaredBefore.add(m[1]);
|
|
234
|
+
// 记录解构声明:const [a, b] = ... / const { a, b } = ...
|
|
235
|
+
for (const m of line.matchAll(/\b(?:const|let|var)\s*\[([^\]]+)\]\s*=/g)) {
|
|
236
|
+
for (const part of m[1].split(',')) {
|
|
237
|
+
const name = part.trim().split(':').pop().trim();
|
|
238
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name)) declaredBefore.add(name);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
for (const m of line.matchAll(/\b(?:const|let|var)\s*\{([^}]+)\}\s*=/g)) {
|
|
242
|
+
for (const part of m[1].split(',')) {
|
|
243
|
+
const name = part.trim().split(':').pop().trim();
|
|
244
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name)) declaredBefore.add(name);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// 记录函数声明
|
|
248
|
+
for (const m of line.matchAll(/\bfunction\s+([A-Za-z_$][\w$]*)\s*\(/g)) declaredBefore.add(m[1]);
|
|
249
|
+
}
|
|
250
|
+
assert.deepEqual(tdzIssues, [], `useEffect 依赖里出现了「声明在之后」的变量(会导致面板白屏):\n${tdzIssues.join('\n')}`);
|
|
251
|
+
|
|
151
252
|
// 宿主没提供 slots 时 apply 要安全退出,不能抛
|
|
152
253
|
recorded.exports.apply({ effect: () => {} });
|
|
153
254
|
|