skill-base 2.0.4 → 2.0.7

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.
Files changed (46) hide show
  1. package/README.md +177 -115
  2. package/bin/skill-base.js +29 -3
  3. package/package.json +4 -1
  4. package/src/cappy.js +416 -0
  5. package/src/database.js +11 -0
  6. package/src/index.js +125 -25
  7. package/src/middleware/auth.js +96 -32
  8. package/src/routes/auth.js +1 -1
  9. package/src/routes/skills.js +10 -5
  10. package/src/utils/zip.js +15 -4
  11. package/static/android-chrome-192x192.png +0 -0
  12. package/static/android-chrome-512x512.png +0 -0
  13. package/static/apple-touch-icon.png +0 -0
  14. package/static/assets/index-BkwByEEp.css +1 -0
  15. package/static/assets/index-CB4Diul3.js +209 -0
  16. package/static/favicon-16x16.png +0 -0
  17. package/static/favicon-32x32.png +0 -0
  18. package/static/favicon.ico +0 -0
  19. package/static/favicon.svg +14 -0
  20. package/static/index.html +18 -248
  21. package/static/site.webmanifest +1 -0
  22. package/static/admin/users.html +0 -593
  23. package/static/cli-code.html +0 -203
  24. package/static/css/.gitkeep +0 -0
  25. package/static/css/style.css +0 -1567
  26. package/static/diff.html +0 -466
  27. package/static/file.html +0 -443
  28. package/static/js/.gitkeep +0 -0
  29. package/static/js/admin/users.js +0 -346
  30. package/static/js/app.js +0 -508
  31. package/static/js/auth.js +0 -151
  32. package/static/js/cli-code.js +0 -184
  33. package/static/js/collaborators.js +0 -283
  34. package/static/js/diff.js +0 -540
  35. package/static/js/file.js +0 -619
  36. package/static/js/i18n.js +0 -739
  37. package/static/js/index.js +0 -168
  38. package/static/js/publish.js +0 -718
  39. package/static/js/settings.js +0 -124
  40. package/static/js/setup.js +0 -157
  41. package/static/js/skill.js +0 -808
  42. package/static/login.html +0 -82
  43. package/static/publish.html +0 -459
  44. package/static/settings.html +0 -163
  45. package/static/setup.html +0 -101
  46. package/static/skill.html +0 -851
@@ -1,808 +0,0 @@
1
- /**
2
- * Skill Base - 详情页逻辑
3
- */
4
-
5
- // 全局状态
6
- let currentSkill = null;
7
- let currentVersion = null;
8
- let currentZip = null; // 缓存已下载的 JSZip 实例
9
- let versions = []; // 版本列表
10
-
11
- /** 当前打开的 Markdown 预览状态(用于 HTML / 源码切换) */
12
- let markdownPreviewState = null;
13
-
14
- // 文本文件扩展名列表(参考设计文档 5.3 节)
15
- const TEXT_EXTS = new Set([
16
- '.md', '.py', '.sh', '.bash', '.zsh',
17
- '.js', '.jsx', '.ts', '.tsx', '.vue',
18
- '.json', '.yaml', '.yml', '.toml', '.ini', '.cfg',
19
- '.txt', '.text', '.log',
20
- '.html', '.htm', '.css', '.scss', '.sass', '.less',
21
- '.xml', '.sql', '.go', '.rs', '.java', '.c', '.cpp',
22
- '.h', '.hpp', '.cs', '.rb', '.php', '.swift', '.kt',
23
- '.dockerfile', '.gitignore', '.env.example',
24
- ]);
25
-
26
- // 无扩展名但是文本文件
27
- const TEXT_FILENAMES = new Set([
28
- 'dockerfile', 'makefile', 'rakefile', 'readme', 'license', 'changelog',
29
- '.gitignore', '.gitattributes', '.editorconfig', '.env', '.env.example',
30
- ]);
31
-
32
- // 页面加载时初始化
33
- document.addEventListener('DOMContentLoaded', async () => {
34
- initMarkdownPreviewToggle();
35
-
36
- // 1. 从 URL 参数获取 skill_id
37
- const params = new URLSearchParams(window.location.search);
38
- const skillId = params.get('id');
39
-
40
- if (!skillId) {
41
- showToast(t('skill.missingId'), 'error');
42
- setTimeout(() => window.location.href = '/', 1500);
43
- return;
44
- }
45
-
46
- // 2. 检查登录状态
47
- const user = await checkAuth();
48
- if (!user) return;
49
-
50
- // 3. 渲染导航栏
51
- renderNavbar(user);
52
-
53
- // 4. 加载 Skill 详情
54
- await loadSkillDetail(skillId);
55
-
56
- // 5. 初始化协作者面板
57
- await initCollaboratorsPanel();
58
-
59
- // 6. 加载版本列表
60
- await loadVersions(skillId);
61
-
62
- // 7. 默认加载最新版本的 zip 并生成目录树
63
- if (currentSkill && currentSkill.latest_version) {
64
- await switchVersion(currentSkill.latest_version);
65
- }
66
- });
67
-
68
- function initMarkdownPreviewToggle() {
69
- const renderBtn = document.getElementById('md-view-render');
70
- const sourceBtn = document.getElementById('md-view-source');
71
- if (!renderBtn || !sourceBtn) return;
72
- renderBtn.addEventListener('click', () => setMarkdownPreviewMode('render'));
73
- sourceBtn.addEventListener('click', () => setMarkdownPreviewMode('source'));
74
- }
75
-
76
- function hideMarkdownPreviewActions() {
77
- markdownPreviewState = null;
78
- const wrap = document.getElementById('file-preview-md-actions');
79
- if (wrap) wrap.hidden = true;
80
- }
81
-
82
- function showMarkdownPreviewActions(content) {
83
- markdownPreviewState = { content, mode: 'render' };
84
- const wrap = document.getElementById('file-preview-md-actions');
85
- const renderBtn = document.getElementById('md-view-render');
86
- const sourceBtn = document.getElementById('md-view-source');
87
- if (wrap) wrap.hidden = false;
88
- renderBtn?.classList.add('is-active');
89
- sourceBtn?.classList.remove('is-active');
90
- }
91
-
92
- /**
93
- * 将 Markdown 渲染为 HTML,并做表格包裹(避免撑破布局)
94
- * @param {string} content
95
- * @returns {HTMLElement}
96
- */
97
- function buildMarkdownBodyElement(content) {
98
- const html = marked.parse(content);
99
- const wrapper = document.createElement('div');
100
- wrapper.className = 'markdown-body';
101
- wrapper.innerHTML = html;
102
- wrapper.querySelectorAll('table').forEach((table) => {
103
- if (table.closest('.md-table-wrap')) return;
104
- const outer = document.createElement('div');
105
- outer.className = 'md-table-wrap';
106
- table.parentNode.insertBefore(outer, table);
107
- outer.appendChild(table);
108
- });
109
- const hl = typeof hljs !== 'undefined' ? hljs : null;
110
- wrapper.querySelectorAll('pre code').forEach((block) => {
111
- if (hl && typeof hl.highlightElement === 'function') {
112
- hl.highlightElement(block);
113
- }
114
- });
115
- return wrapper;
116
- }
117
-
118
- /**
119
- * @param {'render'|'source'} mode
120
- */
121
- function setMarkdownPreviewMode(mode) {
122
- if (!markdownPreviewState) return;
123
- markdownPreviewState.mode = mode;
124
- const container = document.getElementById('file-content');
125
- if (!container) return;
126
- const renderBtn = document.getElementById('md-view-render');
127
- const sourceBtn = document.getElementById('md-view-source');
128
- renderBtn?.classList.toggle('is-active', mode === 'render');
129
- sourceBtn?.classList.toggle('is-active', mode === 'source');
130
- renderMarkdownPreview(container, markdownPreviewState.content, mode);
131
- }
132
-
133
- /**
134
- * @param {HTMLElement} container
135
- * @param {string} content
136
- * @param {'render'|'source'} mode
137
- */
138
- function renderMarkdownPreview(container, content, mode) {
139
- if (mode === 'source') {
140
- container.innerHTML = `<pre class="md-source-pre"><code>${escapeHtml(content)}</code></pre>`;
141
- return;
142
- }
143
- container.innerHTML = '';
144
- container.appendChild(buildMarkdownBodyElement(content));
145
- }
146
-
147
- /**
148
- * 加载 Skill 详情
149
- * @param {string} skillId - Skill ID
150
- */
151
- async function loadSkillDetail(skillId) {
152
- try {
153
- const skill = await apiGet(`/skills/${skillId}`);
154
- currentSkill = skill;
155
-
156
- // 更新页面标题
157
- document.title = `${skill.name} - Skill Base`;
158
- document.getElementById('breadcrumb-name').textContent = skill.name;
159
-
160
- // 渲染基本信息
161
- renderSkillInfo(skill);
162
-
163
- } catch (error) {
164
- console.error('Failed to load Skill detail:', error);
165
- showToast(t('skill.loadFailed') + ': ' + error.message, 'error');
166
- document.getElementById('skill-info').innerHTML = `
167
- <div class="empty-preview">
168
- <div class="empty-preview-icon">❌</div>
169
- <p>${t('skill.loadFailed')}</p>
170
- <a href="/" class="btn btn-primary mt-2">${t('nav.home')}</a>
171
- </div>
172
- `;
173
- }
174
- }
175
-
176
- /**
177
- * 渲染 Skill 基本信息
178
- * @param {object} skill - Skill 数据
179
- */
180
- function renderSkillInfo(skill) {
181
- const container = document.getElementById('skill-info');
182
- const ownerName = skill.owner?.name || skill.owner?.username || t('state.unknown');
183
- const createdTime = formatDate(skill.created_at);
184
-
185
- container.innerHTML = `
186
- <div class="skill-header">
187
- <div>
188
- <h1 class="skill-title">${escapeHtml(skill.name)}</h1>
189
- <p class="skill-desc">${escapeHtml(skill.description || t('state.noDesc'))}</p>
190
- </div>
191
- </div>
192
- <div class="skill-meta">
193
- <div class="skill-meta-item">
194
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
195
- <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
196
- <circle cx="12" cy="7" r="4"/>
197
- </svg>
198
- <span>${t('skill.owner')}${escapeHtml(ownerName)}</span>
199
- </div>
200
- <div class="skill-meta-item">
201
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
202
- <rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
203
- <line x1="16" y1="2" x2="16" y2="6"/>
204
- <line x1="8" y1="2" x2="8" y2="6"/>
205
- <line x1="3" y1="10" x2="21" y2="10"/>
206
- </svg>
207
- <span>${t('skill.createdAt')}${createdTime}</span>
208
- </div>
209
- </div>
210
- <div class="skill-actions">
211
- <select id="version-select" class="version-select" onchange="onVersionChange(this.value)">
212
- <option value="">${t('skill.selectVersion')}</option>
213
- </select>
214
- <button class="btn btn-primary" onclick="downloadCurrentVersion()">
215
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
216
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
217
- <polyline points="7 10 12 15 17 10"/>
218
- <line x1="12" y1="15" x2="12" y2="3"/>
219
- </svg>
220
- ${t('skill.download')}
221
- </button>
222
- <button class="btn btn-secondary" onclick="goToDiff()">
223
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
224
- <line x1="18" y1="20" x2="18" y2="10"/>
225
- <line x1="12" y1="20" x2="12" y2="4"/>
226
- <line x1="6" y1="20" x2="6" y2="14"/>
227
- </svg>
228
- ${t('skill.compare')}
229
- </button>
230
- </div>
231
- `;
232
- }
233
-
234
- /**
235
- * 加载版本列表
236
- * @param {string} skillId - Skill ID
237
- */
238
- async function loadVersions(skillId) {
239
- try {
240
- const data = await apiGet(`/skills/${skillId}/versions`);
241
- versions = data.versions || [];
242
-
243
- // 渲染版本下拉框
244
- renderVersionSelect(versions);
245
-
246
- // 渲染版本历史列表
247
- renderVersionHistory(versions);
248
-
249
- } catch (error) {
250
- console.error('Failed to load version list:', error);
251
- showToast(t('skill.loadVersionsFailed'), 'error');
252
- }
253
- }
254
-
255
- /**
256
- * 渲染版本下拉框
257
- * @param {array} versions - 版本列表
258
- */
259
- function renderVersionSelect(versions) {
260
- const select = document.getElementById('version-select');
261
- if (!select) return;
262
-
263
- select.innerHTML = versions.map((v, index) => {
264
- const label = index === 0 ? `${v.version} ${t('skill.latestTag')}` : v.version;
265
- return `<option value="${escapeHtml(v.version)}">${escapeHtml(label)}</option>`;
266
- }).join('');
267
-
268
- // 默认选中第一个(最新版本)
269
- if (versions.length > 0) {
270
- select.value = versions[0].version;
271
- }
272
- }
273
-
274
- /**
275
- * 渲染版本历史列表
276
- * @param {array} versions - 版本列表
277
- */
278
- function renderVersionHistory(versions) {
279
- const container = document.getElementById('version-list');
280
-
281
- if (versions.length === 0) {
282
- container.innerHTML = `
283
- <div class="empty-preview" style="padding: var(--spacing-lg);">
284
- <p class="text-muted">${t('skill.noVersions')}</p>
285
- </div>
286
- `;
287
- return;
288
- }
289
-
290
- container.innerHTML = versions.map((v, index) => {
291
- const uploaderName = v.uploader?.name || v.uploader?.username || t('state.unknown');
292
- const createdTime = formatDate(v.created_at);
293
- const tagClass = index === 0 ? 'version-tag latest' : 'version-tag';
294
-
295
- return `
296
- <div class="version-item">
297
- <span class="${tagClass}">${escapeHtml(v.version)}</span>
298
- <div class="version-item-info">
299
- <div class="version-item-changelog">${escapeHtml(v.changelog || t('skill.noChangelog'))}</div>
300
- <div class="version-item-meta">
301
- <span>${t('skill.uploadedBy')}${escapeHtml(uploaderName)}</span>
302
- <span>${createdTime}</span>
303
- </div>
304
- </div>
305
- </div>
306
- `;
307
- }).join('');
308
- }
309
-
310
- /**
311
- * 版本选择变更事件
312
- * @param {string} version - 选中的版本
313
- */
314
- async function onVersionChange(version) {
315
- if (version && version !== currentVersion) {
316
- await switchVersion(version);
317
- }
318
- }
319
-
320
- /**
321
- * 切换版本
322
- * @param {string} version - 版本号
323
- */
324
- async function switchVersion(version) {
325
- if (!currentSkill) return;
326
-
327
- const fileTreeContainer = document.getElementById('file-tree');
328
- const fileContentContainer = document.getElementById('file-content');
329
-
330
- // 显示 Loading
331
- fileTreeContainer.innerHTML = `
332
- <div class="loading-content">
333
- <div class="spinner spinner-sm"></div>
334
- </div>
335
- `;
336
-
337
- try {
338
- // 下载 zip
339
- const downloadUrl = `/api/v1/skills/${currentSkill.id}/versions/${version}/download`;
340
- const response = await fetch(downloadUrl, { credentials: 'same-origin' });
341
-
342
- if (!response.ok) {
343
- throw new Error(t('skill.zipFailed'));
344
- }
345
-
346
- const zipData = await response.arrayBuffer();
347
-
348
- // 使用 JSZip 解压
349
- const zip = await JSZip.loadAsync(zipData);
350
- currentZip = zip;
351
- currentVersion = version;
352
-
353
- // 更新版本下拉框选中状态
354
- const select = document.getElementById('version-select');
355
- if (select) {
356
- select.value = version;
357
- }
358
-
359
- // 生成目录树
360
- const fileTree = generateFileTree(zip);
361
- renderFileTree(fileTree, fileTreeContainer);
362
-
363
- // 重置文件预览区域
364
- hideMarkdownPreviewActions();
365
- document.getElementById('file-preview-path').textContent = t('skill.selectFile');
366
- fileContentContainer.innerHTML = `
367
- <div class="empty-preview">
368
- <div class="empty-preview-icon">📄</div>
369
- <p>${t('skill.clickFile')}</p>
370
- </div>
371
- `;
372
-
373
- } catch (error) {
374
- console.error('Failed to switch version:', error);
375
- showToast(t('skill.versionFailed') + error.message, 'error');
376
- fileTreeContainer.innerHTML = `
377
- <div class="empty-preview">
378
- <p class="text-error">${t('file.loadFailed')}</p>
379
- </div>
380
- `;
381
- }
382
- }
383
-
384
- /**
385
- * 生成目录树(从 JSZip 实例)
386
- * @param {JSZip} zip - JSZip 实例
387
- * @returns {array} - 目录树结构
388
- */
389
- function generateFileTree(zip) {
390
- const root = { type: 'directory', name: '', children: [] };
391
-
392
- zip.forEach((relativePath, zipEntry) => {
393
- // 跳过空路径
394
- if (!relativePath) return;
395
-
396
- const parts = relativePath.split('/').filter(Boolean);
397
- let current = root;
398
-
399
- for (let i = 0; i < parts.length; i++) {
400
- const part = parts[i];
401
- const isLast = i === parts.length - 1;
402
- const isDir = zipEntry.dir || !isLast;
403
-
404
- let child = current.children.find(c => c.name === part);
405
- if (!child) {
406
- child = {
407
- type: isDir ? 'directory' : 'file',
408
- name: part,
409
- path: relativePath,
410
- ...(isDir ? { children: [] } : {})
411
- };
412
- current.children.push(child);
413
- }
414
- if (isDir) current = child;
415
- }
416
- });
417
-
418
- // 排序:目录在前,文件在后,按名称排序
419
- sortTree(root.children);
420
-
421
- return root.children;
422
- }
423
-
424
- /**
425
- * 对目录树排序
426
- * @param {array} nodes - 节点数组
427
- */
428
- function sortTree(nodes) {
429
- nodes.sort((a, b) => {
430
- if (a.type !== b.type) {
431
- return a.type === 'directory' ? -1 : 1;
432
- }
433
- return a.name.localeCompare(b.name);
434
- });
435
-
436
- nodes.forEach(node => {
437
- if (node.children) {
438
- sortTree(node.children);
439
- }
440
- });
441
- }
442
-
443
- /**
444
- * 渲染目录树 DOM
445
- * @param {array} nodes - 目录树节点数组
446
- * @param {HTMLElement} container - 容器元素
447
- * @param {number} level - 层级深度
448
- */
449
- function renderFileTree(nodes, container, level = 0) {
450
- if (!nodes || nodes.length === 0) {
451
- container.innerHTML = `
452
- <div class="empty-preview">
453
- <p class="text-muted">${t('state.empty')}</p>
454
- </div>
455
- `;
456
- return;
457
- }
458
-
459
- const ul = document.createElement('ul');
460
- if (level === 0) {
461
- ul.style.paddingLeft = '0';
462
- }
463
-
464
- nodes.forEach(node => {
465
- const li = document.createElement('li');
466
-
467
- if (node.type === 'directory') {
468
- li.className = 'file-tree-folder open';
469
- li.innerHTML = `
470
- <div class="file-tree-item">
471
- <span class="icon">📁</span>
472
- <span class="name">${escapeHtml(node.name)}</span>
473
- </div>
474
- `;
475
-
476
- const childContainer = document.createElement('ul');
477
- renderFileTreeChildren(node.children, childContainer);
478
- li.appendChild(childContainer);
479
-
480
- // 绑定折叠/展开事件
481
- const itemDiv = li.querySelector('.file-tree-item');
482
- itemDiv.addEventListener('click', (e) => {
483
- e.stopPropagation();
484
- li.classList.toggle('open');
485
- const icon = li.querySelector('.icon');
486
- icon.textContent = li.classList.contains('open') ? '📁' : '📂';
487
- });
488
-
489
- } else {
490
- li.className = 'file-tree-file';
491
- li.innerHTML = `
492
- <div class="file-tree-item" data-path="${escapeHtml(node.path)}">
493
- <span class="icon">📄</span>
494
- <span class="name">${escapeHtml(node.name)}</span>
495
- </div>
496
- `;
497
-
498
- // 绑定点击预览事件
499
- const itemDiv = li.querySelector('.file-tree-item');
500
- itemDiv.addEventListener('click', (e) => {
501
- e.stopPropagation();
502
- // 移除其他选中状态
503
- document.querySelectorAll('.file-tree-item.selected').forEach(el => {
504
- el.classList.remove('selected');
505
- });
506
- itemDiv.classList.add('selected');
507
- previewFile(node.path);
508
- });
509
- }
510
-
511
- ul.appendChild(li);
512
- });
513
-
514
- container.innerHTML = '';
515
- container.appendChild(ul);
516
- }
517
-
518
- /**
519
- * 渲染子目录(递归辅助函数)
520
- * @param {array} nodes - 子节点数组
521
- * @param {HTMLElement} container - 容器元素
522
- */
523
- function renderFileTreeChildren(nodes, container) {
524
- if (!nodes || nodes.length === 0) return;
525
-
526
- nodes.forEach(node => {
527
- const li = document.createElement('li');
528
-
529
- if (node.type === 'directory') {
530
- li.className = 'file-tree-folder open';
531
- li.innerHTML = `
532
- <div class="file-tree-item">
533
- <span class="icon">📁</span>
534
- <span class="name">${escapeHtml(node.name)}</span>
535
- </div>
536
- `;
537
-
538
- const childContainer = document.createElement('ul');
539
- renderFileTreeChildren(node.children, childContainer);
540
- li.appendChild(childContainer);
541
-
542
- const itemDiv = li.querySelector('.file-tree-item');
543
- itemDiv.addEventListener('click', (e) => {
544
- e.stopPropagation();
545
- li.classList.toggle('open');
546
- const icon = li.querySelector('.icon');
547
- icon.textContent = li.classList.contains('open') ? '📁' : '📂';
548
- });
549
-
550
- } else {
551
- li.className = 'file-tree-file';
552
- li.innerHTML = `
553
- <div class="file-tree-item" data-path="${escapeHtml(node.path)}">
554
- <span class="icon">📄</span>
555
- <span class="name">${escapeHtml(node.name)}</span>
556
- </div>
557
- `;
558
-
559
- const itemDiv = li.querySelector('.file-tree-item');
560
- itemDiv.addEventListener('click', (e) => {
561
- e.stopPropagation();
562
- document.querySelectorAll('.file-tree-item.selected').forEach(el => {
563
- el.classList.remove('selected');
564
- });
565
- itemDiv.classList.add('selected');
566
- previewFile(node.path);
567
- });
568
- }
569
-
570
- container.appendChild(li);
571
- });
572
- }
573
-
574
- /**
575
- * 预览文件内容
576
- * @param {string} filePath - 文件路径
577
- */
578
- async function previewFile(filePath) {
579
- if (!currentZip) {
580
- showToast(t('skill.selectVersionFirst'), 'warning');
581
- return;
582
- }
583
-
584
- hideMarkdownPreviewActions();
585
-
586
- const container = document.getElementById('file-content');
587
- const pathDisplay = document.getElementById('file-preview-path');
588
-
589
- // 更新路径显示
590
- pathDisplay.textContent = filePath;
591
-
592
- // 显示 Loading
593
- container.innerHTML = `
594
- <div class="loading-content">
595
- <div class="spinner"></div>
596
- </div>
597
- `;
598
-
599
- try {
600
- const file = currentZip.file(filePath);
601
- if (!file) {
602
- container.innerHTML = `
603
- <div class="empty-preview">
604
- <div class="empty-preview-icon">❓</div>
605
- <p>${t('skill.fileNotFound')}</p>
606
- </div>
607
- `;
608
- return;
609
- }
610
-
611
- // 判断是否为文本文件
612
- const isText = isTextFile(filePath);
613
-
614
- if (!isText) {
615
- // 二进制文件
616
- container.innerHTML = `
617
- <div class="binary-notice">
618
- <div class="binary-notice-icon">📦</div>
619
- <p>${t('skill.binaryFile')}</p>
620
- <p class="text-muted mt-1">${t('skill.binaryHint')}</p>
621
- </div>
622
- `;
623
- return;
624
- }
625
-
626
- // 读取文本内容
627
- const content = await file.async('string');
628
- const ext = getFileExtension(filePath).toLowerCase();
629
-
630
- // 根据扩展名决定渲染方式
631
- if (ext === '.md') {
632
- showMarkdownPreviewActions(content);
633
- renderMarkdownPreview(container, content, 'render');
634
- } else if (isCodeFile(ext)) {
635
- // 代码高亮
636
- const language = getLanguageFromExt(ext);
637
- let highlighted;
638
- const hl = typeof hljs !== 'undefined' ? hljs : null;
639
- if (hl && typeof hl.highlight === 'function') {
640
- try {
641
- highlighted = hl.highlight(content, { language }).value;
642
- } catch {
643
- highlighted = escapeHtml(content);
644
- }
645
- } else {
646
- highlighted = escapeHtml(content);
647
- }
648
- container.innerHTML = `<pre><code class="hljs">${highlighted}</code></pre>`;
649
- } else {
650
- // 纯文本
651
- container.innerHTML = `<pre><code>${escapeHtml(content)}</code></pre>`;
652
- }
653
-
654
- } catch (error) {
655
- console.error('Failed to preview file:', error);
656
- container.innerHTML = `
657
- <div class="empty-preview">
658
- <div class="empty-preview-icon">❌</div>
659
- <p>${t('skill.previewFailed')}${escapeHtml(error.message)}</p>
660
- </div>
661
- `;
662
- }
663
- }
664
-
665
- /**
666
- * 判断是否为文本文件
667
- * @param {string} filePath - 文件路径
668
- * @returns {boolean}
669
- */
670
- function isTextFile(filePath) {
671
- const ext = getFileExtension(filePath).toLowerCase();
672
- const fileName = filePath.split('/').pop().toLowerCase();
673
-
674
- // 检查扩展名
675
- if (TEXT_EXTS.has(ext)) {
676
- return true;
677
- }
678
-
679
- // 检查特殊文件名
680
- if (TEXT_FILENAMES.has(fileName)) {
681
- return true;
682
- }
683
-
684
- // 没有扩展名的文件,默认当作文本
685
- if (!ext || ext === '.') {
686
- return true;
687
- }
688
-
689
- return false;
690
- }
691
-
692
- /**
693
- * 获取文件扩展名
694
- * @param {string} filePath - 文件路径
695
- * @returns {string}
696
- */
697
- function getFileExtension(filePath) {
698
- const fileName = filePath.split('/').pop();
699
- const dotIndex = fileName.lastIndexOf('.');
700
- if (dotIndex === -1 || dotIndex === 0) {
701
- return '';
702
- }
703
- return fileName.substring(dotIndex);
704
- }
705
-
706
- /**
707
- * 判断是否为代码文件
708
- * @param {string} ext - 扩展名
709
- * @returns {boolean}
710
- */
711
- function isCodeFile(ext) {
712
- const codeExts = new Set([
713
- '.js', '.jsx', '.ts', '.tsx', '.vue',
714
- '.py', '.sh', '.bash', '.zsh',
715
- '.json', '.yaml', '.yml', '.toml', '.ini', '.cfg',
716
- '.html', '.htm', '.css', '.scss', '.sass', '.less',
717
- '.xml', '.sql', '.go', '.rs', '.java', '.c', '.cpp',
718
- '.h', '.hpp', '.cs', '.rb', '.php', '.swift', '.kt',
719
- ]);
720
- return codeExts.has(ext);
721
- }
722
-
723
- /**
724
- * 根据扩展名获取 highlight.js 语言标识
725
- * @param {string} ext - 扩展名
726
- * @returns {string}
727
- */
728
- function getLanguageFromExt(ext) {
729
- const langMap = {
730
- '.js': 'javascript',
731
- '.jsx': 'javascript',
732
- '.ts': 'typescript',
733
- '.tsx': 'typescript',
734
- '.vue': 'xml',
735
- '.py': 'python',
736
- '.sh': 'bash',
737
- '.bash': 'bash',
738
- '.zsh': 'bash',
739
- '.json': 'json',
740
- '.yaml': 'yaml',
741
- '.yml': 'yaml',
742
- '.toml': 'ini',
743
- '.ini': 'ini',
744
- '.cfg': 'ini',
745
- '.html': 'xml',
746
- '.htm': 'xml',
747
- '.xml': 'xml',
748
- '.css': 'css',
749
- '.scss': 'scss',
750
- '.sass': 'scss',
751
- '.less': 'less',
752
- '.sql': 'sql',
753
- '.go': 'go',
754
- '.rs': 'rust',
755
- '.java': 'java',
756
- '.c': 'c',
757
- '.cpp': 'cpp',
758
- '.h': 'c',
759
- '.hpp': 'cpp',
760
- '.cs': 'csharp',
761
- '.rb': 'ruby',
762
- '.php': 'php',
763
- '.swift': 'swift',
764
- '.kt': 'kotlin',
765
- };
766
- return langMap[ext] || 'plaintext';
767
- }
768
-
769
- /**
770
- * 下载当前版本 zip
771
- */
772
- function downloadCurrentVersion() {
773
- if (!currentSkill || !currentVersion) {
774
- showToast(t('skill.selectVersionFirst'), 'warning');
775
- return;
776
- }
777
-
778
- const downloadUrl = `/api/v1/skills/${currentSkill.id}/versions/${currentVersion}/download`;
779
- window.open(downloadUrl);
780
- }
781
-
782
- /**
783
- * 跳转到版本对比页
784
- */
785
- function goToDiff() {
786
- if (!currentSkill) {
787
- showToast(t('skill.infoLoading'), 'warning');
788
- return;
789
- }
790
-
791
- if (versions.length < 2) {
792
- showToast(t('skill.needTwoVersions'), 'warning');
793
- return;
794
- }
795
-
796
- // 跳转到 diff 页面,默认对比最新两个版本
797
- const versionA = versions[1]?.version || '';
798
- const versionB = versions[0]?.version || '';
799
- window.location.href = `/diff.html?id=${encodeURIComponent(currentSkill.id)}&version_a=${encodeURIComponent(versionA)}&version_b=${encodeURIComponent(versionB)}`;
800
- }
801
-
802
- /**
803
- * 切换版本历史折叠状态
804
- */
805
- function toggleVersionHistory() {
806
- const container = document.getElementById('version-history');
807
- container.classList.toggle('collapsed');
808
- }