skill-base 2.0.1
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 +141 -0
- package/bin/skill-base.js +53 -0
- package/data/.gitkeep +0 -0
- package/package.json +36 -0
- package/src/database.js +119 -0
- package/src/index.js +88 -0
- package/src/middleware/.gitkeep +0 -0
- package/src/middleware/admin.js +23 -0
- package/src/middleware/auth.js +96 -0
- package/src/middleware/error.js +23 -0
- package/src/models/.gitkeep +0 -0
- package/src/models/skill.js +57 -0
- package/src/models/user.js +130 -0
- package/src/models/version.js +57 -0
- package/src/routes/.gitkeep +0 -0
- package/src/routes/auth.js +173 -0
- package/src/routes/collaborators.js +260 -0
- package/src/routes/init.js +86 -0
- package/src/routes/publish.js +108 -0
- package/src/routes/skills.js +119 -0
- package/src/routes/users.js +169 -0
- package/src/utils/.gitkeep +0 -0
- package/src/utils/crypto.js +35 -0
- package/src/utils/permission.js +45 -0
- package/src/utils/zip.js +35 -0
- package/static/admin/users.html +593 -0
- package/static/cli-code.html +203 -0
- package/static/css/.gitkeep +0 -0
- package/static/css/style.css +1567 -0
- package/static/diff.html +466 -0
- package/static/file.html +443 -0
- package/static/index.html +251 -0
- package/static/js/.gitkeep +0 -0
- package/static/js/admin/users.js +346 -0
- package/static/js/app.js +508 -0
- package/static/js/auth.js +151 -0
- package/static/js/cli-code.js +184 -0
- package/static/js/collaborators.js +283 -0
- package/static/js/diff.js +540 -0
- package/static/js/file.js +619 -0
- package/static/js/i18n.js +739 -0
- package/static/js/index.js +168 -0
- package/static/js/publish.js +718 -0
- package/static/js/settings.js +124 -0
- package/static/js/setup.js +157 -0
- package/static/js/skill.js +808 -0
- package/static/login.html +82 -0
- package/static/publish.html +459 -0
- package/static/settings.html +163 -0
- package/static/setup.html +101 -0
- package/static/skill.html +851 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill Base - 文件预览页逻辑
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// 文本文件扩展名列表
|
|
6
|
+
const TEXT_EXTS = new Set([
|
|
7
|
+
'.md', '.py', '.sh', '.bash', '.zsh',
|
|
8
|
+
'.js', '.jsx', '.ts', '.tsx', '.vue',
|
|
9
|
+
'.json', '.yaml', '.yml', '.toml', '.ini', '.cfg',
|
|
10
|
+
'.txt', '.text', '.log',
|
|
11
|
+
'.html', '.htm', '.css', '.scss', '.sass', '.less',
|
|
12
|
+
'.xml', '.sql', '.go', '.rs', '.java', '.c', '.cpp',
|
|
13
|
+
'.h', '.hpp', '.cs', '.rb', '.php', '.swift', '.kt',
|
|
14
|
+
'.dockerfile', '.gitignore', '.env.example',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
// 无扩展名但是文本文件
|
|
18
|
+
const TEXT_FILENAMES = new Set([
|
|
19
|
+
'dockerfile', 'makefile', 'rakefile', 'readme', 'license', 'changelog',
|
|
20
|
+
'.gitignore', '.gitattributes', '.editorconfig', '.env', '.env.example',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
// 二进制文件魔数
|
|
24
|
+
const BINARY_MAGIC = [
|
|
25
|
+
[0x89, 0x50, 0x4E, 0x47], // PNG
|
|
26
|
+
[0xFF, 0xD8, 0xFF], // JPEG
|
|
27
|
+
[0x47, 0x49, 0x46, 0x38], // GIF
|
|
28
|
+
[0x50, 0x4B, 0x03, 0x04], // ZIP
|
|
29
|
+
[0x25, 0x50, 0x44, 0x46], // PDF
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// 大文件阈值 (1MB)
|
|
33
|
+
const LARGE_FILE_SIZE = 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
// 全局状态
|
|
36
|
+
let currentSkill = null;
|
|
37
|
+
let currentVersion = null;
|
|
38
|
+
let currentFilePath = null;
|
|
39
|
+
let currentZip = null;
|
|
40
|
+
let versions = [];
|
|
41
|
+
|
|
42
|
+
// 页面加载时初始化
|
|
43
|
+
document.addEventListener('DOMContentLoaded', async () => {
|
|
44
|
+
const params = new URLSearchParams(window.location.search);
|
|
45
|
+
const skillId = params.get('id');
|
|
46
|
+
const version = params.get('version');
|
|
47
|
+
const filePath = params.get('path');
|
|
48
|
+
|
|
49
|
+
if (!skillId) {
|
|
50
|
+
showToast(t('file.missingId'), 'error');
|
|
51
|
+
setTimeout(() => window.location.href = '/', 1500);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 1. 检查登录状态
|
|
56
|
+
const user = await checkAuth();
|
|
57
|
+
if (!user) return;
|
|
58
|
+
|
|
59
|
+
// 2. 渲染导航栏
|
|
60
|
+
renderNavbar(user);
|
|
61
|
+
|
|
62
|
+
// 3. 加载 Skill 信息用于面包屑
|
|
63
|
+
await loadSkillInfo(skillId);
|
|
64
|
+
|
|
65
|
+
// 4. 加载版本列表
|
|
66
|
+
await loadVersions(skillId);
|
|
67
|
+
|
|
68
|
+
// 5. 确定当前版本
|
|
69
|
+
currentVersion = version || (currentSkill?.latest_version);
|
|
70
|
+
if (!currentVersion && versions.length > 0) {
|
|
71
|
+
currentVersion = versions[0].version;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 6. 下载并解压 zip
|
|
75
|
+
if (currentVersion) {
|
|
76
|
+
await loadZipAndTree(currentVersion);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 7. 如果有文件路径参数,预览该文件
|
|
80
|
+
if (filePath) {
|
|
81
|
+
currentFilePath = filePath;
|
|
82
|
+
await previewFile(filePath);
|
|
83
|
+
highlightCurrentFile(filePath);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 加载 Skill 信息
|
|
89
|
+
* @param {string} skillId - Skill ID
|
|
90
|
+
*/
|
|
91
|
+
async function loadSkillInfo(skillId) {
|
|
92
|
+
try {
|
|
93
|
+
const skill = await apiGet(`/skills/${skillId}`);
|
|
94
|
+
currentSkill = skill;
|
|
95
|
+
|
|
96
|
+
// 更新页面标题
|
|
97
|
+
document.title = `${t('file.title').replace(/ - Skill Base$/, '')} - ${skill.name} - Skill Base`;
|
|
98
|
+
|
|
99
|
+
// 更新面包屑
|
|
100
|
+
const breadcrumbSkill = document.getElementById('breadcrumb-skill');
|
|
101
|
+
breadcrumbSkill.textContent = skill.name;
|
|
102
|
+
breadcrumbSkill.href = `/skill.html?id=${encodeURIComponent(skillId)}`;
|
|
103
|
+
|
|
104
|
+
} catch (error) {
|
|
105
|
+
console.error('Failed to load Skill info:', error);
|
|
106
|
+
showToast(t('skill.loadInfoFailed'), 'error');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 加载版本列表
|
|
112
|
+
* @param {string} skillId - Skill ID
|
|
113
|
+
*/
|
|
114
|
+
async function loadVersions(skillId) {
|
|
115
|
+
try {
|
|
116
|
+
const data = await apiGet(`/skills/${skillId}/versions`);
|
|
117
|
+
versions = data.versions || [];
|
|
118
|
+
|
|
119
|
+
// 渲染版本下拉框
|
|
120
|
+
const select = document.getElementById('version-select');
|
|
121
|
+
if (select) {
|
|
122
|
+
select.innerHTML = `<option value="">${t('file.selectOtherVersion')}</option>` +
|
|
123
|
+
versions.map((v, index) => {
|
|
124
|
+
const label = index === 0 ? `${v.version} ${t('skill.latestTag')}` : v.version;
|
|
125
|
+
return `<option value="${escapeHtml(v.version)}">${escapeHtml(label)}</option>`;
|
|
126
|
+
}).join('');
|
|
127
|
+
}
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.error('Failed to load versions:', error);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 加载 ZIP 并生成目录树
|
|
135
|
+
* @param {string} version - 版本号
|
|
136
|
+
*/
|
|
137
|
+
async function loadZipAndTree(version) {
|
|
138
|
+
const fileTreeContainer = document.getElementById('file-tree');
|
|
139
|
+
|
|
140
|
+
// 显示 Loading
|
|
141
|
+
fileTreeContainer.innerHTML = `
|
|
142
|
+
<div class="loading-content">
|
|
143
|
+
<div class="spinner spinner-sm"></div>
|
|
144
|
+
</div>
|
|
145
|
+
`;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
// 下载 zip
|
|
149
|
+
const downloadUrl = `/api/v1/skills/${currentSkill.id}/versions/${version}/download`;
|
|
150
|
+
const response = await fetch(downloadUrl, { credentials: 'same-origin' });
|
|
151
|
+
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
throw new Error('Failed to download zip');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const zipData = await response.arrayBuffer();
|
|
157
|
+
|
|
158
|
+
// 使用 JSZip 解压
|
|
159
|
+
const zip = await JSZip.loadAsync(zipData);
|
|
160
|
+
currentZip = zip;
|
|
161
|
+
currentVersion = version;
|
|
162
|
+
|
|
163
|
+
// 更新版本显示
|
|
164
|
+
document.getElementById('current-version').textContent = version;
|
|
165
|
+
|
|
166
|
+
// 更新版本下拉框选中状态
|
|
167
|
+
const select = document.getElementById('version-select');
|
|
168
|
+
if (select) {
|
|
169
|
+
select.value = '';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 生成目录树
|
|
173
|
+
const fileTree = generateFileTree(zip);
|
|
174
|
+
renderFileTree(fileTree, fileTreeContainer, currentFilePath);
|
|
175
|
+
|
|
176
|
+
} catch (error) {
|
|
177
|
+
console.error('Failed to load ZIP:', error);
|
|
178
|
+
showToast(t('skill.versionFailed') + error.message, 'error');
|
|
179
|
+
fileTreeContainer.innerHTML = `
|
|
180
|
+
<div class="empty-preview">
|
|
181
|
+
<p class="text-error">${t('file.loadFailed')}</p>
|
|
182
|
+
</div>
|
|
183
|
+
`;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 生成目录树(从 JSZip 实例)
|
|
189
|
+
* @param {JSZip} zip - JSZip 实例
|
|
190
|
+
* @returns {array} - 目录树结构
|
|
191
|
+
*/
|
|
192
|
+
function generateFileTree(zip) {
|
|
193
|
+
const root = { type: 'directory', name: '', children: [] };
|
|
194
|
+
|
|
195
|
+
zip.forEach((relativePath, zipEntry) => {
|
|
196
|
+
if (!relativePath) return;
|
|
197
|
+
|
|
198
|
+
const parts = relativePath.split('/').filter(Boolean);
|
|
199
|
+
let current = root;
|
|
200
|
+
|
|
201
|
+
for (let i = 0; i < parts.length; i++) {
|
|
202
|
+
const part = parts[i];
|
|
203
|
+
const isLast = i === parts.length - 1;
|
|
204
|
+
const isDir = zipEntry.dir || !isLast;
|
|
205
|
+
|
|
206
|
+
let child = current.children.find(c => c.name === part);
|
|
207
|
+
if (!child) {
|
|
208
|
+
child = {
|
|
209
|
+
type: isDir ? 'directory' : 'file',
|
|
210
|
+
name: part,
|
|
211
|
+
path: relativePath,
|
|
212
|
+
...(isDir ? { children: [] } : {})
|
|
213
|
+
};
|
|
214
|
+
current.children.push(child);
|
|
215
|
+
}
|
|
216
|
+
if (isDir) current = child;
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// 排序:目录在前,文件在后
|
|
221
|
+
sortTree(root.children);
|
|
222
|
+
|
|
223
|
+
return root.children;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 对目录树排序
|
|
228
|
+
* @param {array} nodes - 节点数组
|
|
229
|
+
*/
|
|
230
|
+
function sortTree(nodes) {
|
|
231
|
+
nodes.sort((a, b) => {
|
|
232
|
+
if (a.type !== b.type) {
|
|
233
|
+
return a.type === 'directory' ? -1 : 1;
|
|
234
|
+
}
|
|
235
|
+
return a.name.localeCompare(b.name);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
nodes.forEach(node => {
|
|
239
|
+
if (node.children) {
|
|
240
|
+
sortTree(node.children);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 渲染目录树 DOM
|
|
247
|
+
* @param {array} nodes - 目录树节点数组
|
|
248
|
+
* @param {HTMLElement} container - 容器元素
|
|
249
|
+
* @param {string} currentPath - 当前高亮的文件路径
|
|
250
|
+
*/
|
|
251
|
+
function renderFileTree(nodes, container, currentPath = null) {
|
|
252
|
+
if (!nodes || nodes.length === 0) {
|
|
253
|
+
container.innerHTML = `
|
|
254
|
+
<div class="empty-preview">
|
|
255
|
+
<p class="text-muted">${t('state.empty')}</p>
|
|
256
|
+
</div>
|
|
257
|
+
`;
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const ul = document.createElement('ul');
|
|
262
|
+
ul.style.paddingLeft = '0';
|
|
263
|
+
|
|
264
|
+
renderFileTreeNodes(nodes, ul, currentPath);
|
|
265
|
+
|
|
266
|
+
container.innerHTML = '';
|
|
267
|
+
container.appendChild(ul);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 递归渲染目录树节点
|
|
272
|
+
* @param {array} nodes - 节点数组
|
|
273
|
+
* @param {HTMLElement} container - 容器元素
|
|
274
|
+
* @param {string} currentPath - 当前高亮的文件路径
|
|
275
|
+
*/
|
|
276
|
+
function renderFileTreeNodes(nodes, container, currentPath) {
|
|
277
|
+
nodes.forEach(node => {
|
|
278
|
+
const li = document.createElement('li');
|
|
279
|
+
|
|
280
|
+
if (node.type === 'directory') {
|
|
281
|
+
li.className = 'file-tree-folder open';
|
|
282
|
+
li.innerHTML = `
|
|
283
|
+
<div class="file-tree-item">
|
|
284
|
+
<span class="icon">📁</span>
|
|
285
|
+
<span class="name">${escapeHtml(node.name)}</span>
|
|
286
|
+
</div>
|
|
287
|
+
`;
|
|
288
|
+
|
|
289
|
+
const childContainer = document.createElement('ul');
|
|
290
|
+
renderFileTreeNodes(node.children || [], childContainer, currentPath);
|
|
291
|
+
li.appendChild(childContainer);
|
|
292
|
+
|
|
293
|
+
// 绑定折叠/展开事件
|
|
294
|
+
const itemDiv = li.querySelector('.file-tree-item');
|
|
295
|
+
itemDiv.addEventListener('click', (e) => {
|
|
296
|
+
e.stopPropagation();
|
|
297
|
+
li.classList.toggle('open');
|
|
298
|
+
const icon = li.querySelector('.icon');
|
|
299
|
+
icon.textContent = li.classList.contains('open') ? '📁' : '📂';
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
} else {
|
|
303
|
+
li.className = 'file-tree-file';
|
|
304
|
+
const isSelected = currentPath && node.path === currentPath;
|
|
305
|
+
li.innerHTML = `
|
|
306
|
+
<div class="file-tree-item${isSelected ? ' selected' : ''}" data-path="${escapeHtml(node.path)}">
|
|
307
|
+
<span class="icon">📄</span>
|
|
308
|
+
<span class="name">${escapeHtml(node.name)}</span>
|
|
309
|
+
</div>
|
|
310
|
+
`;
|
|
311
|
+
|
|
312
|
+
// 绑定点击预览事件
|
|
313
|
+
const itemDiv = li.querySelector('.file-tree-item');
|
|
314
|
+
itemDiv.addEventListener('click', (e) => {
|
|
315
|
+
e.stopPropagation();
|
|
316
|
+
document.querySelectorAll('.file-tree-item.selected').forEach(el => {
|
|
317
|
+
el.classList.remove('selected');
|
|
318
|
+
});
|
|
319
|
+
itemDiv.classList.add('selected');
|
|
320
|
+
currentFilePath = node.path;
|
|
321
|
+
previewFile(node.path);
|
|
322
|
+
// 更新 URL
|
|
323
|
+
updateUrl(node.path);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
container.appendChild(li);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* 高亮当前文件
|
|
333
|
+
* @param {string} filePath - 文件路径
|
|
334
|
+
*/
|
|
335
|
+
function highlightCurrentFile(filePath) {
|
|
336
|
+
document.querySelectorAll('.file-tree-item.selected').forEach(el => {
|
|
337
|
+
el.classList.remove('selected');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const targetItem = document.querySelector(`.file-tree-item[data-path="${CSS.escape(filePath)}"]`);
|
|
341
|
+
if (targetItem) {
|
|
342
|
+
targetItem.classList.add('selected');
|
|
343
|
+
targetItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* 更新 URL(不刷新页面)
|
|
349
|
+
* @param {string} filePath - 文件路径
|
|
350
|
+
*/
|
|
351
|
+
function updateUrl(filePath) {
|
|
352
|
+
const url = new URL(window.location);
|
|
353
|
+
url.searchParams.set('path', filePath);
|
|
354
|
+
url.searchParams.set('version', currentVersion);
|
|
355
|
+
window.history.replaceState({}, '', url);
|
|
356
|
+
|
|
357
|
+
// 更新面包屑
|
|
358
|
+
const breadcrumbFile = document.getElementById('breadcrumb-file');
|
|
359
|
+
breadcrumbFile.textContent = filePath.split('/').pop();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* 判断是否为二进制文件
|
|
364
|
+
* @param {string} filePath - 文件路径
|
|
365
|
+
* @param {Uint8Array} contentBytes - 文件内容字节
|
|
366
|
+
* @returns {boolean}
|
|
367
|
+
*/
|
|
368
|
+
function isBinaryFile(filePath, contentBytes) {
|
|
369
|
+
const ext = '.' + filePath.split('.').pop()?.toLowerCase();
|
|
370
|
+
const baseName = filePath.split('/').pop()?.toLowerCase();
|
|
371
|
+
|
|
372
|
+
if (TEXT_EXTS.has(ext) || TEXT_FILENAMES.has(baseName)) {
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (contentBytes && contentBytes.length > 0) {
|
|
377
|
+
const header = Array.from(contentBytes.slice(0, 8));
|
|
378
|
+
for (const magic of BINARY_MAGIC) {
|
|
379
|
+
if (magic.every((b, i) => header[i] === b)) {
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return contentBytes.slice(0, 8192).some(b => b === 0);
|
|
384
|
+
}
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* 预览文件
|
|
390
|
+
* @param {string} filePath - 文件路径
|
|
391
|
+
*/
|
|
392
|
+
async function previewFile(filePath) {
|
|
393
|
+
if (!currentZip) {
|
|
394
|
+
showToast(t('skill.selectVersionFirst'), 'warning');
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const container = document.getElementById('file-content');
|
|
399
|
+
const pathDisplay = document.getElementById('file-path');
|
|
400
|
+
const fileSizeDisplay = document.getElementById('file-size');
|
|
401
|
+
|
|
402
|
+
// 更新路径显示
|
|
403
|
+
pathDisplay.textContent = filePath;
|
|
404
|
+
|
|
405
|
+
// 显示 Loading
|
|
406
|
+
container.innerHTML = `
|
|
407
|
+
<div class="loading-content">
|
|
408
|
+
<div class="spinner"></div>
|
|
409
|
+
</div>
|
|
410
|
+
`;
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
const file = currentZip.file(filePath);
|
|
414
|
+
if (!file) {
|
|
415
|
+
container.innerHTML = `
|
|
416
|
+
<div class="empty-preview">
|
|
417
|
+
<div class="empty-preview-icon">❓</div>
|
|
418
|
+
<p>${t('skill.fileNotFound')}</p>
|
|
419
|
+
</div>
|
|
420
|
+
`;
|
|
421
|
+
fileSizeDisplay.textContent = '-';
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const bytes = await file.async('uint8array');
|
|
426
|
+
const fileSize = bytes.length;
|
|
427
|
+
fileSizeDisplay.textContent = formatFileSize(fileSize);
|
|
428
|
+
|
|
429
|
+
// 大文件警告
|
|
430
|
+
let warningHtml = '';
|
|
431
|
+
if (fileSize > LARGE_FILE_SIZE) {
|
|
432
|
+
warningHtml = `
|
|
433
|
+
<div class="large-file-warning">
|
|
434
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
435
|
+
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
|
436
|
+
<line x1="12" y1="9" x2="12" y2="13"/>
|
|
437
|
+
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
|
438
|
+
</svg>
|
|
439
|
+
<span>${(window.I18N_LANG || 'en').startsWith('zh') ? `文件较大 (${formatFileSize(fileSize)}),加载可能较慢` : `Large file (${formatFileSize(fileSize)}), loading may be slow`}</span>
|
|
440
|
+
</div>
|
|
441
|
+
`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (isBinaryFile(filePath, bytes)) {
|
|
445
|
+
container.innerHTML = `
|
|
446
|
+
${warningHtml}
|
|
447
|
+
<div class="binary-notice">
|
|
448
|
+
<div class="binary-notice-icon">📦</div>
|
|
449
|
+
<p>${t('file.binaryFile')}</p>
|
|
450
|
+
<p class="text-muted mt-1">${t('file.binaryHint')}</p>
|
|
451
|
+
</div>
|
|
452
|
+
`;
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const content = new TextDecoder().decode(bytes);
|
|
457
|
+
const ext = filePath.split('.').pop()?.toLowerCase();
|
|
458
|
+
|
|
459
|
+
if (ext === 'md') {
|
|
460
|
+
// Markdown 渲染
|
|
461
|
+
const html = marked.parse(content);
|
|
462
|
+
container.innerHTML = `${warningHtml}<div class="markdown-body">${html}</div>`;
|
|
463
|
+
const hl = typeof hljs !== 'undefined' ? hljs : null;
|
|
464
|
+
container.querySelectorAll('pre code').forEach((block) => {
|
|
465
|
+
if (hl && typeof hl.highlightElement === 'function') {
|
|
466
|
+
hl.highlightElement(block);
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
} else {
|
|
470
|
+
// 代码高亮 + 行号
|
|
471
|
+
const language = getLanguage(ext);
|
|
472
|
+
let highlighted;
|
|
473
|
+
const hl = typeof hljs !== 'undefined' ? hljs : null;
|
|
474
|
+
if (hl && typeof hl.highlight === 'function') {
|
|
475
|
+
try {
|
|
476
|
+
highlighted = hl.highlight(content, { language }).value;
|
|
477
|
+
} catch {
|
|
478
|
+
highlighted = escapeHtml(content);
|
|
479
|
+
}
|
|
480
|
+
} else {
|
|
481
|
+
highlighted = escapeHtml(content);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// 生成带行号的代码
|
|
485
|
+
const lines = highlighted.split('\n');
|
|
486
|
+
const codeWithLines = lines.map((line, index) => `
|
|
487
|
+
<div class="code-line">
|
|
488
|
+
<span class="code-line-number">${index + 1}</span>
|
|
489
|
+
<span class="code-line-content">${line || ' '}</span>
|
|
490
|
+
</div>
|
|
491
|
+
`).join('');
|
|
492
|
+
|
|
493
|
+
container.innerHTML = `
|
|
494
|
+
${warningHtml}
|
|
495
|
+
<pre><code class="hljs"><div class="code-with-lines">${codeWithLines}</div></code></pre>
|
|
496
|
+
`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
} catch (error) {
|
|
500
|
+
console.error('Failed to preview file:', error);
|
|
501
|
+
container.innerHTML = `
|
|
502
|
+
<div class="empty-preview">
|
|
503
|
+
<div class="empty-preview-icon">❌</div>
|
|
504
|
+
<p>${t('skill.previewFailed')}${escapeHtml(error.message)}</p>
|
|
505
|
+
</div>
|
|
506
|
+
`;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* 获取语言名(用于 highlight.js)
|
|
512
|
+
* @param {string} ext - 文件扩展名
|
|
513
|
+
* @returns {string}
|
|
514
|
+
*/
|
|
515
|
+
function getLanguage(ext) {
|
|
516
|
+
const langMap = {
|
|
517
|
+
'js': 'javascript',
|
|
518
|
+
'jsx': 'javascript',
|
|
519
|
+
'ts': 'typescript',
|
|
520
|
+
'tsx': 'typescript',
|
|
521
|
+
'py': 'python',
|
|
522
|
+
'sh': 'bash',
|
|
523
|
+
'bash': 'bash',
|
|
524
|
+
'zsh': 'bash',
|
|
525
|
+
'json': 'json',
|
|
526
|
+
'yaml': 'yaml',
|
|
527
|
+
'yml': 'yaml',
|
|
528
|
+
'html': 'html',
|
|
529
|
+
'htm': 'html',
|
|
530
|
+
'css': 'css',
|
|
531
|
+
'scss': 'scss',
|
|
532
|
+
'sql': 'sql',
|
|
533
|
+
'go': 'go',
|
|
534
|
+
'rs': 'rust',
|
|
535
|
+
'java': 'java',
|
|
536
|
+
'c': 'c',
|
|
537
|
+
'cpp': 'cpp',
|
|
538
|
+
'h': 'c',
|
|
539
|
+
'hpp': 'cpp',
|
|
540
|
+
'rb': 'ruby',
|
|
541
|
+
'php': 'php',
|
|
542
|
+
'swift': 'swift',
|
|
543
|
+
'kt': 'kotlin',
|
|
544
|
+
'vue': 'html',
|
|
545
|
+
'xml': 'xml',
|
|
546
|
+
'md': 'markdown',
|
|
547
|
+
'toml': 'ini',
|
|
548
|
+
'ini': 'ini',
|
|
549
|
+
'cfg': 'ini',
|
|
550
|
+
};
|
|
551
|
+
return langMap[ext] || 'plaintext';
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* 版本选择变更事件
|
|
556
|
+
* @param {string} version - 选中的版本
|
|
557
|
+
*/
|
|
558
|
+
async function onVersionChange(version) {
|
|
559
|
+
if (version && version !== currentVersion) {
|
|
560
|
+
await loadZipAndTree(version);
|
|
561
|
+
// 如果当前有选中的文件,重新预览
|
|
562
|
+
if (currentFilePath) {
|
|
563
|
+
await previewFile(currentFilePath);
|
|
564
|
+
highlightCurrentFile(currentFilePath);
|
|
565
|
+
}
|
|
566
|
+
// 更新 URL
|
|
567
|
+
const url = new URL(window.location);
|
|
568
|
+
url.searchParams.set('version', version);
|
|
569
|
+
window.history.replaceState({}, '', url);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* 下载当前版本 zip
|
|
575
|
+
*/
|
|
576
|
+
function downloadCurrentVersion() {
|
|
577
|
+
if (!currentSkill || !currentVersion) {
|
|
578
|
+
showToast(t('skill.selectVersionFirst'), 'warning');
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const downloadUrl = `/api/v1/skills/${currentSkill.id}/versions/${currentVersion}/download`;
|
|
583
|
+
window.open(downloadUrl);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* 跳转到版本对比页
|
|
588
|
+
*/
|
|
589
|
+
function goToDiff() {
|
|
590
|
+
if (!currentSkill) {
|
|
591
|
+
showToast(t('skill.infoLoading'), 'warning');
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (versions.length < 2) {
|
|
596
|
+
showToast(t('skill.needTwoVersions'), 'warning');
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// 默认对比最新两个版本,并带上当前文件路径
|
|
601
|
+
const versionA = versions[1]?.version || '';
|
|
602
|
+
const versionB = versions[0]?.version || '';
|
|
603
|
+
let url = `/diff.html?id=${encodeURIComponent(currentSkill.id)}&version_a=${encodeURIComponent(versionA)}&version_b=${encodeURIComponent(versionB)}`;
|
|
604
|
+
if (currentFilePath) {
|
|
605
|
+
url += `&path=${encodeURIComponent(currentFilePath)}`;
|
|
606
|
+
}
|
|
607
|
+
window.location.href = url;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* 返回 Skill 详情页
|
|
612
|
+
*/
|
|
613
|
+
function goToSkillDetail() {
|
|
614
|
+
if (currentSkill) {
|
|
615
|
+
window.location.href = `/skill.html?id=${encodeURIComponent(currentSkill.id)}`;
|
|
616
|
+
} else {
|
|
617
|
+
window.location.href = '/';
|
|
618
|
+
}
|
|
619
|
+
}
|