koishi-plugin-docker-control 0.0.3 → 0.0.5

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.
@@ -6,6 +6,9 @@ exports.generateResultHtml = generateResultHtml;
6
6
  exports.generateInspectHtml = generateInspectHtml;
7
7
  exports.generateNodesHtml = generateNodesHtml;
8
8
  exports.generateNodeDetailHtml = generateNodeDetailHtml;
9
+ exports.generateLogsHtml = generateLogsHtml;
10
+ exports.generateExecHtml = generateExecHtml;
11
+ exports.generateComposeHtml = generateComposeHtml;
9
12
  const koishi_1 = require("koishi");
10
13
  // 基础样式
11
14
  const STYLE = `
@@ -121,6 +124,14 @@ const STYLE = `
121
124
  .detail-value.highlight {
122
125
  color: #60a5fa;
123
126
  }
127
+ .detail-span {
128
+ grid-column: 1 / -1;
129
+ }
130
+ .detail-span .detail-value {
131
+ white-space: pre-wrap;
132
+ font-size: 13px;
133
+ line-height: 1.6;
134
+ }
124
135
 
125
136
  /* 操作结果样式 */
126
137
  .result-card {
@@ -167,26 +178,30 @@ async function renderToImage(ctx, html, options = {}) {
167
178
  throw new Error('未安装 koishi-plugin-puppeteer 插件');
168
179
  }
169
180
  return ctx.puppeteer.render(html, async (page, next) => {
170
- // 设置适当的视口,高度设大一点以便 content 自适应,然后截图 clip
181
+ // 1. 设置初始视口
171
182
  await page.setViewport({
172
- width: options.width || 700,
173
- height: options.height || 1000,
174
- deviceScaleFactor: 2 // 高清渲染
183
+ width: options.width || 800,
184
+ height: options.height || 100,
185
+ deviceScaleFactor: 2
175
186
  });
176
- // 等待内容渲染
187
+ // 2. 等待内容渲染
177
188
  const body = await page.$('body');
178
189
  const wrapper = await page.$('.wrapper');
179
- // 获取 wrapper 的实际大小
180
- const clip = await wrapper?.boundingBox() || await body?.boundingBox();
181
- if (clip) {
182
- // 增加一点 padding 截图
183
- // clip.x -= 10
184
- // clip.y -= 10
185
- // clip.width += 20
186
- // clip.height += 20
187
- // 直接截取 content
188
- const buffer = await page.screenshot({ clip });
189
- return koishi_1.h.image(buffer, 'image/png').toString();
190
+ // 3. 获取实际内容的高度
191
+ const boundingBox = await wrapper?.boundingBox() || await body?.boundingBox();
192
+ if (boundingBox) {
193
+ // 调整视口高度以匹配内容
194
+ await page.setViewport({
195
+ width: options.width || 800,
196
+ height: Math.ceil(boundingBox.height) + 100,
197
+ deviceScaleFactor: 2
198
+ });
199
+ // 重新获取 clip (因为视口变化可能导致重绘)
200
+ const finalClip = await wrapper?.boundingBox() || await body?.boundingBox();
201
+ if (finalClip) {
202
+ const buffer = await page.screenshot({ clip: finalClip });
203
+ return koishi_1.h.image(buffer, 'image/png').toString();
204
+ }
190
205
  }
191
206
  // Fallback
192
207
  const buffer = await page.screenshot({ fullPage: true });
@@ -289,25 +304,57 @@ function generateInspectHtml(nodeName, info) {
289
304
  const name = info.Name.replace('/', '');
290
305
  const shortId = info.Id.slice(0, 12);
291
306
  const isRunning = info.State.Running;
307
+ // 网络信息
308
+ const networks = info.NetworkSettings?.Networks;
309
+ const networkInfo = networks && Object.keys(networks).length > 0
310
+ ? Object.entries(networks).map(([name, net]) => {
311
+ const n = net;
312
+ const ip = n.IPAddress || '-';
313
+ const gateway = n.Gateway || '-';
314
+ return ` ${name}: ${ip} (GW: ${gateway})`;
315
+ }).join('\n')
316
+ : '-';
317
+ // 环境变量
318
+ const envVars = info.Config?.Env || [];
319
+ const envDisplay = envVars.length > 0
320
+ ? envVars.slice(0, 10).map(e => {
321
+ const [key, ...val] = e.split('=');
322
+ return ` ${key}=${val.join('=').slice(0, 50)}${val.join('=').length > 50 ? '...' : ''}`;
323
+ }).join('\n') + (envVars.length > 10 ? `\n ... (共 ${envVars.length} 个)` : '')
324
+ : '-';
325
+ // 重启策略
326
+ const restartPolicy = info.HostConfig?.RestartPolicy;
327
+ const restartDisplay = restartPolicy
328
+ ? `${restartPolicy.Name}${restartPolicy.Name !== 'no' ? ` (最大 ${restartPolicy.MaximumRetryCount} 次重试)` : ''}`
329
+ : 'no';
330
+ // 挂载目录
331
+ const mounts = info.Mounts || [];
332
+ const mountsDisplay = mounts.length > 0
333
+ ? mounts.map((m) => {
334
+ const mount = m;
335
+ return ` ${mount.Source} → ${mount.Destination} (${mount.Type})`;
336
+ }).join('\n')
337
+ : '-';
292
338
  const items = [
293
- { label: '容器名称', value: name },
294
- { label: '容器 ID', value: info.Id },
295
- { label: '镜像', value: info.Config.Image },
296
- { label: '状态', value: info.State.Status, highlight: true },
297
- { label: '创建时间', value: new Date(info.Created).toLocaleString() },
298
- { label: '启动时间', value: new Date(info.State.StartedAt).toLocaleString() },
299
- { label: '重启次数', value: info.RestartCount },
300
- { label: 'IP 地址', value: info.NetworkSettings?.IPAddress || '-' },
301
- { label: '平台', value: info.Platform || 'linux' },
302
- { label: '驱动', value: info.Driver },
339
+ { label: '容器名称', value: name, span: false },
340
+ { label: '容器 ID', value: info.Id, span: false },
341
+ { label: '镜像', value: info.Config.Image, span: false },
342
+ { label: '状态', value: info.State.Status, highlight: true, span: false },
343
+ { label: '创建时间', value: new Date(info.Created).toLocaleString(), span: false },
344
+ { label: '启动时间', value: new Date(info.State.StartedAt).toLocaleString(), span: false },
345
+ { label: '重启策略', value: restartDisplay, span: false },
346
+ { label: '重启次数', value: String(info.RestartCount), span: false },
347
+ { label: '网络', value: networkInfo, span: true },
348
+ { label: '环境变量', value: envDisplay, span: true },
349
+ { label: '挂载目录', value: mountsDisplay, span: true },
303
350
  ];
304
351
  if (info.State.Health) {
305
- items.push({ label: '健康状态', value: info.State.Health.Status, highlight: true });
352
+ items.push({ label: '健康状态', value: info.State.Health.Status, highlight: true, span: false });
306
353
  }
307
354
  const gridItems = items.map(item => `
308
- <div class="detail-item">
355
+ <div class="detail-item ${item.span ? 'detail-span' : ''}">
309
356
  <div class="detail-label">${item.label}</div>
310
- <div class="detail-value ${item.highlight ? 'highlight' : ''}">${item.value}</div>
357
+ <div class="detail-value ${item.highlight ? 'highlight' : ''}">${item.value.replace(/\n/g, '<br>')}</div>
311
358
  </div>
312
359
  `).join('');
313
360
  const header = `
@@ -330,7 +377,6 @@ function generateInspectHtml(nodeName, info) {
330
377
  ${gridItems}
331
378
  </div>
332
379
  </div>
333
- <!--Mounts/Ports could be added here-->
334
380
  </div>
335
381
  `;
336
382
  return wrapHtml(header + body);
@@ -339,22 +385,27 @@ function generateInspectHtml(nodeName, info) {
339
385
  * 生成节点列表 HTML
340
386
  */
341
387
  function generateNodesHtml(nodes) {
342
- const onlineCount = nodes.filter(n => n.status === 'connected').length;
388
+ // 兼容字段名称
389
+ const getStatus = (n) => n.status || n.Status || 'unknown';
390
+ const getName = (n) => n.name || n.Name || 'Unknown';
391
+ const getId = (n) => n.id || n.ID || n.Id || '-';
392
+ const onlineCount = nodes.filter(n => getStatus(n) === 'connected').length;
343
393
  const totalCount = nodes.length;
344
394
  const listItems = nodes.map(n => {
345
- const isOnline = n.status === 'connected';
346
- const isConnecting = n.status === 'connecting';
395
+ const status = getStatus(n);
396
+ const isOnline = status === 'connected' || status === 'running';
397
+ const isConnecting = status === 'connecting';
347
398
  const icon = isOnline ? '🟢' : (isConnecting ? '🟡' : '🔴');
348
- const tags = n.tags.map((t) => `<span class="tag">@${t}</span>`).join(' ');
399
+ const tags = (n.tags || []).map((t) => `<span class="tag">@${t}</span>`).join(' ');
349
400
  return `
350
401
  <div class="list-item">
351
402
  <div class="status-icon">${icon}</div>
352
403
  <div class="name-col">
353
- <div>${n.name}</div>
354
- <div style="font-size:12px; opacity:0.6; margin-top:2px;">${n.id}</div>
404
+ <div>${getName(n)}</div>
405
+ <div style="font-size:12px; opacity:0.6; margin-top:2px;">${getId(n)}</div>
355
406
  </div>
356
407
  <div class="meta-col">
357
- <div style="color: ${isOnline ? '#4ade80' : (isConnecting ? '#facc15' : '#f87171')}">${n.status}</div>
408
+ <div style="color: ${isOnline ? '#4ade80' : (isConnecting ? '#facc15' : '#f87171')}">${status}</div>
358
409
  </div>
359
410
  <div>${tags}</div>
360
411
  </div>
@@ -371,18 +422,37 @@ function generateNodesHtml(nodes) {
371
422
  /**
372
423
  * 生成节点详情 HTML
373
424
  */
374
- function generateNodeDetailHtml(node, version) {
375
- const isOnline = node.status === 'connected';
425
+ function generateNodeDetailHtml(node, version, systemInfo) {
426
+ // 兼容字段名称 (处理大小写不一致的问题)
427
+ // 优先从 config 获取名称,因为 node 对象可能是 DockerNode 实例
428
+ const nodeName = node.config?.name || node.name || node.Name || 'Unknown';
429
+ const nodeId = node.id || node.ID || node.Id || node.config?.id || '-';
430
+ const nodeStatus = node.status || node.Status || 'unknown';
431
+ const nodeTags = node.tags || node.config?.tags || [];
432
+ const isOnline = nodeStatus === 'connected' || nodeStatus === 'running';
433
+ // 解析系统信息 (兼容不同字段格式)
434
+ const cpuCores = systemInfo?.NCPU || systemInfo?.Ncpu || systemInfo?.ncpu || '-';
435
+ const memoryTotal = systemInfo?.MemTotal ? formatBytes(systemInfo.MemTotal) : '-';
436
+ // 如果没有 MemAvailable,则只显示总内存
437
+ const memoryDisplay = systemInfo?.MemAvailable !== undefined
438
+ ? `${formatBytes(systemInfo.MemAvailable)} / ${memoryTotal}`
439
+ : memoryTotal !== '-' ? memoryTotal : '-';
376
440
  // 基础信息
377
441
  const items = [
378
- { label: '节点名称', value: node.name },
379
- { label: '节点 ID', value: node.id },
380
- { label: '状态', value: node.status, highlight: isOnline },
381
- { label: '标签', value: node.tags.join(', ') || '(无)' },
442
+ { label: '节点名称', value: nodeName },
443
+ { label: '节点 ID', value: nodeId },
444
+ { label: '状态', value: nodeStatus, highlight: isOnline },
445
+ { label: '标签', value: (nodeTags || []).join(', ') || '(无)' },
382
446
  ];
447
+ // 系统资源信息
448
+ items.push({ label: 'CPU', value: `${cpuCores} 核心` }, { label: '内存', value: memoryDisplay }, { label: '容器数量', value: String(node.containerCount ?? node.Containers ?? node.containers ?? '-') }, { label: '镜像数量', value: String(node.imageCount ?? node.Images ?? node.images ?? '-') });
449
+ // 集群信息
450
+ if (node.cluster || node.Swarm?.NodeID) {
451
+ items.push({ label: '集群', value: node.cluster || 'Swarm Mode' });
452
+ }
383
453
  // 版本信息
384
454
  if (version) {
385
- items.push({ label: 'Docker 版本', value: version.Version }, { label: 'API 版本', value: version.ApiVersion }, { label: '操作系统', value: `${version.Os} (${version.Arch})` }, { label: '内核版本', value: version.KernelVersion }, { label: 'Go 版本', value: version.GoVersion }, { label: 'Git Commit', value: version.GitCommit }, { label: '构建时间', value: version.BuildTime });
455
+ items.push({ label: 'Docker 版本', value: version.Version || version.version || '-' }, { label: 'API 版本', value: version.ApiVersion || version.ApiVersion || '-' }, { label: '操作系统', value: `${version.Os || version.Os || 'unknown'} (${version.Arch || version.Arch || 'unknown'})` }, { label: '内核版本', value: version.KernelVersion || version.KernelVersion || '-' });
386
456
  }
387
457
  const gridItems = items.map(item => `
388
458
  <div class="detail-item">
@@ -393,7 +463,7 @@ function generateNodeDetailHtml(node, version) {
393
463
  const header = `
394
464
  <div class="header">
395
465
  <div class="header-title">节点详情</div>
396
- <div class="header-badge">${node.name}</div>
466
+ <div class="header-badge">${nodeName}</div>
397
467
  </div>
398
468
  `;
399
469
  const body = `
@@ -402,8 +472,8 @@ function generateNodeDetailHtml(node, version) {
402
472
  <div style="display: flex; align-items: center; margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid rgba(255,255,255,0.1);">
403
473
  <div style="font-size: 32px; margin-right: 16px;">${isOnline ? '🟢' : '🔴'}</div>
404
474
  <div>
405
- <div style="font-size: 20px; font-weight: 600;">${node.name}</div>
406
- <div style="font-size: 13px; color: #94a3b8; font-family: monospace;">${node.id}</div>
475
+ <div style="font-size: 20px; font-weight: 600;">${nodeName}</div>
476
+ <div style="font-size: 13px; color: #94a3b8; font-family: monospace;">${nodeId}</div>
407
477
  </div>
408
478
  </div>
409
479
  <div class="detail-grid">
@@ -414,3 +484,277 @@ function generateNodeDetailHtml(node, version) {
414
484
  `;
415
485
  return wrapHtml(header + body);
416
486
  }
487
+ /**
488
+ * 生成日志 HTML
489
+ */
490
+ function generateLogsHtml(nodeName, containerName, logs, lineCount) {
491
+ // 限制日志行数,避免过长
492
+ const maxLines = 150;
493
+ const allLines = logs.split('\n');
494
+ const totalLines = allLines.length;
495
+ const displayLines = allLines.slice(-maxLines);
496
+ const displayLogs = displayLines.join('\n');
497
+ const displayLineCount = displayLines.length;
498
+ // 逐行渲染,带行号和高亮
499
+ const logLines = displayLines.map((line, idx) => {
500
+ const lineNum = totalLines - displayLineCount + idx + 1;
501
+ return `<span class="line-num">${lineNum.toString().padStart(5, ' ')}</span><span class="line-content">${highlightLogContent(line)}</span>`;
502
+ }).join('\n');
503
+ const header = `
504
+ <div class="header">
505
+ <div class="header-title">📋 容器日志</div>
506
+ <div class="header-badge">${nodeName}/${containerName}</div>
507
+ </div>
508
+ `;
509
+ const body = `
510
+ <div class="content">
511
+ <div style="margin-bottom: 12px; font-size: 13px; color: #94a3b8; display: flex; justify-content: space-between;">
512
+ <span>显示第 ${totalLines - displayLineCount + 1} - ${totalLines} 行</span>
513
+ <span>共 ${totalLines} 行</span>
514
+ </div>
515
+ <div class="log-container">
516
+ <div class="log-lines">${logLines}</div>
517
+ </div>
518
+ </div>
519
+ `;
520
+ // 添加日志专用样式
521
+ const logStyle = `
522
+ .log-container {
523
+ background: rgba(0, 0, 0, 0.3);
524
+ border-radius: 8px;
525
+ padding: 16px;
526
+ overflow: visible;
527
+ }
528
+ .log-lines {
529
+ font-family: 'SF Mono', Monaco, 'Courier New', monospace;
530
+ font-size: 12px;
531
+ line-height: 1.6;
532
+ white-space: pre-wrap;
533
+ word-break: break-all;
534
+ color: #e2e8f0;
535
+ }
536
+ .line-num {
537
+ color: #475569;
538
+ margin-right: 12px;
539
+ user-select: none;
540
+ display: inline-block;
541
+ min-width: 35px;
542
+ text-align: right;
543
+ border-right: 1px solid #334155;
544
+ padding-right: 8px;
545
+ }
546
+ .line-content {
547
+ color: #e2e8f0;
548
+ }
549
+
550
+ /* 高亮样式 */
551
+ .hl-date { color: #64748b; }
552
+ .hl-ip { color: #22d3ee; }
553
+ .hl-string { color: #a5f3fc; opacity: 0.9; }
554
+ .hl-error { color: #ef4444; font-weight: bold; background: rgba(239, 68, 68, 0.1); padding: 0 4px; border-radius: 2px; }
555
+ .hl-warn { color: #f59e0b; font-weight: bold; }
556
+ .hl-info { color: #3b82f6; font-weight: bold; }
557
+ .hl-debug { color: #94a3b8; }
558
+ `;
559
+ return wrapHtml(header + body, STYLE + logStyle);
560
+ }
561
+ /**
562
+ * 格式化字节为可读格式
563
+ */
564
+ function formatBytes(bytes) {
565
+ if (!bytes || bytes < 0)
566
+ return '-';
567
+ const k = 1024;
568
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
569
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
570
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
571
+ }
572
+ /**
573
+ * HTML 转义
574
+ */
575
+ function escapeHtml(text) {
576
+ return text
577
+ .replace(/&/g, '&amp;')
578
+ .replace(/</g, '&lt;')
579
+ .replace(/>/g, '&gt;')
580
+ .replace(/"/g, '&quot;')
581
+ .replace(/'/g, '&#039;');
582
+ }
583
+ /**
584
+ * 处理日志高亮
585
+ */
586
+ function highlightLogContent(text) {
587
+ // 1. 先进行基础的 HTML 转义
588
+ let html = escapeHtml(text);
589
+ // 2. 定义高亮规则 (注意顺序:先匹配复杂的,再匹配简单的)
590
+ // [时间戳] YYYY-MM-DD HH:mm:ss 或 ISO8601
591
+ html = html.replace(/(\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/g, '\x1f$1\x1f');
592
+ // [IP地址] 简单的 IPv4 匹配
593
+ html = html.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '\x1f$&\x1f');
594
+ // [日志等级 - Error/Fail] 红色
595
+ html = html.replace(/(\b(ERROR|ERR|FATAL|CRITICAL|FAIL|FAILED|EXCEPTION)\b|\[(ERROR|ERR)\])/gi, '\x1f$1\x1f');
596
+ // [日志等级 - Warn] 黄色
597
+ html = html.replace(/(\b(WARN|WARNING)\b|\[(WARN|WARNING)\])/gi, '\x1f$1\x1f');
598
+ // [日志等级 - Info] 蓝色
599
+ html = html.replace(/(\b(INFO|INFORMATION)\b|\[(INFO)\])/gi, '\x1f$1\x1f');
600
+ // [日志等级 - Debug/Trace] 灰色
601
+ html = html.replace(/(\b(DEBUG|TRACE)\b|\[(DEBUG|TRACE)\])/gi, '\x1f$1\x1f');
602
+ // [引用/字符串] "xxx" 或 'xxx'
603
+ html = html.replace(/(".*?"|'.*?')/g, '\x1f$1\x1f');
604
+ // 3. 将占位符替换回 HTML 标签
605
+ html = html
606
+ .replace(/\x1f(\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)\x1f/g, '<span class="hl-date">$1</span>')
607
+ .replace(/\x1f((?:\d{1,3}\.){3}\d{1,3})\x1f/g, '<span class="hl-ip">$1</span>')
608
+ .replace(/\x1f((?:\[[^\]]*\]|\w+))\x1f/g, (match, p1) => {
609
+ const lower = p1.toLowerCase();
610
+ if (lower.includes('error') || lower.includes('fatal') || lower.includes('fail') || lower.includes('exception')) {
611
+ return `<span class="hl-error">${p1}</span>`;
612
+ }
613
+ if (lower.includes('warn')) {
614
+ return `<span class="hl-warn">${p1}</span>`;
615
+ }
616
+ if (lower.includes('info')) {
617
+ return `<span class="hl-info">${p1}</span>`;
618
+ }
619
+ if (lower.includes('debug') || lower.includes('trace')) {
620
+ return `<span class="hl-debug">${p1}</span>`;
621
+ }
622
+ if (p1.startsWith('"') || p1.startsWith("'")) {
623
+ return `<span class="hl-string">${p1}</span>`;
624
+ }
625
+ return p1;
626
+ });
627
+ return html;
628
+ }
629
+ /**
630
+ * 生成执行结果 HTML
631
+ */
632
+ function generateExecHtml(nodeName, containerName, command, output, exitCode) {
633
+ const isSuccess = exitCode === 0;
634
+ const statusIcon = isSuccess ? '✅' : '❌';
635
+ const header = `
636
+ <div class="header">
637
+ <div class="header-title">🔧 命令执行</div>
638
+ <div class="header-badge">${nodeName}/${containerName}</div>
639
+ </div>
640
+ `;
641
+ const body = `
642
+ <div class="content">
643
+ <div style="
644
+ background: rgba(0, 0, 0, 0.2);
645
+ border-radius: 8px;
646
+ padding: 16px;
647
+ margin-bottom: 16px;
648
+ ">
649
+ <div style="font-size: 13px; color: #94a3b8; margin-bottom: 8px;">执行命令</div>
650
+ <div style="
651
+ font-family: 'SF Mono', Monaco, monospace;
652
+ font-size: 13px;
653
+ color: #60a5fa;
654
+ background: rgba(96, 165, 250, 0.1);
655
+ padding: 8px 12px;
656
+ border-radius: 4px;
657
+ ">${command}</div>
658
+ </div>
659
+
660
+ <div style="
661
+ background: rgba(0, 0, 0, 0.3);
662
+ border-radius: 8px;
663
+ padding: 16px;
664
+ font-family: 'SF Mono', Monaco, 'Courier New', monospace;
665
+ font-size: 12px;
666
+ line-height: 1.6;
667
+ max-height: 300px;
668
+ overflow-y: auto;
669
+ white-space: pre-wrap;
670
+ word-break: break-all;
671
+ color: #e2e8f0;
672
+ ">${output || '(无输出)'}</div>
673
+
674
+ <div style="margin-top: 16px; display: flex; align-items: center; gap: 8px;">
675
+ <span style="font-size: 20px;">${statusIcon}</span>
676
+ <span style="color: ${isSuccess ? '#4ade80' : '#f87171'}">
677
+ 退出码: ${exitCode}
678
+ </span>
679
+ </div>
680
+ </div>
681
+ `;
682
+ return wrapHtml(header + body);
683
+ }
684
+ /**
685
+ * 生成 Docker Compose 配置 HTML
686
+ */
687
+ function generateComposeHtml(nodeName, containerName, projectName, filePath, serviceCount, composeContent) {
688
+ // 对内容进行语法高亮
689
+ const highlightedContent = highlightYaml(composeContent);
690
+ const header = `
691
+ <div class="header">
692
+ <div class="header-title">Docker Compose</div>
693
+ <div class="header-badge">${nodeName}/${containerName}</div>
694
+ </div>
695
+ `;
696
+ const body = `
697
+ <div class="content">
698
+ <div class="detail-card" style="margin-bottom: 20px;">
699
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px;">
700
+ <div class="detail-item">
701
+ <div class="detail-label">项目名称</div>
702
+ <div class="detail-value highlight">${projectName}</div>
703
+ </div>
704
+ <div class="detail-item">
705
+ <div class="detail-label">服务数量</div>
706
+ <div class="detail-value">${serviceCount} 个</div>
707
+ </div>
708
+ <div class="detail-item" style="grid-column: 1 / -1;">
709
+ <div class="detail-label">文件路径</div>
710
+ <div class="detail-value" style="font-size: 13px;">${filePath}</div>
711
+ </div>
712
+ </div>
713
+ </div>
714
+
715
+ <div style="
716
+ background: rgba(0, 0, 0, 0.3);
717
+ border-radius: 8px;
718
+ padding: 16px;
719
+ font-family: 'SF Mono', Monaco, 'Courier New', monospace;
720
+ font-size: 12px;
721
+ line-height: 1.6;
722
+ white-space: pre-wrap;
723
+ word-break: break-all;
724
+ ">${highlightedContent}</div>
725
+ </div>
726
+ `;
727
+ // 添加 YAML 高亮样式
728
+ const yamlStyle = `
729
+ .yaml-key { color: #60a5fa; }
730
+ .yaml-string { color: #a5f3fc; }
731
+ .yaml-number { color: #f472b6; }
732
+ .yaml-boolean { color: #fbbf24; }
733
+ .yaml-null { color: #94a3b8; }
734
+ .yaml-comment { color: #64748b; font-style: italic; }
735
+ .yaml-bracket { color: #f87171; }
736
+ `;
737
+ return wrapHtml(header + body, STYLE + yamlStyle);
738
+ }
739
+ /**
740
+ * 简单的 YAML 语法高亮
741
+ */
742
+ function highlightYaml(content) {
743
+ // HTML 转义
744
+ let html = escapeHtml(content);
745
+ // 高亮键名 (冒号前的单词)
746
+ html = html.replace(/^([a-zA-Z0-9_-]+):(\s*)$/gm, '<span class="yaml-key">$1</span>:<br>');
747
+ // 高亮带引号的字符串
748
+ html = html.replace(/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g, '<span class="yaml-string">$1</span>');
749
+ // 高亮数字
750
+ html = html.replace(/\b(\d+\.?\d*)\b/g, '<span class="yaml-number">$1</span>');
751
+ // 高亮布尔值
752
+ html = html.replace(/\b(true|false|yes|no|on|off)\b/gi, '<span class="yaml-boolean">$1</span>');
753
+ // 高亮 null
754
+ html = html.replace(/\bnull\b/gi, '<span class="yaml-null">null</span>');
755
+ // 高亮注释
756
+ html = html.replace(/#.*$/gm, '<span class="yaml-comment">$&</span>');
757
+ // 高亮括号
758
+ html = html.replace(/([\[\]{}()])/g, '<span class="yaml-bracket">$1</span>');
759
+ return html;
760
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-docker-control",
3
3
  "description": "Koishi 插件 - 通过 SSH 控制 Docker 容器",
4
- "version": "0.0.3",
4
+ "version": "0.0.5",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [