sloth-d2c-mcp 1.0.4-beta70 → 1.0.4-beta71
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/cli/run.js +5 -0
- package/dist/build/component-mapping/adapter-manager.js +45 -0
- package/dist/build/component-mapping/adapters/base-adapter.js +137 -0
- package/dist/build/component-mapping/adapters/ios-adapter.js +697 -0
- package/dist/build/component-mapping/adapters/web-adapter.js +536 -0
- package/dist/build/component-mapping/index.js +32 -0
- package/dist/build/component-mapping/storage.js +142 -0
- package/dist/build/component-mapping/types.js +4 -0
- package/dist/build/config-manager/index.js +80 -0
- package/dist/build/core/prompt-builder.js +110 -0
- package/dist/build/core/sampling.js +382 -0
- package/dist/build/core/types.js +1 -0
- package/dist/build/index.js +320 -81
- package/dist/build/server.js +510 -14
- package/dist/build/utils/file-manager.js +301 -10
- package/dist/build/utils/image-matcher.js +154 -0
- package/dist/build/utils/opencv-loader.js +70 -0
- package/dist/interceptor-web/dist/build-report.json +7 -7
- package/dist/interceptor-web/dist/detail.html +1 -1
- package/dist/interceptor-web/dist/index.html +1 -1
- package/package.json +6 -3
|
@@ -9,10 +9,22 @@ import { Logger } from '../utils/logger.js';
|
|
|
9
9
|
export class FileManager {
|
|
10
10
|
paths; // 应用路径配置
|
|
11
11
|
baseDir; // 基础存储目录
|
|
12
|
+
workspaceRoot = null; // MCP 工作目录根路径
|
|
12
13
|
constructor(appName) {
|
|
13
14
|
this.paths = envPaths(appName);
|
|
14
15
|
this.baseDir = path.join(this.paths.data, 'files');
|
|
15
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* 设置工作目录根路径(用于 MCP 工作项目)
|
|
19
|
+
* @param rootPath - 工作目录根路径
|
|
20
|
+
*/
|
|
21
|
+
setWorkspaceRoot(rootPath) {
|
|
22
|
+
this.workspaceRoot = rootPath;
|
|
23
|
+
Logger.log(`已设置工作目录根路径: ${rootPath}`);
|
|
24
|
+
}
|
|
25
|
+
getWorkspaceRoot() {
|
|
26
|
+
return this.workspaceRoot;
|
|
27
|
+
}
|
|
16
28
|
/**
|
|
17
29
|
* 生成文件路径
|
|
18
30
|
* @param fileKey - Figma文件的key
|
|
@@ -27,16 +39,46 @@ export class FileManager {
|
|
|
27
39
|
const cleanFilename = filename.replace(/[^a-zA-Z0-9-_.]/g, '_');
|
|
28
40
|
return path.join(this.baseDir, cleanFileKey, cleanNodeId, cleanFilename);
|
|
29
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* 生成工作目录中的文件路径(保存到 .sloth 文件夹)
|
|
44
|
+
* @param fileKey - Figma文件的key
|
|
45
|
+
* @param nodeId - 节点ID(可选)
|
|
46
|
+
* @param filename - 文件名
|
|
47
|
+
* @returns 完整的文件路径
|
|
48
|
+
*/
|
|
49
|
+
getWorkspaceFilePath(fileKey, nodeId, filename) {
|
|
50
|
+
if (!this.workspaceRoot) {
|
|
51
|
+
console.error('工作目录根路径未设置,使用默认目录:', this.baseDir);
|
|
52
|
+
return this.getFilePath(fileKey, nodeId, filename);
|
|
53
|
+
}
|
|
54
|
+
// 清理文件名中的特殊字符
|
|
55
|
+
const cleanFileKey = fileKey.replace(/[^a-zA-Z0-9-_]/g, '_');
|
|
56
|
+
const cleanNodeId = nodeId ? nodeId.replace(/[^a-zA-Z0-9-_:]/g, '_') : 'root';
|
|
57
|
+
const cleanFilename = filename.replace(/[^a-zA-Z0-9-_.]/g, '_');
|
|
58
|
+
return path.join(this.workspaceRoot, '.sloth', cleanFileKey, cleanNodeId, cleanFilename);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 获取工作目录下特定 fileKey 的目录路径
|
|
62
|
+
* @param fileKey - Figma 文件的 key
|
|
63
|
+
* @returns string | null
|
|
64
|
+
*/
|
|
65
|
+
getWorkspaceFileKeyDirPath(fileKey) {
|
|
66
|
+
if (!this.workspaceRoot)
|
|
67
|
+
return null;
|
|
68
|
+
const cleanFileKey = fileKey.replace(/[^a-zA-Z0-9-_]/g, '_');
|
|
69
|
+
return path.join(this.workspaceRoot, '.sloth', cleanFileKey);
|
|
70
|
+
}
|
|
30
71
|
/**
|
|
31
72
|
* 保存文件内容
|
|
32
73
|
* @param fileKey - Figma文件的key
|
|
33
74
|
* @param nodeId - 节点ID(可选)
|
|
34
75
|
* @param filename - 文件名
|
|
35
76
|
* @param content - 文件内容
|
|
77
|
+
* @param useWorkspaceDir - 是否使用工作目录
|
|
36
78
|
*/
|
|
37
|
-
async saveFile(fileKey, nodeId, filename, content) {
|
|
79
|
+
async saveFile(fileKey, nodeId, filename, content, useWorkspaceDir = false) {
|
|
38
80
|
try {
|
|
39
|
-
const filePath = this.getFilePath(fileKey, nodeId, filename);
|
|
81
|
+
const filePath = useWorkspaceDir ? this.getWorkspaceFilePath(fileKey, nodeId, filename) : this.getFilePath(fileKey, nodeId, filename);
|
|
40
82
|
// 确保目录存在
|
|
41
83
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
42
84
|
// 保存文件内容
|
|
@@ -53,11 +95,13 @@ export class FileManager {
|
|
|
53
95
|
* @param fileKey - Figma文件的key
|
|
54
96
|
* @param nodeId - 节点ID(可选)
|
|
55
97
|
* @param filename - 文件名
|
|
98
|
+
* @param useWorkspaceDir - 是否使用工作目录
|
|
56
99
|
* @returns Promise<string> - 文件内容,如果文件不存在则返回空字符串
|
|
57
100
|
*/
|
|
58
|
-
async loadFile(fileKey, nodeId, filename) {
|
|
101
|
+
async loadFile(fileKey, nodeId, filename, useWorkspaceDir = false) {
|
|
59
102
|
try {
|
|
60
|
-
const filePath = this.getFilePath(fileKey, nodeId, filename);
|
|
103
|
+
const filePath = useWorkspaceDir ? this.getWorkspaceFilePath(fileKey, nodeId, filename) : this.getFilePath(fileKey, nodeId, filename);
|
|
104
|
+
Logger.log(`加载文件: ${filePath}, workspaceRoot: ${this.workspaceRoot}`);
|
|
61
105
|
const content = await fs.readFile(filePath, 'utf-8');
|
|
62
106
|
return content;
|
|
63
107
|
}
|
|
@@ -188,13 +232,13 @@ export class FileManager {
|
|
|
188
232
|
return path.join(this.baseDir, cleanFileKey, cleanNodeId);
|
|
189
233
|
}
|
|
190
234
|
/**
|
|
191
|
-
* 保存 groupsData 到特定的 nodeId
|
|
235
|
+
* 保存 groupsData 到特定的 nodeId 文件(保存到工作目录的 .sloth 文件夹)
|
|
192
236
|
* @param fileKey - Figma文件的key
|
|
193
237
|
* @param nodeId - 节点ID(可选)
|
|
194
238
|
* @param groupsData - 分组数据
|
|
195
239
|
*/
|
|
196
240
|
async saveGroupsData(fileKey, nodeId, groupsData) {
|
|
197
|
-
await this.saveFile(fileKey, nodeId, 'groupsData.json', JSON.stringify(groupsData, null, 2));
|
|
241
|
+
await this.saveFile(fileKey, nodeId, 'groupsData.json', JSON.stringify(groupsData, null, 2), true);
|
|
198
242
|
}
|
|
199
243
|
/**
|
|
200
244
|
* 加载 groupsData 从特定的 nodeId 文件
|
|
@@ -204,7 +248,7 @@ export class FileManager {
|
|
|
204
248
|
*/
|
|
205
249
|
async loadGroupsData(fileKey, nodeId) {
|
|
206
250
|
try {
|
|
207
|
-
const content = await this.loadFile(fileKey, nodeId, 'groupsData.json');
|
|
251
|
+
const content = await this.loadFile(fileKey, nodeId, 'groupsData.json', true);
|
|
208
252
|
return content ? JSON.parse(content) : [];
|
|
209
253
|
}
|
|
210
254
|
catch (error) {
|
|
@@ -213,13 +257,13 @@ export class FileManager {
|
|
|
213
257
|
}
|
|
214
258
|
}
|
|
215
259
|
/**
|
|
216
|
-
* 保存 promptSetting 到特定的 nodeId
|
|
260
|
+
* 保存 promptSetting 到特定的 nodeId 文件(保存到工作目录的 .sloth 文件夹)
|
|
217
261
|
* @param fileKey - Figma文件的key
|
|
218
262
|
* @param nodeId - 节点ID(可选)
|
|
219
263
|
* @param promptSetting - 提示词设置
|
|
220
264
|
*/
|
|
221
265
|
async savePromptSetting(fileKey, nodeId, promptSetting) {
|
|
222
|
-
await this.saveFile(fileKey, nodeId, 'promptSetting.json', JSON.stringify(promptSetting, null, 2));
|
|
266
|
+
await this.saveFile(fileKey, nodeId, 'promptSetting.json', JSON.stringify(promptSetting, null, 2), true);
|
|
223
267
|
}
|
|
224
268
|
/**
|
|
225
269
|
* 加载 promptSetting 从特定的 nodeId 文件
|
|
@@ -229,7 +273,7 @@ export class FileManager {
|
|
|
229
273
|
*/
|
|
230
274
|
async loadPromptSetting(fileKey, nodeId) {
|
|
231
275
|
try {
|
|
232
|
-
const content = await this.loadFile(fileKey, nodeId, 'promptSetting.json');
|
|
276
|
+
const content = await this.loadFile(fileKey, nodeId, 'promptSetting.json', true);
|
|
233
277
|
return content ? JSON.parse(content) : null;
|
|
234
278
|
}
|
|
235
279
|
catch (error) {
|
|
@@ -237,5 +281,252 @@ export class FileManager {
|
|
|
237
281
|
return null;
|
|
238
282
|
}
|
|
239
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* 保存 configSetting 到特定的 nodeId 文件(保存到工作目录的 .sloth 文件夹)
|
|
286
|
+
* @param fileKey - Figma文件的key
|
|
287
|
+
* @param nodeId - 节点ID(可选)
|
|
288
|
+
* @param configSetting - 配置设置
|
|
289
|
+
*/
|
|
290
|
+
async saveConfigSetting(fileKey, nodeId, configSetting) {
|
|
291
|
+
await this.saveFile(fileKey, nodeId, 'configSetting.json', JSON.stringify(configSetting, null, 2), true);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* 加载 configSetting 从特定的 nodeId 文件
|
|
295
|
+
* @param fileKey - Figma文件的key
|
|
296
|
+
* @param nodeId - 节点ID(可选)
|
|
297
|
+
* @returns Promise<any> - 配置设置,如果文件不存在则返回 null
|
|
298
|
+
*/
|
|
299
|
+
async loadConfigSetting(fileKey, nodeId) {
|
|
300
|
+
try {
|
|
301
|
+
const content = await this.loadFile(fileKey, nodeId, 'configSetting.json', true);
|
|
302
|
+
return content ? JSON.parse(content) : null;
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
Logger.log(`加载 configSetting 失败,返回 null: ${error}`);
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* 列出指定 fileKey 下的所有 nodeId(目录名)
|
|
311
|
+
* @param fileKey - Figma 文件的 key
|
|
312
|
+
*/
|
|
313
|
+
async listNodeIds(fileKey) {
|
|
314
|
+
const workspaceDir = this.getWorkspaceFileKeyDirPath(fileKey);
|
|
315
|
+
const targetDir = workspaceDir || this.getFileKeyDir(fileKey);
|
|
316
|
+
try {
|
|
317
|
+
const dirents = await fs.readdir(targetDir, { withFileTypes: true });
|
|
318
|
+
return dirents.filter((d) => d.isDirectory()).map((d) => d.name);
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return [];
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* 列出所有 fileKey
|
|
326
|
+
* @returns Promise<string[]> - 所有 fileKey 的列表
|
|
327
|
+
*/
|
|
328
|
+
async listAllFileKeys() {
|
|
329
|
+
try {
|
|
330
|
+
if (!this.workspaceRoot) {
|
|
331
|
+
Logger.log('workspaceRoot 未设置,无法列出所有 fileKey');
|
|
332
|
+
return [];
|
|
333
|
+
}
|
|
334
|
+
const slothDir = path.join(this.workspaceRoot, '.sloth');
|
|
335
|
+
const entries = await fs.readdir(slothDir, { withFileTypes: true });
|
|
336
|
+
const fileKeys = entries
|
|
337
|
+
.filter(entry => entry.isDirectory())
|
|
338
|
+
.map(entry => entry.name);
|
|
339
|
+
Logger.log(`找到 ${fileKeys.length} 个 fileKey:`, fileKeys);
|
|
340
|
+
return fileKeys;
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return [];
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* 加载指定 fileKey 下所有 nodeId 的 groupsData
|
|
348
|
+
* @param fileKey - Figma 文件的 key
|
|
349
|
+
*/
|
|
350
|
+
async loadAllGroupsData(fileKey) {
|
|
351
|
+
const nodeIds = await this.listNodeIds(fileKey);
|
|
352
|
+
const results = [];
|
|
353
|
+
Logger.log(`加载指定 fileKey 下所有 nodeId 的 groupsData: ${fileKey}, nodeIds: ${nodeIds}`);
|
|
354
|
+
if (nodeIds.length === 0) {
|
|
355
|
+
const groups = await this.loadGroupsData(fileKey, undefined);
|
|
356
|
+
if (groups.length > 0) {
|
|
357
|
+
results.push({ nodeId: 'root', groups });
|
|
358
|
+
}
|
|
359
|
+
return results;
|
|
360
|
+
}
|
|
361
|
+
for (const nodeId of nodeIds) {
|
|
362
|
+
const normalizedNodeId = nodeId === 'root' ? undefined : nodeId;
|
|
363
|
+
Logger.log(`加载指定 fileKey 下 nodeId 的 groupsData: ${fileKey}, nodeId: ${nodeId}`);
|
|
364
|
+
const groups = await this.loadGroupsData(fileKey, normalizedNodeId);
|
|
365
|
+
if (groups && groups.length > 0) {
|
|
366
|
+
results.push({ nodeId, groups });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return results;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* 加载整个项目所有 fileKey 的所有 groupsData(用于跨文件组件匹配)
|
|
373
|
+
* @returns Promise<Array<{ fileKey: string; nodeId: string; groups: any[] }>>
|
|
374
|
+
*/
|
|
375
|
+
async loadAllProjectGroupsData() {
|
|
376
|
+
const fileKeys = await this.listAllFileKeys();
|
|
377
|
+
const results = [];
|
|
378
|
+
Logger.log(`开始加载整个项目的 groupsData,共 ${fileKeys.length} 个 fileKey`);
|
|
379
|
+
for (const fileKey of fileKeys) {
|
|
380
|
+
const groupsDataByNode = await this.loadAllGroupsData(fileKey);
|
|
381
|
+
for (const { nodeId, groups } of groupsDataByNode) {
|
|
382
|
+
if (groups && groups.length > 0) {
|
|
383
|
+
results.push({ fileKey, nodeId, groups });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
Logger.log(`项目 groupsData 加载完成,共 ${results.length} 个节点有分组数据`);
|
|
388
|
+
return results;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* 获取 screenshots 目录路径
|
|
392
|
+
* @param fileKey - Figma文件的key
|
|
393
|
+
* @param nodeId - 节点ID(可选)
|
|
394
|
+
* @returns string - screenshots 目录的完整路径
|
|
395
|
+
*/
|
|
396
|
+
getScreenshotsDir(fileKey, nodeId) {
|
|
397
|
+
const nodeDir = this.getWorkspaceFilePath(fileKey, nodeId, '');
|
|
398
|
+
return path.join(nodeDir, 'screenshots');
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* 保存截图文件
|
|
402
|
+
* @param fileKey - Figma文件的key
|
|
403
|
+
* @param nodeId - 节点ID(可选)
|
|
404
|
+
* @param hash - 截图文件的哈希值(作为文件名)
|
|
405
|
+
* @param buffer - 截图文件的二进制数据
|
|
406
|
+
*/
|
|
407
|
+
async saveScreenshot(fileKey, nodeId, hash, buffer) {
|
|
408
|
+
try {
|
|
409
|
+
const screenshotsDir = this.getScreenshotsDir(fileKey, nodeId);
|
|
410
|
+
// 确保目录存在
|
|
411
|
+
await fs.mkdir(screenshotsDir, { recursive: true });
|
|
412
|
+
const filePath = path.join(screenshotsDir, `${hash}.png`);
|
|
413
|
+
await fs.writeFile(filePath, buffer);
|
|
414
|
+
Logger.log(`截图已保存: ${filePath}`);
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
Logger.error(`保存截图失败: ${error}`);
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* 加载截图文件
|
|
423
|
+
* @param fileKey - Figma文件的key
|
|
424
|
+
* @param nodeId - 节点ID(可选)
|
|
425
|
+
* @param hash - 截图文件的哈希值
|
|
426
|
+
* @returns Promise<Buffer> - 截图文件的二进制数据
|
|
427
|
+
*/
|
|
428
|
+
async loadScreenshot(fileKey, nodeId, hash) {
|
|
429
|
+
try {
|
|
430
|
+
const screenshotsDir = this.getScreenshotsDir(fileKey, nodeId);
|
|
431
|
+
const filePath = path.join(screenshotsDir, `${hash}.png`);
|
|
432
|
+
const buffer = await fs.readFile(filePath);
|
|
433
|
+
return buffer;
|
|
434
|
+
}
|
|
435
|
+
catch (err) {
|
|
436
|
+
if (err.code === 'ENOENT') {
|
|
437
|
+
Logger.log(`截图文件不存在: ${hash}.png`);
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
Logger.error(`加载截图失败: ${err.toString()}`);
|
|
441
|
+
throw err;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* 检查截图文件是否存在
|
|
446
|
+
* @param fileKey - Figma文件的key
|
|
447
|
+
* @param nodeId - 节点ID(可选)
|
|
448
|
+
* @param hash - 截图文件的哈希值
|
|
449
|
+
* @returns Promise<boolean> - 如果文件存在则返回 true
|
|
450
|
+
*/
|
|
451
|
+
async screenshotExists(fileKey, nodeId, hash) {
|
|
452
|
+
try {
|
|
453
|
+
const screenshotsDir = this.getScreenshotsDir(fileKey, nodeId);
|
|
454
|
+
const filePath = path.join(screenshotsDir, `${hash}.png`);
|
|
455
|
+
await fs.access(filePath);
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* 获取截图文件的完整路径
|
|
464
|
+
* @param fileKey - Figma文件的key
|
|
465
|
+
* @param nodeId - 节点ID(可选)
|
|
466
|
+
* @param hash - 截图文件的哈希值
|
|
467
|
+
* @returns string - 截图文件的完整路径
|
|
468
|
+
*/
|
|
469
|
+
getScreenshotPath(fileKey, nodeId, hash) {
|
|
470
|
+
const screenshotsDir = this.getScreenshotsDir(fileKey, nodeId);
|
|
471
|
+
return path.join(screenshotsDir, `${hash}.png`);
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* 删除截图文件
|
|
475
|
+
* @param fileKey - Figma文件的key
|
|
476
|
+
* @param nodeId - 节点ID(可选)
|
|
477
|
+
* @param hash - 截图文件的哈希值
|
|
478
|
+
*/
|
|
479
|
+
async deleteScreenshot(fileKey, nodeId, hash) {
|
|
480
|
+
try {
|
|
481
|
+
const screenshotsDir = this.getScreenshotsDir(fileKey, nodeId);
|
|
482
|
+
const filePath = path.join(screenshotsDir, `${hash}.png`);
|
|
483
|
+
await fs.unlink(filePath);
|
|
484
|
+
Logger.log(`截图已删除: ${filePath}`);
|
|
485
|
+
}
|
|
486
|
+
catch (err) {
|
|
487
|
+
if (err.code !== 'ENOENT') {
|
|
488
|
+
Logger.error(`删除截图失败: ${err.toString()}`);
|
|
489
|
+
throw err;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* 清理旧的截图文件(根据时间戳)
|
|
495
|
+
* @param fileKey - Figma文件的key
|
|
496
|
+
* @param nodeId - 节点ID(可选)
|
|
497
|
+
* @param maxAgeDays - 最大保留天数
|
|
498
|
+
*/
|
|
499
|
+
async cleanupOldScreenshots(fileKey, nodeId, maxAgeDays = 30) {
|
|
500
|
+
try {
|
|
501
|
+
const screenshotsDir = this.getScreenshotsDir(fileKey, nodeId);
|
|
502
|
+
// 检查目录是否存在
|
|
503
|
+
try {
|
|
504
|
+
await fs.access(screenshotsDir);
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
// 目录不存在,直接返回
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const files = await fs.readdir(screenshotsDir);
|
|
511
|
+
const now = Date.now();
|
|
512
|
+
const maxAge = maxAgeDays * 24 * 60 * 60 * 1000;
|
|
513
|
+
let deletedCount = 0;
|
|
514
|
+
for (const file of files) {
|
|
515
|
+
const filePath = path.join(screenshotsDir, file);
|
|
516
|
+
const stats = await fs.stat(filePath);
|
|
517
|
+
const age = now - stats.mtimeMs;
|
|
518
|
+
if (age > maxAge) {
|
|
519
|
+
await fs.unlink(filePath);
|
|
520
|
+
deletedCount++;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (deletedCount > 0) {
|
|
524
|
+
Logger.log(`清理了 ${deletedCount} 个旧截图文件(超过 ${maxAgeDays} 天)`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
catch (error) {
|
|
528
|
+
Logger.error(`清理旧截图失败: ${error}`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
240
531
|
}
|
|
241
532
|
export default FileManager;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import Jimp from 'jimp';
|
|
2
|
+
import { getCV } from './opencv-loader.js';
|
|
3
|
+
import { Logger } from './logger.js';
|
|
4
|
+
export class ImageMatcher {
|
|
5
|
+
/**
|
|
6
|
+
* 使用 OpenCV 模板匹配在大图中查找小图
|
|
7
|
+
* @param haystackPath 大图路径(当前设计稿)
|
|
8
|
+
* @param needlePath 小图路径(历史截图)
|
|
9
|
+
* @param threshold 匹配阈值 (0-1)
|
|
10
|
+
*/
|
|
11
|
+
async findMatch(haystackPath, needlePath, threshold = 0.8) {
|
|
12
|
+
try {
|
|
13
|
+
const startTime = Date.now();
|
|
14
|
+
Logger.log(`开始 OpenCV 模板匹配: ${needlePath}`);
|
|
15
|
+
// 获取 OpenCV 实例
|
|
16
|
+
const cv = getCV();
|
|
17
|
+
// 使用 Jimp 读取图片
|
|
18
|
+
const haystackImg = await Jimp.read(haystackPath);
|
|
19
|
+
const needleImg = await Jimp.read(needlePath);
|
|
20
|
+
Logger.log(`大图尺寸: ${haystackImg.bitmap.width}x${haystackImg.bitmap.height}`);
|
|
21
|
+
Logger.log(`小图尺寸: ${needleImg.bitmap.width}x${needleImg.bitmap.height}`);
|
|
22
|
+
// 转换为 OpenCV Mat
|
|
23
|
+
const haystack = this.jimpToMat(haystackImg, cv);
|
|
24
|
+
const needle = this.jimpToMat(needleImg, cv);
|
|
25
|
+
// 创建结果矩阵
|
|
26
|
+
const result = new cv.Mat();
|
|
27
|
+
const mask = new cv.Mat();
|
|
28
|
+
// 执行模板匹配
|
|
29
|
+
// TM_CCOEFF_NORMED: 归一化相关系数匹配,返回值 -1 到 1
|
|
30
|
+
cv.matchTemplate(haystack, needle, result, cv.TM_CCOEFF_NORMED, mask);
|
|
31
|
+
// 找到最佳匹配位置
|
|
32
|
+
const minMax = cv.minMaxLoc(result);
|
|
33
|
+
const maxVal = minMax.maxVal; // 最大相似度(-1 到 1)
|
|
34
|
+
const maxLoc = minMax.maxLoc; // 最佳匹配位置
|
|
35
|
+
const elapsedTime = Date.now() - startTime;
|
|
36
|
+
Logger.log(`OpenCV 匹配完成,耗时: ${elapsedTime}ms,最大相似度: ${maxVal.toFixed(4)}`);
|
|
37
|
+
// 清理内存
|
|
38
|
+
haystack.delete();
|
|
39
|
+
needle.delete();
|
|
40
|
+
result.delete();
|
|
41
|
+
mask.delete();
|
|
42
|
+
// 判断是否满足阈值
|
|
43
|
+
if (maxVal >= threshold) {
|
|
44
|
+
return {
|
|
45
|
+
matched: true,
|
|
46
|
+
confidence: maxVal,
|
|
47
|
+
position: {
|
|
48
|
+
left: maxLoc.x,
|
|
49
|
+
top: maxLoc.y,
|
|
50
|
+
width: needleImg.bitmap.width,
|
|
51
|
+
height: needleImg.bitmap.height,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
Logger.log(`匹配失败,相似度 ${maxVal.toFixed(4)} 低于阈值 ${threshold}`);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
Logger.error('OpenCV 图像匹配失败:', error);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 将 Jimp 图像转换为 OpenCV Mat
|
|
65
|
+
*/
|
|
66
|
+
jimpToMat(jimpImg, cv) {
|
|
67
|
+
const { width, height, data } = jimpImg.bitmap;
|
|
68
|
+
// 创建 Mat (RGBA 格式)
|
|
69
|
+
const mat = new cv.Mat(height, width, cv.CV_8UC4);
|
|
70
|
+
// 复制像素数据
|
|
71
|
+
mat.data.set(data);
|
|
72
|
+
// 转换为 RGB(模板匹配只需要 RGB,去掉 Alpha 通道)
|
|
73
|
+
const rgbMat = new cv.Mat();
|
|
74
|
+
cv.cvtColor(mat, rgbMat, cv.COLOR_RGBA2RGB);
|
|
75
|
+
mat.delete();
|
|
76
|
+
return rgbMat;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* 使用多尺度模板匹配(处理截图可能有缩放的情况)
|
|
80
|
+
* @param haystackPath 大图路径
|
|
81
|
+
* @param needlePath 小图路径
|
|
82
|
+
* @param threshold 匹配阈值
|
|
83
|
+
* @param scales 尝试的缩放比例列表
|
|
84
|
+
*/
|
|
85
|
+
async findMatchMultiScale(haystackPath, needlePath, threshold = 0.8, scales = [1.0, 0.95, 1.05, 0.9, 1.1]) {
|
|
86
|
+
Logger.log(`开始多尺度匹配,尝试 ${scales.length} 个缩放比例`);
|
|
87
|
+
let bestMatch = null;
|
|
88
|
+
for (const scale of scales) {
|
|
89
|
+
try {
|
|
90
|
+
// 读取并缩放模板图片
|
|
91
|
+
const needleImg = await Jimp.read(needlePath);
|
|
92
|
+
if (scale !== 1.0) {
|
|
93
|
+
needleImg.scale(scale);
|
|
94
|
+
Logger.log(`尝试缩放比例: ${scale}`);
|
|
95
|
+
}
|
|
96
|
+
// 保存临时缩放图片
|
|
97
|
+
const fs = await import('fs/promises');
|
|
98
|
+
const path = await import('path');
|
|
99
|
+
const tempPath = needlePath.replace('.png', `_scale${scale}.png`);
|
|
100
|
+
await needleImg.writeAsync(tempPath);
|
|
101
|
+
// 执行匹配
|
|
102
|
+
const result = await this.findMatch(haystackPath, tempPath, threshold);
|
|
103
|
+
// 清理临时文件
|
|
104
|
+
await fs.unlink(tempPath).catch(() => { });
|
|
105
|
+
if (result && (!bestMatch || result.confidence > bestMatch.confidence)) {
|
|
106
|
+
// 调整位置和尺寸(补偿缩放)
|
|
107
|
+
result.position.width = Math.round(result.position.width / scale);
|
|
108
|
+
result.position.height = Math.round(result.position.height / scale);
|
|
109
|
+
bestMatch = result;
|
|
110
|
+
Logger.log(`找到更好的匹配,缩放比例: ${scale},置信度: ${result.confidence.toFixed(4)}`);
|
|
111
|
+
}
|
|
112
|
+
// 如果找到完美匹配,提前退出
|
|
113
|
+
if (result && result.confidence >= 0.95) {
|
|
114
|
+
Logger.log('找到完美匹配,提前退出多尺度搜索');
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
Logger.error(`缩放比例 ${scale} 匹配失败:`, error);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (bestMatch) {
|
|
124
|
+
Logger.log(`多尺度匹配完成,最佳置信度: ${bestMatch.confidence.toFixed(4)}`);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
Logger.log('多尺度匹配未找到符合要求的结果');
|
|
128
|
+
}
|
|
129
|
+
return bestMatch;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* 批量匹配多个历史截图
|
|
133
|
+
*/
|
|
134
|
+
async batchMatch(haystackPath, needlePaths, threshold = 0.8) {
|
|
135
|
+
Logger.log(`开始批量匹配 ${needlePaths.length} 个历史截图`);
|
|
136
|
+
const matches = [];
|
|
137
|
+
for (const { path, groupData } of needlePaths) {
|
|
138
|
+
try {
|
|
139
|
+
const result = await this.findMatch(haystackPath, path, threshold);
|
|
140
|
+
if (result && result.matched) {
|
|
141
|
+
result.groupData = groupData;
|
|
142
|
+
matches.push(result);
|
|
143
|
+
Logger.log(`匹配成功: ${groupData.componentMapping?.name || 'Unknown'}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
Logger.error(`批量匹配失败 (${path}):`, error);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
Logger.log(`批量匹配完成,找到 ${matches.length} 个匹配`);
|
|
152
|
+
return matches;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Logger } from './logger.js';
|
|
2
|
+
let cv = null;
|
|
3
|
+
let isOpencvReady = false;
|
|
4
|
+
/**
|
|
5
|
+
* 初始化 OpenCV.js
|
|
6
|
+
*/
|
|
7
|
+
export async function initOpenCV() {
|
|
8
|
+
if (isOpencvReady && cv) {
|
|
9
|
+
Logger.log('OpenCV.js 已经初始化');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
Logger.log('开始初始化 OpenCV.js...');
|
|
14
|
+
// 动态导入 OpenCV.js
|
|
15
|
+
const opencvModule = await import('@techstark/opencv-js');
|
|
16
|
+
cv = opencvModule.default || opencvModule;
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const timeout = setTimeout(() => {
|
|
19
|
+
reject(new Error('OpenCV.js 初始化超时(30秒)'));
|
|
20
|
+
}, 30000);
|
|
21
|
+
if (cv.getBuildInformation) {
|
|
22
|
+
// OpenCV 已经加载完成
|
|
23
|
+
clearTimeout(timeout);
|
|
24
|
+
isOpencvReady = true;
|
|
25
|
+
Logger.log('OpenCV.js 已就绪');
|
|
26
|
+
Logger.log('OpenCV 版本信息:', cv.getBuildInformation());
|
|
27
|
+
resolve();
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
// 等待 OpenCV 运行时初始化
|
|
31
|
+
cv.onRuntimeInitialized = () => {
|
|
32
|
+
clearTimeout(timeout);
|
|
33
|
+
isOpencvReady = true;
|
|
34
|
+
Logger.log('OpenCV.js 运行时初始化完成');
|
|
35
|
+
if (cv.getBuildInformation) {
|
|
36
|
+
Logger.log('OpenCV 版本信息:', cv.getBuildInformation());
|
|
37
|
+
}
|
|
38
|
+
resolve();
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
Logger.error('OpenCV.js 初始化失败:', error);
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 检查 OpenCV 是否就绪
|
|
50
|
+
*/
|
|
51
|
+
export function isReady() {
|
|
52
|
+
return isOpencvReady;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* 获取 OpenCV 实例
|
|
56
|
+
*/
|
|
57
|
+
export function getCV() {
|
|
58
|
+
if (!cv || !isOpencvReady) {
|
|
59
|
+
throw new Error('OpenCV.js 尚未初始化,请先调用 initOpenCV()');
|
|
60
|
+
}
|
|
61
|
+
return cv;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 清理 OpenCV 资源(如果需要)
|
|
65
|
+
*/
|
|
66
|
+
export function cleanup() {
|
|
67
|
+
isOpencvReady = false;
|
|
68
|
+
cv = null;
|
|
69
|
+
Logger.log('OpenCV.js 资源已清理');
|
|
70
|
+
}
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
|
-
"buildTime": "2025-
|
|
2
|
+
"buildTime": "2025-12-01T03:49:02.385Z",
|
|
3
3
|
"mode": "build",
|
|
4
4
|
"pages": {
|
|
5
5
|
"main": {
|
|
6
6
|
"file": "index.html",
|
|
7
|
-
"size":
|
|
8
|
-
"sizeFormatted": "1.
|
|
7
|
+
"size": 1414275,
|
|
8
|
+
"sizeFormatted": "1.35 MB"
|
|
9
9
|
},
|
|
10
10
|
"detail": {
|
|
11
11
|
"file": "detail.html",
|
|
12
|
-
"size":
|
|
13
|
-
"sizeFormatted": "
|
|
12
|
+
"size": 281682,
|
|
13
|
+
"sizeFormatted": "275.08 KB"
|
|
14
14
|
}
|
|
15
15
|
},
|
|
16
|
-
"totalSize":
|
|
17
|
-
"totalSizeFormatted": "1.
|
|
16
|
+
"totalSize": 1695957,
|
|
17
|
+
"totalSizeFormatted": "1.62 MB"
|
|
18
18
|
}
|