@code2rich/jpage 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +68 -0
- package/.dockerignore +8 -0
- package/.env.example +56 -0
- package/.github/workflows/ci.yml +43 -0
- package/CLAUDE.md +280 -0
- package/Dockerfile +44 -0
- package/LICENSE +21 -0
- package/README.md +433 -0
- package/README_EN.md +399 -0
- package/bin/args.js +64 -0
- package/bin/client.js +93 -0
- package/bin/commands/_shared.js +54 -0
- package/bin/commands/cat.js +23 -0
- package/bin/commands/ls.js +44 -0
- package/bin/commands/mv.js +20 -0
- package/bin/commands/rm.js +22 -0
- package/bin/commands/skills.js +70 -0
- package/bin/commands/star.js +23 -0
- package/bin/commands/tags.js +97 -0
- package/bin/commands/upload.js +84 -0
- package/bin/commands/url.js +25 -0
- package/bin/commands/whoami.js +29 -0
- package/bin/config.js +85 -0
- package/bin/jpage.js +168 -0
- package/build.js +112 -0
- package/docker-compose.yml +26 -0
- package/docs/api.md +438 -0
- package/docs/design/005-custom-modal.md +296 -0
- package/docs/design/013-file-version-history.md +324 -0
- package/docs/design/billing-system.md +600 -0
- package/docs/design/db-index-and-healthcheck.md +176 -0
- package/docs/design/loading-states.md +209 -0
- package/docs/virtual-hosting-feasibility.md +453 -0
- package/eslint.config.mjs +172 -0
- package/lib/auth-state.js +15 -0
- package/lib/categories.js +20 -0
- package/lib/crypto.js +85 -0
- package/lib/csp.js +66 -0
- package/lib/db.js +53 -0
- package/lib/dispatch.js +103 -0
- package/lib/fts.js +81 -0
- package/lib/middleware/auth.js +114 -0
- package/lib/middleware/files.js +42 -0
- package/lib/paths.js +9 -0
- package/lib/render-cache.js +48 -0
- package/lib/render.js +157 -0
- package/lib/templates.js +149 -0
- package/lib/util.js +66 -0
- package/lib/view-counts.js +59 -0
- package/lib/zip.js +192 -0
- package/logger.js +16 -0
- package/mailer.js +34 -0
- package/mcp/constants.js +16 -0
- package/mcp/resources.js +74 -0
- package/mcp/server.js +43 -0
- package/mcp/tools-categories.js +56 -0
- package/mcp/tools-content-templates.js +59 -0
- package/mcp/tools-files.js +245 -0
- package/mcp/tools-tags.js +41 -0
- package/mcp/tools-versions.js +57 -0
- package/mcp/transport.js +183 -0
- package/mcp/util.js +63 -0
- package/mcp-server.js +20 -0
- package/migrations/001_init_schema.js +25 -0
- package/migrations/002_add_share_key.js +33 -0
- package/migrations/003_add_roles_and_tokens.js +28 -0
- package/migrations/004_add_version_history.js +32 -0
- package/migrations/005_tags_starred_categories.js +49 -0
- package/migrations/006_zip_bundle.js +17 -0
- package/migrations/007_add_file_type_uploaded_by_indexes.js +7 -0
- package/migrations/008_add_fts5.js +6 -0
- package/migrations/009_add_link_visits.js +20 -0
- package/migrations/010_add_templates_system.js +34 -0
- package/migrations/011_content_templates.js +233 -0
- package/migrations/012_add_email_and_verification.js +35 -0
- package/migrations/013_add_token_encrypted.js +14 -0
- package/migrations.js +65 -0
- package/package.json +63 -0
- package/public/css/style.css +2915 -0
- package/public/index.html +855 -0
- package/public/js/api.js +22 -0
- package/public/js/app.js +94 -0
- package/public/js/components/dialog.js +106 -0
- package/public/js/components/toast.js +13 -0
- package/public/js/pages/content-templates.js +330 -0
- package/public/js/pages/home.js +1903 -0
- package/public/js/pages/landing.js +158 -0
- package/public/js/pages/login.js +175 -0
- package/public/js/pages/preview.js +713 -0
- package/public/js/theme.js +44 -0
- package/public/js/utils.js +67 -0
- package/routes/admin.js +136 -0
- package/routes/auth.js +365 -0
- package/routes/categories.js +90 -0
- package/routes/content-templates.js +215 -0
- package/routes/files/_shared.js +112 -0
- package/routes/files/associations.js +94 -0
- package/routes/files/crud.js +139 -0
- package/routes/files/detail-serve.js +178 -0
- package/routes/files/index.js +38 -0
- package/routes/files/list.js +200 -0
- package/routes/files/overwrite.js +114 -0
- package/routes/files/upload.js +204 -0
- package/routes/files/versions.js +166 -0
- package/routes/files.js +16 -0
- package/routes/skills.js +93 -0
- package/routes/tags.js +65 -0
- package/routes/tokens.js +110 -0
- package/routes/users.js +120 -0
- package/server.js +372 -0
- package/skills/jpage-content-template/SKILL.md +98 -0
- package/skills/jpage-upload/SKILL.md +247 -0
- package/skills-registry.js +135 -0
- package/templates/academic.html +41 -0
- package/templates/dark-pro.html +41 -0
- package/templates/default.html +56 -0
- package/templates/github.html +67 -0
- package/test/browser-harness.js +125 -0
- package/test/dispatch-bench.js +74 -0
- package/test/helpers/setup.js +45 -0
- package/test/integration/admin.test.js +108 -0
- package/test/integration/auth.test.js +93 -0
- package/test/integration/categories.test.js +103 -0
- package/test/integration/cli.test.js +310 -0
- package/test/integration/content-templates.test.js +147 -0
- package/test/integration/files-security.test.js +248 -0
- package/test/integration/files.test.js +139 -0
- package/test/integration/share.test.js +79 -0
- package/test/integration/skills.test.js +104 -0
- package/test/integration/tags.test.js +84 -0
- package/test/integration/tokens.test.js +89 -0
- package/test/integration/users.test.js +138 -0
- package/test/mcp-harness.js +152 -0
- package/test/perf-bench.js +108 -0
- package/test/perf-harness.js +198 -0
- package/test/run-server.sh +15 -0
- package/test/unit/cli-args.test.js +88 -0
- package/test/unit/cli-config.test.js +89 -0
- package/test/unit/crypto.test.js +100 -0
- package/test/unit/fts.test.js +52 -0
- package/test/unit/render-cache.test.js +76 -0
- package/test/unit/util.test.js +81 -0
- package/test/unit/zip.test.js +164 -0
|
@@ -0,0 +1,1903 @@
|
|
|
1
|
+
// 首页:文件列表、上传、筛选、批量操作、标签/分类管理、Skills/MCP/用户/令牌弹窗
|
|
2
|
+
|
|
3
|
+
import { api, API_BASE } from '../api.js';
|
|
4
|
+
import { toast } from '../components/toast.js';
|
|
5
|
+
import { dialogModal } from '../components/dialog.js';
|
|
6
|
+
import { escapeHtml, formatSize, relativeTime, esc, buildSkeletonCards, formatDate, openModal, closeModal } from '../utils.js';
|
|
7
|
+
import { state, navigate } from '../app.js';
|
|
8
|
+
import { openContentTemplateMarket } from './content-templates.js';
|
|
9
|
+
|
|
10
|
+
// ---------- 模块级状态 ----------
|
|
11
|
+
let allFiles = [];
|
|
12
|
+
let pagination = { page: 1, limit: 20, total: 0, totalPages: 1 };
|
|
13
|
+
const filterState = { query: '', filter: 'all', tagId: null, categoryId: null };
|
|
14
|
+
let allTags = [];
|
|
15
|
+
let allCategories = [];
|
|
16
|
+
const selectedFileIds = new Set();
|
|
17
|
+
let lastCheckedIndex = -1;
|
|
18
|
+
let skillModalCurrent = null;
|
|
19
|
+
const allTemplates = [];
|
|
20
|
+
let searchResults = null;
|
|
21
|
+
|
|
22
|
+
// ---------- Home Page ----------
|
|
23
|
+
function renderHome(container) {
|
|
24
|
+
const tmpl = document.getElementById('home-template');
|
|
25
|
+
container.innerHTML = '';
|
|
26
|
+
container.appendChild(tmpl.content.cloneNode(true));
|
|
27
|
+
|
|
28
|
+
const userEl = container.querySelector('#header-user');
|
|
29
|
+
if (state.currentUser) {
|
|
30
|
+
const roleBadge = state.currentUser.role === 'admin' ? '' : ' <small style="color:var(--text-secondary);font-weight:400">(用户)</small>';
|
|
31
|
+
userEl.innerHTML = escapeHtml(state.currentUser.username) + roleBadge;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 根据角色显示/隐藏 admin-only 元素
|
|
35
|
+
const adminEls = container.querySelectorAll('.admin-only');
|
|
36
|
+
adminEls.forEach(el => { el.style.display = state.currentUser.role === 'admin' ? 'block' : 'none'; });
|
|
37
|
+
|
|
38
|
+
const logoutBtn = container.querySelector('#btn-logout');
|
|
39
|
+
logoutBtn.addEventListener('click', async () => {
|
|
40
|
+
try {
|
|
41
|
+
await api('/api/auth/logout', { method: 'POST' });
|
|
42
|
+
} catch (_) {}
|
|
43
|
+
state.currentUser = null;
|
|
44
|
+
toast('已退出');
|
|
45
|
+
location.hash = '#/';
|
|
46
|
+
location.reload();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// 邮箱验证提示条
|
|
50
|
+
const verifyBanner = container.querySelector('#email-verify-banner');
|
|
51
|
+
const resendBtn = container.querySelector('#btn-resend-verify');
|
|
52
|
+
if (verifyBanner && state.currentUser && !state.currentUser.emailVerified) {
|
|
53
|
+
api('/api/auth/smtp-status').then(data => {
|
|
54
|
+
if (data.configured) verifyBanner.hidden = false;
|
|
55
|
+
}).catch(() => {});
|
|
56
|
+
if (resendBtn) {
|
|
57
|
+
resendBtn.addEventListener('click', async () => {
|
|
58
|
+
resendBtn.disabled = true;
|
|
59
|
+
try {
|
|
60
|
+
await api('/api/auth/resend-verification', { method: 'POST' });
|
|
61
|
+
toast('验证邮件已发送');
|
|
62
|
+
let remain = 60;
|
|
63
|
+
resendBtn.textContent = remain + 's';
|
|
64
|
+
const t = setInterval(() => {
|
|
65
|
+
remain--;
|
|
66
|
+
if (remain <= 0) { clearInterval(t); resendBtn.disabled = false; resendBtn.textContent = '重新发送验证邮件'; }
|
|
67
|
+
else resendBtn.textContent = remain + 's';
|
|
68
|
+
}, 1000);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
toast(e.message || '发送失败');
|
|
71
|
+
resendBtn.disabled = false;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
setupUpload(container);
|
|
78
|
+
setupFileFilter(container);
|
|
79
|
+
loadTagsAndCategories(container);
|
|
80
|
+
loadFiles(container);
|
|
81
|
+
setupSkillModal();
|
|
82
|
+
|
|
83
|
+
// 批量操作工具栏事件
|
|
84
|
+
const batchToolbar = container.querySelector('#batch-toolbar');
|
|
85
|
+
if (batchToolbar) {
|
|
86
|
+
batchToolbar.querySelector('#batch-delete').addEventListener('click', async () => {
|
|
87
|
+
const count = selectedFileIds.size;
|
|
88
|
+
const ok = await dialogModal.confirm({
|
|
89
|
+
title: '确认批量删除',
|
|
90
|
+
message: `确定删除 <strong>${count}</strong> 个文件吗?此操作不可撤销。`,
|
|
91
|
+
confirmText: '删除',
|
|
92
|
+
danger: true,
|
|
93
|
+
});
|
|
94
|
+
if (!ok) return;
|
|
95
|
+
doBatchAction(container, 'delete');
|
|
96
|
+
});
|
|
97
|
+
batchToolbar.querySelector('#batch-set-public').addEventListener('click', () => {
|
|
98
|
+
doBatchAction(container, 'setPublic');
|
|
99
|
+
});
|
|
100
|
+
batchToolbar.querySelector('#batch-set-private').addEventListener('click', () => {
|
|
101
|
+
doBatchAction(container, 'setPrivate');
|
|
102
|
+
});
|
|
103
|
+
const categorySelect = batchToolbar.querySelector('#batch-category-select');
|
|
104
|
+
if (categorySelect) {
|
|
105
|
+
// 填充分类选项
|
|
106
|
+
const updateCategoryOptions = () => {
|
|
107
|
+
categorySelect.innerHTML = '<option value="" disabled selected>移动到分类…</option><option value="0">未分类</option>';
|
|
108
|
+
allCategories.forEach(c => {
|
|
109
|
+
categorySelect.innerHTML += `<option value="${c.id}">${escapeHtml(c.name)}</option>`;
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
updateCategoryOptions();
|
|
113
|
+
categorySelect.addEventListener('change', () => {
|
|
114
|
+
const val = categorySelect.value;
|
|
115
|
+
if (!val) return;
|
|
116
|
+
doBatchAction(container, 'setCategory', { categoryId: parseInt(val) });
|
|
117
|
+
categorySelect.value = '';
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
batchToolbar.querySelector('#batch-cancel').addEventListener('click', () => {
|
|
121
|
+
clearSelection(container);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 设置下拉菜单
|
|
126
|
+
const settingsBtn = container.querySelector('#btn-settings');
|
|
127
|
+
const settingsDropdown = container.querySelector('#settings-dropdown');
|
|
128
|
+
const settingsMenu = container.querySelector('#settings-menu');
|
|
129
|
+
|
|
130
|
+
if (settingsBtn && settingsDropdown) {
|
|
131
|
+
settingsBtn.addEventListener('click', e => {
|
|
132
|
+
e.stopPropagation();
|
|
133
|
+
const isOpen = settingsDropdown.classList.toggle('open');
|
|
134
|
+
settingsBtn.setAttribute('aria-expanded', String(isOpen));
|
|
135
|
+
if (isOpen) {
|
|
136
|
+
const firstItem = settingsMenu.querySelector('.settings-menu-item');
|
|
137
|
+
if (firstItem) firstItem.focus();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// 点击菜单项后关闭
|
|
142
|
+
settingsDropdown.querySelector('#menu-item-skills').addEventListener('click', () => {
|
|
143
|
+
settingsDropdown.classList.remove('open');
|
|
144
|
+
settingsBtn.setAttribute('aria-expanded', 'false');
|
|
145
|
+
openSkillsListModal();
|
|
146
|
+
});
|
|
147
|
+
settingsDropdown.querySelector('#menu-item-content-templates').addEventListener('click', () => {
|
|
148
|
+
settingsDropdown.classList.remove('open');
|
|
149
|
+
settingsBtn.setAttribute('aria-expanded', 'false');
|
|
150
|
+
openContentTemplateMarket();
|
|
151
|
+
});
|
|
152
|
+
settingsDropdown.querySelector('#menu-item-mcp').addEventListener('click', () => {
|
|
153
|
+
settingsDropdown.classList.remove('open');
|
|
154
|
+
settingsBtn.setAttribute('aria-expanded', 'false');
|
|
155
|
+
openMcpConfigModal();
|
|
156
|
+
});
|
|
157
|
+
settingsDropdown.querySelector('#menu-item-tokens')?.addEventListener('click', () => {
|
|
158
|
+
settingsDropdown.classList.remove('open');
|
|
159
|
+
settingsBtn.setAttribute('aria-expanded', 'false');
|
|
160
|
+
openTokensModal();
|
|
161
|
+
});
|
|
162
|
+
settingsDropdown.querySelector('#menu-item-password')?.addEventListener('click', () => {
|
|
163
|
+
settingsDropdown.classList.remove('open');
|
|
164
|
+
settingsBtn.setAttribute('aria-expanded', 'false');
|
|
165
|
+
openPasswordModal();
|
|
166
|
+
});
|
|
167
|
+
settingsDropdown.querySelector('#menu-item-profile')?.addEventListener('click', () => {
|
|
168
|
+
settingsDropdown.classList.remove('open');
|
|
169
|
+
settingsBtn.setAttribute('aria-expanded', 'false');
|
|
170
|
+
openProfileModal();
|
|
171
|
+
});
|
|
172
|
+
settingsDropdown.querySelector('#menu-item-users')?.addEventListener('click', () => {
|
|
173
|
+
settingsDropdown.classList.remove('open');
|
|
174
|
+
settingsBtn.setAttribute('aria-expanded', 'false');
|
|
175
|
+
openUsersModal();
|
|
176
|
+
});
|
|
177
|
+
settingsDropdown.querySelector('#menu-item-backup')?.addEventListener('click', () => {
|
|
178
|
+
settingsDropdown.classList.remove('open');
|
|
179
|
+
settingsBtn.setAttribute('aria-expanded', 'false');
|
|
180
|
+
openBackupModal();
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 点击外部关闭下拉菜单
|
|
185
|
+
document.addEventListener('click', e => {
|
|
186
|
+
const dd = document.querySelector('#settings-dropdown');
|
|
187
|
+
if (dd && dd.classList.contains('open') && !dd.contains(e.target)) {
|
|
188
|
+
dd.classList.remove('open');
|
|
189
|
+
const btn = dd.querySelector('#btn-settings');
|
|
190
|
+
if (btn) {
|
|
191
|
+
btn.setAttribute('aria-expanded', 'false');
|
|
192
|
+
btn.focus();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// 关闭文件项更多菜单
|
|
196
|
+
document.querySelectorAll('.file-more-dropdown.open').forEach(d => {
|
|
197
|
+
if (!d.contains(e.target)) {
|
|
198
|
+
d.classList.remove('open');
|
|
199
|
+
const t = d.querySelector('.file-more-trigger');
|
|
200
|
+
if (t) t.setAttribute('aria-expanded', 'false');
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------- Upload ----------
|
|
207
|
+
function setupUpload(container) {
|
|
208
|
+
const area = container.querySelector('#upload-area');
|
|
209
|
+
const input = container.querySelector('#file-input');
|
|
210
|
+
|
|
211
|
+
area.addEventListener('click', () => input.click());
|
|
212
|
+
area.addEventListener('keydown', e => {
|
|
213
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
214
|
+
e.preventDefault();
|
|
215
|
+
input.click();
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
area.addEventListener('dragover', e => { e.preventDefault(); area.classList.add('dragover'); });
|
|
219
|
+
area.addEventListener('dragleave', () => area.classList.remove('dragover'));
|
|
220
|
+
area.addEventListener('drop', e => {
|
|
221
|
+
e.preventDefault();
|
|
222
|
+
area.classList.remove('dragover');
|
|
223
|
+
if (e.dataTransfer.files.length) uploadFile(container, e.dataTransfer.files[0]);
|
|
224
|
+
});
|
|
225
|
+
input.addEventListener('change', () => {
|
|
226
|
+
if (input.files.length) uploadFile(container, input.files[0]);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function uploadFile(container, file) {
|
|
231
|
+
const allowed = ['.html', '.htm', '.md', '.markdown', '.zip'];
|
|
232
|
+
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase();
|
|
233
|
+
if (!allowed.includes(ext)) {
|
|
234
|
+
toast('仅支持 HTML、Markdown 和 ZIP 文件', 'error');
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const area = container.querySelector('#upload-area');
|
|
238
|
+
const prevPointer = area.style.pointerEvents;
|
|
239
|
+
area.style.pointerEvents = 'none';
|
|
240
|
+
|
|
241
|
+
const progressEl = container.querySelector('#upload-progress');
|
|
242
|
+
const progressBar = container.querySelector('#upload-progress-bar');
|
|
243
|
+
const progressText = container.querySelector('#upload-progress-text');
|
|
244
|
+
if (progressEl) progressEl.style.display = 'block';
|
|
245
|
+
if (progressBar) progressBar.style.width = '0%';
|
|
246
|
+
if (progressText) progressText.textContent = '0%';
|
|
247
|
+
|
|
248
|
+
const fd = new FormData();
|
|
249
|
+
fd.append('file', file);
|
|
250
|
+
const isPublicEl = container.querySelector('#upload-is-public');
|
|
251
|
+
fd.append('isPublic', isPublicEl && isPublicEl.checked ? 'true' : 'false');
|
|
252
|
+
|
|
253
|
+
return new Promise((resolve) => {
|
|
254
|
+
const xhr = new XMLHttpRequest();
|
|
255
|
+
xhr.open('POST', API_BASE + '/api/files/upload');
|
|
256
|
+
xhr.withCredentials = true;
|
|
257
|
+
xhr.upload.onprogress = (e) => {
|
|
258
|
+
if (e.lengthComputable) {
|
|
259
|
+
const pct = Math.round((e.loaded / e.total) * 100);
|
|
260
|
+
if (progressBar) progressBar.style.width = pct + '%';
|
|
261
|
+
if (progressText) progressText.textContent = pct + '%';
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
xhr.onload = () => {
|
|
265
|
+
const data = JSON.parse(xhr.responseText || '{}');
|
|
266
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
267
|
+
if (data.overwritten) {
|
|
268
|
+
toast(`已更新为第 ${data.version} 版`);
|
|
269
|
+
} else if (data.type === 'batch') {
|
|
270
|
+
toast('批量上传成功,共 ' + data.count + ' 个文件');
|
|
271
|
+
} else if (data.type === 'bundle') {
|
|
272
|
+
toast('网站包上传成功');
|
|
273
|
+
} else {
|
|
274
|
+
toast('上传成功');
|
|
275
|
+
}
|
|
276
|
+
container.querySelector('#file-input').value = '';
|
|
277
|
+
loadFiles(container);
|
|
278
|
+
} else {
|
|
279
|
+
toast(data.error || `HTTP ${xhr.status}`, 'error');
|
|
280
|
+
}
|
|
281
|
+
finish();
|
|
282
|
+
};
|
|
283
|
+
xhr.onerror = () => {
|
|
284
|
+
toast('上传失败,请检查网络', 'error');
|
|
285
|
+
finish();
|
|
286
|
+
};
|
|
287
|
+
function finish() {
|
|
288
|
+
area.style.pointerEvents = prevPointer;
|
|
289
|
+
if (progressBar) progressBar.style.width = '100%';
|
|
290
|
+
setTimeout(() => {
|
|
291
|
+
if (progressEl) progressEl.style.display = 'none';
|
|
292
|
+
}, 400);
|
|
293
|
+
resolve();
|
|
294
|
+
}
|
|
295
|
+
xhr.send(fd);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------- File List ----------
|
|
300
|
+
async function loadFiles(container, page) {
|
|
301
|
+
const list = container.querySelector('#file-list');
|
|
302
|
+
const empty = container.querySelector('#empty-state');
|
|
303
|
+
const countEl = container.querySelector('#file-count');
|
|
304
|
+
|
|
305
|
+
if (page) pagination.page = page;
|
|
306
|
+
|
|
307
|
+
list.setAttribute('aria-busy', 'true');
|
|
308
|
+
list.classList.add('is-loading');
|
|
309
|
+
list.innerHTML = buildSkeletonCards(Math.min(pagination.limit, 5));
|
|
310
|
+
empty.style.display = 'none';
|
|
311
|
+
countEl.textContent = '';
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
let data;
|
|
315
|
+
if (filterState.query) {
|
|
316
|
+
const params = new URLSearchParams({ q: filterState.query, page: pagination.page, limit: pagination.limit });
|
|
317
|
+
data = await api('/api/files/search?' + params.toString());
|
|
318
|
+
searchResults = data.files;
|
|
319
|
+
allFiles = data.files;
|
|
320
|
+
} else {
|
|
321
|
+
searchResults = null;
|
|
322
|
+
const params = new URLSearchParams({ page: pagination.page, limit: pagination.limit });
|
|
323
|
+
if (filterState.categoryId) params.set('category', filterState.categoryId);
|
|
324
|
+
if (filterState.tagId) params.set('tag', filterState.tagId);
|
|
325
|
+
data = await api('/api/files?' + params.toString());
|
|
326
|
+
allFiles = data.files;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
list.classList.remove('is-loading');
|
|
330
|
+
list.setAttribute('aria-busy', 'false');
|
|
331
|
+
|
|
332
|
+
pagination = data.pagination;
|
|
333
|
+
applyFilters(container);
|
|
334
|
+
} catch (e) {
|
|
335
|
+
list.classList.remove('is-loading');
|
|
336
|
+
list.setAttribute('aria-busy', 'false');
|
|
337
|
+
list.innerHTML = '';
|
|
338
|
+
if (e.status === 401) {
|
|
339
|
+
state.currentUser = null;
|
|
340
|
+
navigate('/');
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
toast(e.message, 'error');
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function applyFilters(container) {
|
|
348
|
+
const list = container.querySelector('#file-list');
|
|
349
|
+
const empty = container.querySelector('#empty-state');
|
|
350
|
+
const countEl = container.querySelector('#file-count');
|
|
351
|
+
|
|
352
|
+
let filtered = searchResults || allFiles;
|
|
353
|
+
|
|
354
|
+
if (filterState.filter === 'html') {
|
|
355
|
+
filtered = filtered.filter(f => f.file_type === 'html');
|
|
356
|
+
} else if (filterState.filter === 'markdown') {
|
|
357
|
+
filtered = filtered.filter(f => f.file_type === 'markdown');
|
|
358
|
+
} else if (filterState.filter === 'public') {
|
|
359
|
+
filtered = filtered.filter(f => f.is_public === 1);
|
|
360
|
+
} else if (filterState.filter === 'private') {
|
|
361
|
+
filtered = filtered.filter(f => f.is_public === 0);
|
|
362
|
+
} else if (filterState.filter === 'starred') {
|
|
363
|
+
filtered = filtered.filter(f => f.starred === true);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const hasFilter = filterState.query || filterState.filter !== 'all' || filterState.tagId || filterState.categoryId;
|
|
367
|
+
countEl.textContent = hasFilter
|
|
368
|
+
? `${filtered.length} / ${pagination.total} 个文件`
|
|
369
|
+
: `共 ${pagination.total} 个文件`;
|
|
370
|
+
|
|
371
|
+
list.innerHTML = '';
|
|
372
|
+
|
|
373
|
+
if (!filtered.length) {
|
|
374
|
+
if (!allFiles.length && pagination.total === 0) {
|
|
375
|
+
empty.style.display = 'block';
|
|
376
|
+
const cta = empty.querySelector('#empty-state-cta');
|
|
377
|
+
if (cta && !cta.dataset.bound) {
|
|
378
|
+
cta.dataset.bound = '1';
|
|
379
|
+
cta.addEventListener('click', () => {
|
|
380
|
+
const input = container.querySelector('#file-input');
|
|
381
|
+
if (input) input.click();
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
} else {
|
|
385
|
+
empty.style.display = 'none';
|
|
386
|
+
list.innerHTML = `
|
|
387
|
+
<div class="empty-state" style="padding:32px 20px">
|
|
388
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:36px;height:36px;margin-bottom:8px;opacity:.4"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
|
389
|
+
<p style="color:var(--text-secondary)">无匹配文件</p>
|
|
390
|
+
</div>`;
|
|
391
|
+
}
|
|
392
|
+
renderPagination(container);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
empty.style.display = 'none';
|
|
397
|
+
renderFileList(container, list, filtered);
|
|
398
|
+
renderPagination(container);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ---------- Batch Selection ----------
|
|
402
|
+
function updateBatchToolbar(container) {
|
|
403
|
+
const toolbar = container.querySelector('#batch-toolbar');
|
|
404
|
+
if (!toolbar) return;
|
|
405
|
+
const count = selectedFileIds.size;
|
|
406
|
+
toolbar.hidden = count === 0;
|
|
407
|
+
const deleteBtn = toolbar.querySelector('#batch-delete');
|
|
408
|
+
if (deleteBtn) deleteBtn.textContent = `删除(${count})`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function toggleFileCheckbox(fileId, el, isChecked) {
|
|
412
|
+
if (isChecked) {
|
|
413
|
+
selectedFileIds.add(fileId);
|
|
414
|
+
el.classList.add('selected');
|
|
415
|
+
} else {
|
|
416
|
+
selectedFileIds.delete(fileId);
|
|
417
|
+
el.classList.remove('selected');
|
|
418
|
+
}
|
|
419
|
+
updateBatchToolbar(el.closest('[id="app"]') || document.getElementById('app'));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function clearSelection(container) {
|
|
423
|
+
selectedFileIds.clear();
|
|
424
|
+
lastCheckedIndex = -1;
|
|
425
|
+
container.querySelectorAll('.file-checkbox').forEach(cb => {
|
|
426
|
+
cb.checked = false;
|
|
427
|
+
cb.closest('.file-item').classList.remove('selected');
|
|
428
|
+
});
|
|
429
|
+
const selectAll = container.querySelector('#select-all-checkbox');
|
|
430
|
+
if (selectAll) selectAll.checked = false;
|
|
431
|
+
updateBatchToolbar(container);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function doBatchAction(container, action, data) {
|
|
435
|
+
try {
|
|
436
|
+
const ids = Array.from(selectedFileIds);
|
|
437
|
+
const result = await api('/api/files/batch', {
|
|
438
|
+
method: 'POST',
|
|
439
|
+
body: { action, ids, data }
|
|
440
|
+
});
|
|
441
|
+
toast(`操作成功,影响 ${result.affected} 个文件`);
|
|
442
|
+
clearSelection(container);
|
|
443
|
+
loadFiles(container);
|
|
444
|
+
} catch (e) {
|
|
445
|
+
toast(e.message, 'error');
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function renderFileList(container, list, files) {
|
|
450
|
+
const selectAllCb = container.querySelector('#select-all-checkbox');
|
|
451
|
+
if (selectAllCb) {
|
|
452
|
+
selectAllCb.checked = false;
|
|
453
|
+
selectAllCb.onchange = () => {
|
|
454
|
+
const checked = selectAllCb.checked;
|
|
455
|
+
files.forEach(f => {
|
|
456
|
+
const cb = list.querySelector(`.file-checkbox[data-id="${f.id}"]`);
|
|
457
|
+
if (cb) {
|
|
458
|
+
cb.checked = checked;
|
|
459
|
+
toggleFileCheckbox(f.id, cb.closest('.file-item'), checked);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
files.forEach((f, index) => {
|
|
466
|
+
const el = document.createElement('div');
|
|
467
|
+
el.className = 'file-item';
|
|
468
|
+
el.dataset.fileId = f.id;
|
|
469
|
+
if (selectedFileIds.has(f.id)) el.classList.add('selected');
|
|
470
|
+
const size = formatSize(f.size);
|
|
471
|
+
const timeStr = relativeTime(f.updated_at || f.created_at);
|
|
472
|
+
const iconClass = f.is_bundle ? 'zip' : (f.file_type === 'markdown' ? 'md' : 'html');
|
|
473
|
+
const iconText = f.is_bundle ? 'ZIP' : (f.file_type === 'markdown' ? 'MD' : 'HTML');
|
|
474
|
+
const safeName = escapeHtml(f.original_name);
|
|
475
|
+
const isPublic = !!f.is_public;
|
|
476
|
+
const typeBadge = `<span class="file-badge file-badge-type">${iconText}</span>`;
|
|
477
|
+
const privacyBadge = isPublic
|
|
478
|
+
? '<span class="file-badge file-badge-public">公开</span>'
|
|
479
|
+
: '<span class="file-badge file-badge-private">私有</span>';
|
|
480
|
+
const versionBadge = f.version_count > 0
|
|
481
|
+
? `<span class="file-badge file-badge-version">v${f.version_count + 1}</span>`
|
|
482
|
+
: '';
|
|
483
|
+
const tagBadges = (f.tags || []).map(t =>
|
|
484
|
+
`<span class="file-badge file-badge-tag" data-tag-id="${t.id}">${escapeHtml(t.name)}</span>`
|
|
485
|
+
).join('');
|
|
486
|
+
const categoryBadge = f.category_name
|
|
487
|
+
? `<span class="file-badge file-badge-category">${escapeHtml(f.category_name)}</span>`
|
|
488
|
+
: '';
|
|
489
|
+
const viewBadge = (f.view_count > 0) ? `<span class="file-badge file-badge-views">👁 ${f.view_count}</span>` : '';
|
|
490
|
+
const snippetHtml = f.snippet ? `<div class="file-snippet">${f.snippet}</div>` : '';
|
|
491
|
+
|
|
492
|
+
el.innerHTML = `
|
|
493
|
+
<label class="file-checkbox-wrap">
|
|
494
|
+
<input type="checkbox" class="file-checkbox" data-id="${f.id}" data-index="${index}">
|
|
495
|
+
<span class="file-checkbox-visual"></span>
|
|
496
|
+
</label>
|
|
497
|
+
<div class="file-info" data-id="${f.id}" role="button" tabindex="0">
|
|
498
|
+
<div class="file-icon ${iconClass}" aria-hidden="true">${iconText}</div>
|
|
499
|
+
<div class="file-meta">
|
|
500
|
+
<div class="file-name">${safeName}</div>
|
|
501
|
+
${snippetHtml}
|
|
502
|
+
<div class="file-subline">${typeBadge}${privacyBadge}${versionBadge}${tagBadges}${categoryBadge}${viewBadge}<span class="file-detail">${size} · ${timeStr}</span></div>
|
|
503
|
+
</div>
|
|
504
|
+
</div>
|
|
505
|
+
<div class="file-actions">
|
|
506
|
+
<button type="button" class="btn btn-small btn-star ${f.starred ? 'starred' : ''}" data-id="${f.id}">${f.starred ? '★' : '☆'}</button>
|
|
507
|
+
<button type="button" class="btn btn-small btn-copy-link" data-id="${f.id}">复制链接</button>
|
|
508
|
+
<div class="file-more-dropdown">
|
|
509
|
+
<button type="button" class="btn btn-small file-more-trigger" title="更多操作">⋯</button>
|
|
510
|
+
<div class="file-more-menu">
|
|
511
|
+
<button type="button" class="file-more-item btn-privacy" data-id="${f.id}" data-public="${isPublic}">${isPublic ? '设为私有' : '设为公开'}</button>
|
|
512
|
+
<button type="button" class="file-more-item btn-tags" data-id="${f.id}">编辑标签</button>
|
|
513
|
+
<button type="button" class="file-more-item btn-category" data-id="${f.id}">移动分类</button>
|
|
514
|
+
${f.file_type === 'markdown' ? `<button type="button" class="file-more-item btn-template" data-id="${f.id}">切换模板</button>` : ''}
|
|
515
|
+
<button type="button" class="file-more-item btn-rename" data-id="${f.id}">重命名</button>
|
|
516
|
+
<button type="button" class="file-more-item btn-download" data-id="${f.id}">下载</button>
|
|
517
|
+
<hr class="file-more-divider">
|
|
518
|
+
<button type="button" class="file-more-item file-more-danger btn-delete" data-id="${f.id}">删除</button>
|
|
519
|
+
</div>
|
|
520
|
+
</div>
|
|
521
|
+
</div>
|
|
522
|
+
`;
|
|
523
|
+
|
|
524
|
+
// checkbox 事件(含 Shift 连选)
|
|
525
|
+
const cb = el.querySelector('.file-checkbox');
|
|
526
|
+
cb.addEventListener('click', e => e.stopPropagation());
|
|
527
|
+
cb.addEventListener('change', e => {
|
|
528
|
+
if (e.shiftKey && lastCheckedIndex >= 0) {
|
|
529
|
+
const start = Math.min(lastCheckedIndex, index);
|
|
530
|
+
const end = Math.max(lastCheckedIndex, index);
|
|
531
|
+
for (let i = start; i <= end; i++) {
|
|
532
|
+
const targetCb = list.querySelector(`.file-checkbox[data-index="${i}"]`);
|
|
533
|
+
if (targetCb) {
|
|
534
|
+
targetCb.checked = cb.checked;
|
|
535
|
+
toggleFileCheckbox(parseInt(targetCb.dataset.id), targetCb.closest('.file-item'), cb.checked);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
} else {
|
|
539
|
+
toggleFileCheckbox(f.id, el, cb.checked);
|
|
540
|
+
}
|
|
541
|
+
if (cb.checked) lastCheckedIndex = index;
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
const info = el.querySelector('.file-info');
|
|
545
|
+
info.setAttribute('aria-label', `打开 ${f.original_name}`);
|
|
546
|
+
const openPreview = () => navigate('/view/' + f.id);
|
|
547
|
+
info.addEventListener('click', openPreview);
|
|
548
|
+
info.addEventListener('keydown', e => {
|
|
549
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
550
|
+
e.preventDefault();
|
|
551
|
+
openPreview();
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
el.querySelector('.btn-copy-link').addEventListener('click', e => {
|
|
555
|
+
e.stopPropagation();
|
|
556
|
+
doCopyLink(f.share_key);
|
|
557
|
+
});
|
|
558
|
+
// 更多菜单展开/收起
|
|
559
|
+
const moreDropdown = el.querySelector('.file-more-dropdown');
|
|
560
|
+
const moreTrigger = el.querySelector('.file-more-trigger');
|
|
561
|
+
moreTrigger.addEventListener('click', e => {
|
|
562
|
+
e.stopPropagation();
|
|
563
|
+
// 关闭其他已打开的菜单
|
|
564
|
+
document.querySelectorAll('.file-more-dropdown.open').forEach(d => {
|
|
565
|
+
if (d !== moreDropdown) {
|
|
566
|
+
d.classList.remove('open');
|
|
567
|
+
const t = d.querySelector('.file-more-trigger');
|
|
568
|
+
if (t) t.setAttribute('aria-expanded', 'false');
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
const isOpen = moreDropdown.classList.toggle('open');
|
|
572
|
+
moreTrigger.setAttribute('aria-expanded', String(isOpen));
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
el.querySelector('.btn-privacy').addEventListener('click', e => {
|
|
576
|
+
e.stopPropagation();
|
|
577
|
+
moreDropdown.classList.remove('open');
|
|
578
|
+
doSetPrivacy(container, f.id, isPublic);
|
|
579
|
+
});
|
|
580
|
+
el.querySelector('.btn-rename').addEventListener('click', e => {
|
|
581
|
+
e.stopPropagation();
|
|
582
|
+
moreDropdown.classList.remove('open');
|
|
583
|
+
doRename(container, f.id, f.original_name);
|
|
584
|
+
});
|
|
585
|
+
el.querySelector('.btn-download').addEventListener('click', e => {
|
|
586
|
+
e.stopPropagation();
|
|
587
|
+
moreDropdown.classList.remove('open');
|
|
588
|
+
window.open(API_BASE + '/api/files/' + f.id + '/download', '_blank');
|
|
589
|
+
});
|
|
590
|
+
el.querySelector('.btn-delete').addEventListener('click', e => {
|
|
591
|
+
e.stopPropagation();
|
|
592
|
+
moreDropdown.classList.remove('open');
|
|
593
|
+
doDelete(container, f.id, f.original_name);
|
|
594
|
+
});
|
|
595
|
+
el.querySelector('.btn-star').addEventListener('click', async e => {
|
|
596
|
+
e.stopPropagation();
|
|
597
|
+
await toggleStar(f.id, f.starred);
|
|
598
|
+
loadFiles(container);
|
|
599
|
+
});
|
|
600
|
+
el.querySelectorAll('.file-badge-tag').forEach(badge => {
|
|
601
|
+
badge.addEventListener('click', e => {
|
|
602
|
+
e.stopPropagation();
|
|
603
|
+
filterState.tagId = parseInt(badge.dataset.tagId);
|
|
604
|
+
renderFilterDropdowns(container);
|
|
605
|
+
loadFiles(container, 1);
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
el.querySelector('.btn-tags').addEventListener('click', e => {
|
|
609
|
+
e.stopPropagation();
|
|
610
|
+
moreDropdown.classList.remove('open');
|
|
611
|
+
openTagEditor(container, f.id, f.tags);
|
|
612
|
+
});
|
|
613
|
+
el.querySelector('.btn-category').addEventListener('click', e => {
|
|
614
|
+
e.stopPropagation();
|
|
615
|
+
moreDropdown.classList.remove('open');
|
|
616
|
+
openCategorySelect(container, f.id, f.category_id);
|
|
617
|
+
});
|
|
618
|
+
const btnTpl = el.querySelector('.btn-template');
|
|
619
|
+
if (btnTpl) {
|
|
620
|
+
btnTpl.addEventListener('click', e => {
|
|
621
|
+
e.stopPropagation();
|
|
622
|
+
moreDropdown.classList.remove('open');
|
|
623
|
+
openTemplateSelect(container, f.id, f.template_id);
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
list.appendChild(el);
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function renderPagination(container) {
|
|
631
|
+
let wrap = container.querySelector('#pagination');
|
|
632
|
+
if (!wrap) {
|
|
633
|
+
wrap = document.createElement('div');
|
|
634
|
+
wrap.id = 'pagination';
|
|
635
|
+
wrap.className = 'pagination';
|
|
636
|
+
container.querySelector('#file-list').after(wrap);
|
|
637
|
+
}
|
|
638
|
+
wrap.innerHTML = '';
|
|
639
|
+
|
|
640
|
+
if (pagination.totalPages <= 1) {
|
|
641
|
+
wrap.style.display = 'none';
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
wrap.style.display = '';
|
|
645
|
+
|
|
646
|
+
const { page, totalPages } = pagination;
|
|
647
|
+
|
|
648
|
+
const prevBtn = document.createElement('button');
|
|
649
|
+
prevBtn.type = 'button';
|
|
650
|
+
prevBtn.className = 'pagination-btn';
|
|
651
|
+
prevBtn.textContent = '上一页';
|
|
652
|
+
prevBtn.disabled = page <= 1;
|
|
653
|
+
prevBtn.addEventListener('click', () => loadFiles(container, page - 1));
|
|
654
|
+
wrap.appendChild(prevBtn);
|
|
655
|
+
|
|
656
|
+
const pageNumbers = buildPageNumbers(page, totalPages);
|
|
657
|
+
pageNumbers.forEach(p => {
|
|
658
|
+
if (p === '...') {
|
|
659
|
+
const span = document.createElement('span');
|
|
660
|
+
span.className = 'pagination-ellipsis';
|
|
661
|
+
span.textContent = '...';
|
|
662
|
+
wrap.appendChild(span);
|
|
663
|
+
} else {
|
|
664
|
+
const btn = document.createElement('button');
|
|
665
|
+
btn.type = 'button';
|
|
666
|
+
btn.className = 'pagination-btn' + (p === page ? ' active' : '');
|
|
667
|
+
btn.textContent = p;
|
|
668
|
+
btn.addEventListener('click', () => loadFiles(container, p));
|
|
669
|
+
wrap.appendChild(btn);
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
const nextBtn = document.createElement('button');
|
|
674
|
+
nextBtn.type = 'button';
|
|
675
|
+
nextBtn.className = 'pagination-btn';
|
|
676
|
+
nextBtn.textContent = '下一页';
|
|
677
|
+
nextBtn.disabled = page >= totalPages;
|
|
678
|
+
nextBtn.addEventListener('click', () => loadFiles(container, page + 1));
|
|
679
|
+
wrap.appendChild(nextBtn);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function buildPageNumbers(current, total) {
|
|
683
|
+
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
|
684
|
+
const pages = [];
|
|
685
|
+
if (current <= 4) {
|
|
686
|
+
for (let i = 1; i <= 5; i++) pages.push(i);
|
|
687
|
+
pages.push('...', total);
|
|
688
|
+
} else if (current >= total - 3) {
|
|
689
|
+
pages.push(1, '...');
|
|
690
|
+
for (let i = total - 4; i <= total; i++) pages.push(i);
|
|
691
|
+
} else {
|
|
692
|
+
pages.push(1, '...', current - 1, current, current + 1, '...', total);
|
|
693
|
+
}
|
|
694
|
+
return pages;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function setupFileFilter(container) {
|
|
698
|
+
const searchInput = container.querySelector('#search-input');
|
|
699
|
+
const searchClear = container.querySelector('#search-clear');
|
|
700
|
+
const searchKbd = container.querySelector('#search-kbd');
|
|
701
|
+
const chips = container.querySelectorAll('.filter-chip');
|
|
702
|
+
let searchTimer;
|
|
703
|
+
|
|
704
|
+
searchInput.addEventListener('input', () => {
|
|
705
|
+
filterState.query = searchInput.value.trim();
|
|
706
|
+
searchClear.hidden = !filterState.query;
|
|
707
|
+
if (searchKbd) searchKbd.style.display = filterState.query ? 'none' : '';
|
|
708
|
+
clearTimeout(searchTimer);
|
|
709
|
+
searchTimer = setTimeout(() => loadFiles(container, 1), 300);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
searchInput.addEventListener('focus', () => {
|
|
713
|
+
if (searchKbd) searchKbd.style.display = 'none';
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
searchInput.addEventListener('blur', () => {
|
|
717
|
+
if (searchKbd && !filterState.query) searchKbd.style.display = '';
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
searchInput.addEventListener('keydown', e => {
|
|
721
|
+
if (e.key === 'Escape') {
|
|
722
|
+
searchInput.value = '';
|
|
723
|
+
filterState.query = '';
|
|
724
|
+
searchClear.hidden = true;
|
|
725
|
+
if (searchKbd) searchKbd.style.display = '';
|
|
726
|
+
searchInput.blur();
|
|
727
|
+
clearTimeout(searchTimer);
|
|
728
|
+
loadFiles(container, 1);
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
searchClear.addEventListener('click', () => {
|
|
733
|
+
searchInput.value = '';
|
|
734
|
+
filterState.query = '';
|
|
735
|
+
searchClear.hidden = true;
|
|
736
|
+
if (searchKbd) searchKbd.style.display = '';
|
|
737
|
+
searchInput.focus();
|
|
738
|
+
clearTimeout(searchTimer);
|
|
739
|
+
loadFiles(container, 1);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
chips.forEach(chip => {
|
|
743
|
+
chip.addEventListener('click', () => {
|
|
744
|
+
chips.forEach(c => c.classList.remove('active'));
|
|
745
|
+
chip.classList.add('active');
|
|
746
|
+
filterState.filter = chip.dataset.filter;
|
|
747
|
+
if (chip.dataset.filter === 'all') {
|
|
748
|
+
filterState.tagId = null;
|
|
749
|
+
filterState.categoryId = null;
|
|
750
|
+
}
|
|
751
|
+
applyFilters(container);
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
container.querySelectorAll('.filter-dropdown').forEach(dd => {
|
|
756
|
+
const trigger = dd.querySelector('.filter-dropdown-trigger');
|
|
757
|
+
if (trigger) {
|
|
758
|
+
trigger.addEventListener('click', e => {
|
|
759
|
+
e.stopPropagation();
|
|
760
|
+
container.querySelectorAll('.filter-dropdown.open').forEach(d => { if (d !== dd) d.classList.remove('open'); });
|
|
761
|
+
dd.classList.toggle('open');
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
document.addEventListener('keydown', e => {
|
|
767
|
+
if (e.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) {
|
|
768
|
+
e.preventDefault();
|
|
769
|
+
searchInput.focus();
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
async function doCopyLink(shareKey) {
|
|
775
|
+
const url = `${location.origin}/s/${shareKey}`;
|
|
776
|
+
try {
|
|
777
|
+
await navigator.clipboard.writeText(url);
|
|
778
|
+
toast('链接已复制');
|
|
779
|
+
} catch (_) {
|
|
780
|
+
try {
|
|
781
|
+
const input = document.createElement('input');
|
|
782
|
+
input.value = url;
|
|
783
|
+
document.body.appendChild(input);
|
|
784
|
+
input.select();
|
|
785
|
+
document.execCommand('copy');
|
|
786
|
+
input.remove();
|
|
787
|
+
toast('链接已复制');
|
|
788
|
+
} catch (e) {
|
|
789
|
+
toast('复制失败,请手动复制链接', 'error');
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
async function doRename(container, id, currentName) {
|
|
795
|
+
const name = await dialogModal.prompt({
|
|
796
|
+
title: '重命名文件',
|
|
797
|
+
label: '文件名',
|
|
798
|
+
value: currentName,
|
|
799
|
+
validate: v => {
|
|
800
|
+
if (!v.trim()) return '文件名不能为空';
|
|
801
|
+
if (/[\/\\]/.test(v)) return '文件名不能包含 / 或 \\';
|
|
802
|
+
return null;
|
|
803
|
+
},
|
|
804
|
+
});
|
|
805
|
+
if (name === null || name === currentName) return;
|
|
806
|
+
try {
|
|
807
|
+
await api(`/api/files/${id}`, { method: 'PUT', body: { name } });
|
|
808
|
+
toast('重命名成功');
|
|
809
|
+
loadFiles(container);
|
|
810
|
+
} catch (e) {
|
|
811
|
+
toast(e.message, 'error');
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async function doSetPrivacy(container, id, currentPublic) {
|
|
816
|
+
try {
|
|
817
|
+
await api(`/api/files/${id}`, {
|
|
818
|
+
method: 'PUT',
|
|
819
|
+
body: { isPublic: !currentPublic }
|
|
820
|
+
});
|
|
821
|
+
toast(currentPublic ? '已设为私有' : '已设为公开');
|
|
822
|
+
loadFiles(container);
|
|
823
|
+
} catch (e) {
|
|
824
|
+
toast(e.message, 'error');
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async function doDelete(container, id, fileName) {
|
|
829
|
+
const ok = await dialogModal.confirm({
|
|
830
|
+
title: '确认删除',
|
|
831
|
+
message: `确定要删除 <strong>${escapeHtml(fileName)}</strong> 吗?此操作不可撤销。`,
|
|
832
|
+
confirmText: '删除',
|
|
833
|
+
danger: true,
|
|
834
|
+
});
|
|
835
|
+
if (!ok) return;
|
|
836
|
+
try {
|
|
837
|
+
await api(`/api/files/${id}`, { method: 'DELETE' });
|
|
838
|
+
toast('删除成功');
|
|
839
|
+
loadFiles(container);
|
|
840
|
+
} catch (e) {
|
|
841
|
+
toast(e.message, 'error');
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// ---------- Skills ----------
|
|
846
|
+
function setupSkillModal() {
|
|
847
|
+
const modal = document.getElementById('skill-modal');
|
|
848
|
+
if (!modal || modal.dataset.bound) return;
|
|
849
|
+
modal.dataset.bound = '1';
|
|
850
|
+
modal.querySelector('#skill-modal-close').addEventListener('click', closeSkillModal);
|
|
851
|
+
modal.querySelector('#skill-modal-dismiss').addEventListener('click', closeSkillModal);
|
|
852
|
+
modal.addEventListener('click', e => { if (e.target === modal) closeSkillModal(); });
|
|
853
|
+
document.addEventListener('keydown', e => {
|
|
854
|
+
if (e.key === 'Escape' && !modal.hidden) closeSkillModal();
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function openSkillModal(name) {
|
|
859
|
+
api('/api/skills/' + encodeURIComponent(name)).then(skill => {
|
|
860
|
+
skillModalCurrent = skill.name;
|
|
861
|
+
document.getElementById('skill-modal-title').textContent = skill.title || skill.name;
|
|
862
|
+
const meta = document.getElementById('skill-modal-meta');
|
|
863
|
+
meta.innerHTML = `
|
|
864
|
+
<div><strong>名称:</strong>${escapeHtml(skill.title || skill.name)}</div>
|
|
865
|
+
<div><strong>目录:</strong><code>${escapeHtml(skill.name)}</code></div>
|
|
866
|
+
${skill.version ? `<div><strong>版本:</strong>${escapeHtml(skill.version)}</div>` : ''}
|
|
867
|
+
${skill.author ? `<div><strong>作者:</strong>${escapeHtml(skill.author)}</div>` : ''}
|
|
868
|
+
<div><strong>文件数:</strong>${skill.fileCount} · <strong>大小:</strong>${formatSize(skill.totalSize)}</div>
|
|
869
|
+
${skill.description ? `<div><strong>描述:</strong>${escapeHtml(skill.description)}</div>` : ''}
|
|
870
|
+
`;
|
|
871
|
+
const files = document.getElementById('skill-modal-files');
|
|
872
|
+
files.innerHTML = skill.files.map(f => `<li>${escapeHtml(f)}</li>`).join('');
|
|
873
|
+
document.getElementById('skill-modal-source').textContent = skill.body || '(SKILL.md 正文为空)';
|
|
874
|
+
const installBox = document.getElementById('skill-install-rendered');
|
|
875
|
+
const installHeading = document.getElementById('skill-install-heading');
|
|
876
|
+
if (skill.installHtml || (skill.installBody && skill.installBody.trim())) {
|
|
877
|
+
installHeading.style.display = '';
|
|
878
|
+
installBox.innerHTML = skill.installHtml || renderMarkdown(skill.installBody);
|
|
879
|
+
} else {
|
|
880
|
+
installHeading.style.display = 'none';
|
|
881
|
+
installBox.innerHTML = '';
|
|
882
|
+
}
|
|
883
|
+
const modal = document.getElementById('skill-modal');
|
|
884
|
+
modal.hidden = false;
|
|
885
|
+
modal.setAttribute('aria-hidden', 'false');
|
|
886
|
+
}).catch(e => toast(e.message, 'error'));
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function renderMarkdown(md) {
|
|
890
|
+
const lines = md.split(/\r?\n/);
|
|
891
|
+
const out = [];
|
|
892
|
+
let i = 0;
|
|
893
|
+
while (i < lines.length) {
|
|
894
|
+
const line = lines[i];
|
|
895
|
+
if (line.startsWith('```')) {
|
|
896
|
+
const code = [];
|
|
897
|
+
i++;
|
|
898
|
+
while (i < lines.length && !lines[i].startsWith('```')) {
|
|
899
|
+
code.push(escapeHtml(lines[i]));
|
|
900
|
+
i++;
|
|
901
|
+
}
|
|
902
|
+
i++;
|
|
903
|
+
out.push(`<pre><code>${code.join('\n')}</code></pre>`);
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
const h = line.match(/^(#{1,4})\s+(.*)$/);
|
|
907
|
+
if (h) {
|
|
908
|
+
const level = h[1].length + 2;
|
|
909
|
+
out.push(`<h${level}>${inlineMd(escapeHtml(h[2]))}</h${level}>`);
|
|
910
|
+
i++;
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
const ul = line.match(/^[-*]\s+(.*)$/);
|
|
914
|
+
if (ul) {
|
|
915
|
+
const items = [];
|
|
916
|
+
while (i < lines.length && lines[i].match(/^[-*]\s+/)) {
|
|
917
|
+
items.push(`<li>${inlineMd(escapeHtml(lines[i].replace(/^[-*]\s+/, '')))}</li>`);
|
|
918
|
+
i++;
|
|
919
|
+
}
|
|
920
|
+
out.push(`<ul>${items.join('')}</ul>`);
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
const ol = line.match(/^(\d+)\.\s+(.*)$/);
|
|
924
|
+
if (ol) {
|
|
925
|
+
const items = [];
|
|
926
|
+
while (i < lines.length && lines[i].match(/^\d+\.\s+/)) {
|
|
927
|
+
items.push(`<li>${inlineMd(escapeHtml(lines[i].replace(/^\d+\.\s+/, '')))}</li>`);
|
|
928
|
+
i++;
|
|
929
|
+
}
|
|
930
|
+
out.push(`<ol>${items.join('')}</ol>`);
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
if (line.trim() === '') { i++; continue; }
|
|
934
|
+
const para = [];
|
|
935
|
+
while (i < lines.length && lines[i].trim() !== ''
|
|
936
|
+
&& !lines[i].match(/^(#{1,4}\s|[-*]\s|\d+\.\s|```)/)) {
|
|
937
|
+
para.push(lines[i]);
|
|
938
|
+
i++;
|
|
939
|
+
}
|
|
940
|
+
out.push(`<p>${inlineMd(escapeHtml(para.join(' ')))}</p>`);
|
|
941
|
+
}
|
|
942
|
+
return out.join('');
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function inlineMd(text) {
|
|
946
|
+
return text
|
|
947
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
948
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
949
|
+
.replace(/(https?:\/\/[^\s<]+)/g, '<code>$1</code>');
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function closeSkillModal() {
|
|
953
|
+
const modal = document.getElementById('skill-modal');
|
|
954
|
+
if (!modal) return;
|
|
955
|
+
modal.hidden = true;
|
|
956
|
+
modal.setAttribute('aria-hidden', 'true');
|
|
957
|
+
skillModalCurrent = null;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function downloadSkill(name) {
|
|
961
|
+
const w = window.open(API_BASE + '/api/skills/' + encodeURIComponent(name) + '/download', '_blank');
|
|
962
|
+
if (w) w.opener = null;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
document.addEventListener('click', e => {
|
|
966
|
+
const btn = e.target.closest && e.target.closest('#skill-modal-download');
|
|
967
|
+
if (!btn) return;
|
|
968
|
+
if (skillModalCurrent) downloadSkill(skillModalCurrent);
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
// ---------- MCP Config Modal ----------
|
|
972
|
+
function openMcpConfigModal() {
|
|
973
|
+
const modal = document.getElementById('mcp-config-modal');
|
|
974
|
+
if (!modal) return;
|
|
975
|
+
|
|
976
|
+
// 绑定关闭事件
|
|
977
|
+
if (!modal.dataset.bound) {
|
|
978
|
+
modal.dataset.bound = '1';
|
|
979
|
+
modal.querySelector('#mcp-config-close').addEventListener('click', closeMcpConfigModal);
|
|
980
|
+
modal.querySelector('#mcp-config-dismiss').addEventListener('click', closeMcpConfigModal);
|
|
981
|
+
modal.addEventListener('click', e => { if (e.target === modal) closeMcpConfigModal(); });
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// 加载配置
|
|
985
|
+
const statusEl = document.getElementById('mcp-status');
|
|
986
|
+
const detailEl = document.getElementById('mcp-detail');
|
|
987
|
+
statusEl.innerHTML = '<p style="color:var(--text-secondary)">加载中…</p>';
|
|
988
|
+
detailEl.innerHTML = '';
|
|
989
|
+
|
|
990
|
+
api('/api/mcp/config').then(data => {
|
|
991
|
+
if (data.enabled) {
|
|
992
|
+
statusEl.innerHTML = `
|
|
993
|
+
<div class="mcp-status-badge mcp-status-on">
|
|
994
|
+
<span class="mcp-status-dot"></span> MCP 已启用
|
|
995
|
+
</div>
|
|
996
|
+
<div class="mcp-info-row">
|
|
997
|
+
<span class="mcp-label">Endpoint</span>
|
|
998
|
+
<code class="mcp-value">${escapeHtml(data.url)}</code>
|
|
999
|
+
</div>
|
|
1000
|
+
${data.globalToken ? `<div class="mcp-info-row">
|
|
1001
|
+
<span class="mcp-label">全局 Token</span>
|
|
1002
|
+
<code class="mcp-value">${escapeHtml(data.globalToken)}</code>
|
|
1003
|
+
</div>` : ''}
|
|
1004
|
+
${data.tokens && data.tokens.length > 0 ? `<div class="mcp-info-row">
|
|
1005
|
+
<span class="mcp-label">用户 Token</span>
|
|
1006
|
+
<code class="mcp-value">${data.tokens.map(t => esc(t.token_prefix) + '…').join(', ')}</code>
|
|
1007
|
+
</div>` : ''}
|
|
1008
|
+
`;
|
|
1009
|
+
// 多客户端 Tab:共用同一份标准 JSON,差异仅在目标文件路径/说明文字
|
|
1010
|
+
const configs = (data.configs && data.configs.length > 0)
|
|
1011
|
+
? data.configs
|
|
1012
|
+
: [{ id: 'generic', label: '通用', path: '', config: data.config }];
|
|
1013
|
+
let activeConfigJson = JSON.stringify(configs[0].config, null, 2);
|
|
1014
|
+
detailEl.innerHTML = `
|
|
1015
|
+
<h3>客户端配置</h3>
|
|
1016
|
+
<p class="mcp-config-hint">选择客户端,复制配置粘贴到对应文件中。请根据实际部署环境调整 URL。</p>
|
|
1017
|
+
<div class="mcp-tabs" role="tablist">
|
|
1018
|
+
${configs.map((c, i) => `
|
|
1019
|
+
<button type="button" class="mcp-tab${i === 0 ? ' active' : ''}" role="tab"
|
|
1020
|
+
data-idx="${i}" id="mcp-tab-${escapeHtml(c.id)}">${escapeHtml(c.label)}</button>
|
|
1021
|
+
`).join('')}
|
|
1022
|
+
</div>
|
|
1023
|
+
<p class="mcp-config-hint mcp-config-path"><code></code></p>
|
|
1024
|
+
<div class="mcp-config-block">
|
|
1025
|
+
<button type="button" class="btn btn-small mcp-copy-btn" id="mcp-copy-config">复制</button>
|
|
1026
|
+
<pre class="mcp-config-code"><code>${escapeHtml(activeConfigJson)}</code></pre>
|
|
1027
|
+
</div>
|
|
1028
|
+
`;
|
|
1029
|
+
const codeEl = detailEl.querySelector('.mcp-config-code code');
|
|
1030
|
+
const pathEl = detailEl.querySelector('.mcp-config-path code');
|
|
1031
|
+
const setConfig = (idx) => {
|
|
1032
|
+
const c = configs[idx];
|
|
1033
|
+
if (!c) return;
|
|
1034
|
+
activeConfigJson = JSON.stringify(c.config, null, 2);
|
|
1035
|
+
codeEl.textContent = activeConfigJson;
|
|
1036
|
+
pathEl.textContent = c.path || '';
|
|
1037
|
+
detailEl.querySelectorAll('.mcp-tab').forEach((t, i) => {
|
|
1038
|
+
t.classList.toggle('active', i === idx);
|
|
1039
|
+
});
|
|
1040
|
+
};
|
|
1041
|
+
setConfig(0);
|
|
1042
|
+
detailEl.querySelectorAll('.mcp-tab').forEach((t, i) => {
|
|
1043
|
+
t.addEventListener('click', () => setConfig(i));
|
|
1044
|
+
});
|
|
1045
|
+
const copyBtn = document.getElementById('mcp-copy-config');
|
|
1046
|
+
if (copyBtn) {
|
|
1047
|
+
copyBtn.addEventListener('click', () => {
|
|
1048
|
+
navigator.clipboard.writeText(activeConfigJson).then(() => {
|
|
1049
|
+
toast('已复制到剪贴板');
|
|
1050
|
+
copyBtn.textContent = '已复制';
|
|
1051
|
+
setTimeout(() => { copyBtn.textContent = '复制'; }, 2000);
|
|
1052
|
+
}).catch(() => toast('复制失败', 'error'));
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
} else {
|
|
1056
|
+
statusEl.innerHTML = `
|
|
1057
|
+
<div class="mcp-status-badge mcp-status-off">
|
|
1058
|
+
<span class="mcp-status-dot"></span> MCP 未启用
|
|
1059
|
+
</div>
|
|
1060
|
+
<p class="mcp-config-hint">设置环境变量 <code>MCP_TOKEN</code> 后重启服务即可启用 MCP 端点。</p>
|
|
1061
|
+
<div class="mcp-config-block">
|
|
1062
|
+
<pre class="mcp-config-code"><code>MCP_TOKEN=your-secret-token npm start</code></pre>
|
|
1063
|
+
</div>
|
|
1064
|
+
`;
|
|
1065
|
+
}
|
|
1066
|
+
modal.hidden = false;
|
|
1067
|
+
modal.setAttribute('aria-hidden', 'false');
|
|
1068
|
+
}).catch(e => {
|
|
1069
|
+
statusEl.innerHTML = `<p style="color:var(--danger)">加载失败: ${escapeHtml(e.message)}</p>`;
|
|
1070
|
+
modal.hidden = false;
|
|
1071
|
+
modal.setAttribute('aria-hidden', 'false');
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function closeMcpConfigModal() {
|
|
1076
|
+
const modal = document.getElementById('mcp-config-modal');
|
|
1077
|
+
if (!modal) return;
|
|
1078
|
+
modal.hidden = true;
|
|
1079
|
+
modal.setAttribute('aria-hidden', 'true');
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// ---------- Skills List Modal ----------
|
|
1083
|
+
function openSkillsListModal() {
|
|
1084
|
+
const modal = document.getElementById('skills-list-modal');
|
|
1085
|
+
if (!modal) return;
|
|
1086
|
+
|
|
1087
|
+
// 绑定关闭事件
|
|
1088
|
+
if (!modal.dataset.bound) {
|
|
1089
|
+
modal.dataset.bound = '1';
|
|
1090
|
+
modal.querySelector('#skills-list-close').addEventListener('click', closeSkillsListModal);
|
|
1091
|
+
modal.querySelector('#skills-list-dismiss').addEventListener('click', closeSkillsListModal);
|
|
1092
|
+
modal.addEventListener('click', e => { if (e.target === modal) closeSkillsListModal(); });
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// 加载 skills
|
|
1096
|
+
loadSkillsForModal();
|
|
1097
|
+
modal.hidden = false;
|
|
1098
|
+
modal.setAttribute('aria-hidden', 'false');
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function closeSkillsListModal() {
|
|
1102
|
+
const modal = document.getElementById('skills-list-modal');
|
|
1103
|
+
if (!modal) return;
|
|
1104
|
+
modal.hidden = true;
|
|
1105
|
+
modal.setAttribute('aria-hidden', 'true');
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function loadSkillsForModal() {
|
|
1109
|
+
const list = document.getElementById('skills-list');
|
|
1110
|
+
const empty = document.getElementById('skills-empty');
|
|
1111
|
+
if (!list) return;
|
|
1112
|
+
list.setAttribute('aria-busy', 'true');
|
|
1113
|
+
list.classList.add('is-loading');
|
|
1114
|
+
list.innerHTML = buildSkeletonCards(5);
|
|
1115
|
+
empty.style.display = 'none';
|
|
1116
|
+
api('/api/skills').then(data => {
|
|
1117
|
+
list.classList.remove('is-loading');
|
|
1118
|
+
list.setAttribute('aria-busy', 'false');
|
|
1119
|
+
list.innerHTML = '';
|
|
1120
|
+
if (!data.skills.length) {
|
|
1121
|
+
empty.style.display = 'block';
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
empty.style.display = 'none';
|
|
1125
|
+
data.skills.forEach(s => {
|
|
1126
|
+
const el = document.createElement('div');
|
|
1127
|
+
el.className = 'skill-card';
|
|
1128
|
+
const desc = s.description || '(无描述)';
|
|
1129
|
+
el.innerHTML = `
|
|
1130
|
+
<div class="skill-card-info">
|
|
1131
|
+
<div class="skill-card-title">
|
|
1132
|
+
<span class="skill-name">${escapeHtml(s.title || s.name)}</span>
|
|
1133
|
+
<span class="skill-version">${escapeHtml(s.version || '')}</span>
|
|
1134
|
+
</div>
|
|
1135
|
+
<p class="skill-card-desc">${escapeHtml(desc)}</p>
|
|
1136
|
+
<div class="skill-card-meta">${s.fileCount} 个文件 · ${formatSize(s.totalSize)}</div>
|
|
1137
|
+
</div>
|
|
1138
|
+
<div class="skill-card-actions">
|
|
1139
|
+
<button type="button" class="btn btn-small skill-view" data-name="${escapeHtml(s.name)}">查看详情</button>
|
|
1140
|
+
<button type="button" class="btn btn-small skill-download" data-name="${escapeHtml(s.name)}">下载 .zip</button>
|
|
1141
|
+
</div>
|
|
1142
|
+
`;
|
|
1143
|
+
el.querySelector('.skill-view').addEventListener('click', () => {
|
|
1144
|
+
closeSkillsListModal();
|
|
1145
|
+
openSkillModal(s.name);
|
|
1146
|
+
});
|
|
1147
|
+
el.querySelector('.skill-download').addEventListener('click', () => downloadSkill(s.name));
|
|
1148
|
+
list.appendChild(el);
|
|
1149
|
+
});
|
|
1150
|
+
}).catch(e => {
|
|
1151
|
+
list.classList.remove('is-loading');
|
|
1152
|
+
list.setAttribute('aria-busy', 'false');
|
|
1153
|
+
list.innerHTML = '';
|
|
1154
|
+
if (e.status === 401) {
|
|
1155
|
+
state.currentUser = null;
|
|
1156
|
+
navigate('/');
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
toast(e.message, 'error');
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// ---------- 用户管理弹窗 ----------
|
|
1164
|
+
function openUsersModal() {
|
|
1165
|
+
const modal = document.getElementById('users-modal');
|
|
1166
|
+
openModal(modal);
|
|
1167
|
+
loadUsersList();
|
|
1168
|
+
modal.querySelector('#users-modal-close').onclick = () => { closeModal(modal); };
|
|
1169
|
+
modal.querySelector('#users-modal-dismiss').onclick = () => { closeModal(modal); };
|
|
1170
|
+
modal.querySelector('#btn-create-user').onclick = () => createUserDialog();
|
|
1171
|
+
if (!modal.dataset.bound) { modal.dataset.bound = '1'; modal.addEventListener('click', e => { if (e.target === modal) closeModal(modal); }); }
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
async function loadUsersList() {
|
|
1175
|
+
const wrap = document.getElementById('users-table-wrap');
|
|
1176
|
+
try {
|
|
1177
|
+
const data = await api('/api/users');
|
|
1178
|
+
const users = data.users || [];
|
|
1179
|
+
wrap.innerHTML = '<table class="users-table"><thead><tr><th>ID</th><th>用户名</th><th>邮箱</th><th>角色</th><th>创建时间</th><th>操作</th></tr></thead><tbody>' +
|
|
1180
|
+
users.map(u => `<tr>
|
|
1181
|
+
<td>${u.id}</td>
|
|
1182
|
+
<td>${esc(u.username)}</td>
|
|
1183
|
+
<td>${u.email ? esc(u.email) : '<span style="color:var(--text-muted)">-</span>'}</td>
|
|
1184
|
+
<td><span class="role-badge role-${u.role}">${u.role === 'admin' ? '管理员' : '用户'}</span></td>
|
|
1185
|
+
<td>${formatDate(u.created_at)}</td>
|
|
1186
|
+
<td class="users-actions">
|
|
1187
|
+
<button class="btn btn-small btn-edit-user" data-id="${u.id}" data-username="${escapeHtml(u.username)}" data-role="${u.role}" data-email="${u.email ? escapeHtml(u.email) : ''}">编辑</button>
|
|
1188
|
+
${u.id !== state.currentUser.id ? `<button class="btn btn-small btn-danger-outline btn-delete-user" data-id="${u.id}" data-username="${escapeHtml(u.username)}">删除</button>` : ''}
|
|
1189
|
+
</td></tr>`).join('') +
|
|
1190
|
+
'</tbody></table>';
|
|
1191
|
+
wrap.querySelectorAll('.btn-edit-user').forEach(btn => {
|
|
1192
|
+
btn.addEventListener('click', () => editUserDialog(+btn.dataset.id, btn.dataset.username, btn.dataset.role, btn.dataset.email));
|
|
1193
|
+
});
|
|
1194
|
+
wrap.querySelectorAll('.btn-delete-user').forEach(btn => {
|
|
1195
|
+
btn.addEventListener('click', () => deleteUserConfirm(+btn.dataset.id, btn.dataset.username));
|
|
1196
|
+
});
|
|
1197
|
+
} catch (e) {
|
|
1198
|
+
wrap.innerHTML = '<p class="login-error">加载失败: ' + esc(e.message) + '</p>';
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
async function createUserDialog() {
|
|
1203
|
+
const username = await dialogModal.prompt({ title: '创建用户', label: '用户名', placeholder: '输入用户名' });
|
|
1204
|
+
if (!username) return;
|
|
1205
|
+
const email = await dialogModal.prompt({ title: '创建用户', label: '邮箱(可选)', placeholder: 'user@example.com' });
|
|
1206
|
+
const password = await dialogModal.prompt({ title: '创建用户', label: '密码(至少 8 位)', placeholder: '输入密码' });
|
|
1207
|
+
if (!password || password.length < 8) { if (password) toast('密码至少 8 位', 'error'); return; }
|
|
1208
|
+
const role = await dialogModal.prompt({ title: '创建用户', label: '角色 (admin/user)', value: 'user' });
|
|
1209
|
+
if (!role) return;
|
|
1210
|
+
try {
|
|
1211
|
+
const body = { username, password, role: role || 'user' };
|
|
1212
|
+
if (email) body.email = email;
|
|
1213
|
+
await api('/api/users', { method: 'POST', body });
|
|
1214
|
+
toast('用户已创建');
|
|
1215
|
+
loadUsersList();
|
|
1216
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// 需要挂到 window 上因为 users table 用了 inline onclick
|
|
1220
|
+
async function editUserDialog(id, username, role, email) {
|
|
1221
|
+
const ops = ['修改用户名/邮箱', '修改角色', '重置密码'];
|
|
1222
|
+
const choice = await dialogModal.confirm({
|
|
1223
|
+
title: '编辑用户: ' + username,
|
|
1224
|
+
message: '请选择操作',
|
|
1225
|
+
confirmText: '修改资料',
|
|
1226
|
+
cancelText: '更多操作…'
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
if (choice) {
|
|
1230
|
+
// 修改资料
|
|
1231
|
+
const newUsername = await dialogModal.prompt({ title: '修改用户名', label: '用户名', value: username });
|
|
1232
|
+
if (!newUsername) return;
|
|
1233
|
+
const newEmail = await dialogModal.prompt({ title: '修改邮箱', label: '邮箱(留空清除)', value: email || '' });
|
|
1234
|
+
try {
|
|
1235
|
+
await api('/api/users/' + id, { method: 'PUT', body: { username: newUsername, email: newEmail || '' } });
|
|
1236
|
+
toast('资料已更新');
|
|
1237
|
+
loadUsersList();
|
|
1238
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
1239
|
+
} else {
|
|
1240
|
+
// 更多操作:修改角色或重置密码
|
|
1241
|
+
const changeRole = await dialogModal.confirm({ title: '更多操作', message: '选择操作', confirmText: '修改角色', cancelText: '重置密码' });
|
|
1242
|
+
if (changeRole) {
|
|
1243
|
+
const newRole = await dialogModal.prompt({ title: '修改角色', label: '新角色 (admin/user)', value: role });
|
|
1244
|
+
if (!newRole) return;
|
|
1245
|
+
try {
|
|
1246
|
+
await api('/api/users/' + id, { method: 'PUT', body: { role: newRole } });
|
|
1247
|
+
toast('角色已更新');
|
|
1248
|
+
loadUsersList();
|
|
1249
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
1250
|
+
} else {
|
|
1251
|
+
const pwd = await dialogModal.prompt({ title: '重置密码', label: '新密码(至少 8 位)' });
|
|
1252
|
+
if (!pwd) return;
|
|
1253
|
+
if (pwd.length < 8) { toast('密码至少 8 位', 'error'); return; }
|
|
1254
|
+
try {
|
|
1255
|
+
await api('/api/users/' + id, { method: 'PUT', body: { password: pwd } });
|
|
1256
|
+
toast('密码已重置');
|
|
1257
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
};
|
|
1261
|
+
|
|
1262
|
+
async function deleteUserConfirm(id, username) {
|
|
1263
|
+
const ok = await dialogModal.confirm({ title: '删除用户', message: `确定删除用户 <strong>${escapeHtml(username)}</strong>?其文件将转交给管理员。`, danger: true });
|
|
1264
|
+
if (!ok) return;
|
|
1265
|
+
try {
|
|
1266
|
+
await api('/api/users/' + id, { method: 'DELETE' });
|
|
1267
|
+
toast('用户已删除');
|
|
1268
|
+
loadUsersList();
|
|
1269
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1272
|
+
// ---------- API 令牌弹窗 ----------
|
|
1273
|
+
function openTokensModal() {
|
|
1274
|
+
const modal = document.getElementById('tokens-modal');
|
|
1275
|
+
openModal(modal);
|
|
1276
|
+
loadTokensList();
|
|
1277
|
+
modal.querySelector('#tokens-modal-close').onclick = () => { closeModal(modal); };
|
|
1278
|
+
modal.querySelector('#tokens-modal-dismiss').onclick = () => { closeModal(modal); };
|
|
1279
|
+
modal.querySelector('#btn-create-token').onclick = () => createTokenDialog();
|
|
1280
|
+
if (!modal.dataset.bound) { modal.dataset.bound = '1'; modal.addEventListener('click', e => { if (e.target === modal) closeModal(modal); }); }
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
async function loadTokensList() {
|
|
1284
|
+
const listEl = document.getElementById('tokens-list');
|
|
1285
|
+
try {
|
|
1286
|
+
const data = await api('/api/tokens');
|
|
1287
|
+
const tokens = data.tokens || [];
|
|
1288
|
+
if (tokens.length === 0) {
|
|
1289
|
+
listEl.innerHTML = '<p class="modal-hint">暂无令牌,点击上方按钮创建。</p>';
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
listEl.innerHTML = tokens.map(t => `<div class="token-item">
|
|
1293
|
+
<div class="token-info">
|
|
1294
|
+
<strong>${esc(t.name)}</strong>
|
|
1295
|
+
<code class="token-prefix">${esc(t.token_prefix)}…</code>
|
|
1296
|
+
<span class="token-time">创建于 ${formatDate(t.created_at)}${t.last_used_at ? ' · 最后使用 ' + formatDate(t.last_used_at) : ''}</span>
|
|
1297
|
+
</div>
|
|
1298
|
+
<div class="token-actions">
|
|
1299
|
+
<button class="btn btn-small" data-token-reveal="${t.id}" data-token-name="${esc(t.name)}" ${t.viewable ? '' : 'disabled title="此令牌创建于功能启用前,无法查看,请删除后重建"'}>查看/复制</button>
|
|
1300
|
+
<button class="btn btn-small btn-danger-outline" data-token-id="${t.id}" data-token-name="${esc(t.name)}">删除</button>
|
|
1301
|
+
</div>
|
|
1302
|
+
</div>`).join('');
|
|
1303
|
+
listEl.querySelectorAll('[data-token-id]').forEach(btn => {
|
|
1304
|
+
btn.addEventListener('click', () => deleteTokenConfirm(parseInt(btn.dataset.tokenId), btn.dataset.tokenName));
|
|
1305
|
+
});
|
|
1306
|
+
listEl.querySelectorAll('[data-token-reveal]').forEach(btn => {
|
|
1307
|
+
if (btn.disabled) return;
|
|
1308
|
+
btn.addEventListener('click', () => revealToken(parseInt(btn.dataset.tokenReveal), btn.dataset.tokenName));
|
|
1309
|
+
});
|
|
1310
|
+
} catch (e) {
|
|
1311
|
+
listEl.innerHTML = '<p class="login-error">加载失败: ' + esc(e.message) + '</p>';
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// 复制到剪贴板:navigator.clipboard 优先,不支持时回退 execCommand。
|
|
1316
|
+
async function copyToClipboard(text) {
|
|
1317
|
+
try {
|
|
1318
|
+
await navigator.clipboard.writeText(text);
|
|
1319
|
+
return true;
|
|
1320
|
+
} catch (_) {
|
|
1321
|
+
try {
|
|
1322
|
+
const input = document.createElement('input');
|
|
1323
|
+
input.value = text;
|
|
1324
|
+
document.body.appendChild(input);
|
|
1325
|
+
input.select();
|
|
1326
|
+
document.execCommand('copy');
|
|
1327
|
+
input.remove();
|
|
1328
|
+
return true;
|
|
1329
|
+
} catch (e) {
|
|
1330
|
+
return false;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
async function revealToken(id, name) {
|
|
1336
|
+
let data;
|
|
1337
|
+
try {
|
|
1338
|
+
data = await api('/api/tokens/' + id + '/reveal', { method: 'POST' });
|
|
1339
|
+
} catch (e) { toast(e.message, 'error'); return; }
|
|
1340
|
+
|
|
1341
|
+
const modal = document.getElementById('token-reveal-modal');
|
|
1342
|
+
const input = modal.querySelector('#token-reveal-modal-input');
|
|
1343
|
+
modal.querySelector('#token-reveal-modal-name').textContent = '令牌「' + name + '」的明文:';
|
|
1344
|
+
input.value = data.token;
|
|
1345
|
+
openModal(modal);
|
|
1346
|
+
|
|
1347
|
+
const closeBtn = modal.querySelector('#token-reveal-modal-close');
|
|
1348
|
+
const dismissBtn = modal.querySelector('#token-reveal-modal-dismiss');
|
|
1349
|
+
const copyBtn = modal.querySelector('#token-reveal-modal-copy');
|
|
1350
|
+
const close = () => closeModal(modal);
|
|
1351
|
+
closeBtn.onclick = close;
|
|
1352
|
+
dismissBtn.onclick = close;
|
|
1353
|
+
copyBtn.onclick = async () => {
|
|
1354
|
+
const ok = await copyToClipboard(data.token);
|
|
1355
|
+
if (ok) {
|
|
1356
|
+
copyBtn.textContent = '已复制';
|
|
1357
|
+
toast('令牌已复制');
|
|
1358
|
+
setTimeout(() => { copyBtn.textContent = '复制'; }, 2000);
|
|
1359
|
+
} else {
|
|
1360
|
+
input.focus();
|
|
1361
|
+
input.select();
|
|
1362
|
+
toast('复制失败,请手动选中复制', 'error');
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
if (!modal.dataset.bound) {
|
|
1366
|
+
modal.dataset.bound = '1';
|
|
1367
|
+
modal.addEventListener('click', e => { if (e.target === modal) closeModal(modal); });
|
|
1368
|
+
}
|
|
1369
|
+
// 延迟聚焦,等待弹窗显示动画结束
|
|
1370
|
+
setTimeout(() => { input.focus(); input.select(); }, 0);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
async function createTokenDialog() {
|
|
1374
|
+
const name = await dialogModal.prompt({ title: '创建令牌', label: '令牌名称', placeholder: '例如: My CI Token' });
|
|
1375
|
+
if (!name) return;
|
|
1376
|
+
try {
|
|
1377
|
+
const data = await api('/api/tokens', { method: 'POST', body: { name } });
|
|
1378
|
+
await dialogModal.alert({ title: '令牌已创建', message: '请妥善保存以下令牌。也可稍后在列表中点击「查看/复制」再次获取:\n\n' + esc(data.token) });
|
|
1379
|
+
loadTokensList();
|
|
1380
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
async function deleteTokenConfirm(id, name) {
|
|
1384
|
+
const ok = await dialogModal.confirm({
|
|
1385
|
+
title: '删除令牌',
|
|
1386
|
+
message: `确定删除令牌「${name}」?使用此令牌的应用将失去访问权限。`,
|
|
1387
|
+
confirmText: '删除',
|
|
1388
|
+
});
|
|
1389
|
+
if (!ok) return;
|
|
1390
|
+
try {
|
|
1391
|
+
await api('/api/tokens/' + id, { method: 'DELETE' });
|
|
1392
|
+
toast('令牌已删除');
|
|
1393
|
+
loadTokensList();
|
|
1394
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
1395
|
+
};
|
|
1396
|
+
|
|
1397
|
+
// ---------- 修改密码弹窗 ----------
|
|
1398
|
+
function openPasswordModal() {
|
|
1399
|
+
const modal = document.getElementById('password-modal');
|
|
1400
|
+
openModal(modal);
|
|
1401
|
+
modal.querySelector('#current-password').value = '';
|
|
1402
|
+
modal.querySelector('#new-password').value = '';
|
|
1403
|
+
modal.querySelector('#confirm-password').value = '';
|
|
1404
|
+
const errorEl = modal.querySelector('#password-error');
|
|
1405
|
+
errorEl.hidden = true;
|
|
1406
|
+
|
|
1407
|
+
modal.querySelector('#password-modal-close').onclick = () => { closeModal(modal); };
|
|
1408
|
+
modal.querySelector('#password-modal-cancel').onclick = () => { closeModal(modal); };
|
|
1409
|
+
modal.querySelector('#password-modal-submit').onclick = async () => {
|
|
1410
|
+
const currentPwd = modal.querySelector('#current-password').value;
|
|
1411
|
+
const newPwd = modal.querySelector('#new-password').value;
|
|
1412
|
+
const confirmPwd = modal.querySelector('#confirm-password').value;
|
|
1413
|
+
if (!currentPwd || !newPwd) { errorEl.textContent = '请填写当前密码和新密码'; errorEl.hidden = false; return; }
|
|
1414
|
+
if (newPwd.length < 8) { errorEl.textContent = '新密码至少 8 位'; errorEl.hidden = false; return; }
|
|
1415
|
+
if (newPwd !== confirmPwd) { errorEl.textContent = '两次输入的新密码不一致'; errorEl.hidden = false; return; }
|
|
1416
|
+
try {
|
|
1417
|
+
await api('/api/auth/change-password', { method: 'POST', body: { currentPassword: currentPwd, newPassword: newPwd } });
|
|
1418
|
+
toast('密码已修改');
|
|
1419
|
+
closeModal(modal);
|
|
1420
|
+
} catch (e) {
|
|
1421
|
+
errorEl.textContent = e.message;
|
|
1422
|
+
errorEl.hidden = false;
|
|
1423
|
+
}
|
|
1424
|
+
};
|
|
1425
|
+
if (!modal.dataset.bound) { modal.dataset.bound = '1'; modal.addEventListener('click', e => { if (e.target === modal) closeModal(modal); }); }
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// ---------- 个人资料弹窗 ----------
|
|
1429
|
+
function openProfileModal() {
|
|
1430
|
+
const modal = document.getElementById('profile-modal');
|
|
1431
|
+
openModal(modal);
|
|
1432
|
+
const u = state.currentUser || {};
|
|
1433
|
+
modal.querySelector('#profile-username').value = u.username || '';
|
|
1434
|
+
modal.querySelector('#profile-email').value = u.email || '';
|
|
1435
|
+
const errorEl = modal.querySelector('#profile-error');
|
|
1436
|
+
errorEl.hidden = true;
|
|
1437
|
+
|
|
1438
|
+
modal.querySelector('#profile-modal-close').onclick = () => { closeModal(modal); };
|
|
1439
|
+
modal.querySelector('#profile-modal-cancel').onclick = () => { closeModal(modal); };
|
|
1440
|
+
modal.querySelector('#profile-modal-submit').onclick = async () => {
|
|
1441
|
+
const username = modal.querySelector('#profile-username').value.trim();
|
|
1442
|
+
const email = modal.querySelector('#profile-email').value.trim();
|
|
1443
|
+
errorEl.hidden = true;
|
|
1444
|
+
if (!username) { errorEl.textContent = '用户名不能为空'; errorEl.hidden = false; return; }
|
|
1445
|
+
try {
|
|
1446
|
+
const body = { username };
|
|
1447
|
+
if (email !== (u.email || '')) body.email = email || '';
|
|
1448
|
+
const data = await api('/api/auth/profile', { method: 'POST', body });
|
|
1449
|
+
// 更新本地状态
|
|
1450
|
+
if (data.username) state.currentUser.username = data.username;
|
|
1451
|
+
state.currentUser.email = data.email || null;
|
|
1452
|
+
state.currentUser.emailVerified = data.emailVerified;
|
|
1453
|
+
// 更新 header 显示
|
|
1454
|
+
const headerUser = document.getElementById('header-user');
|
|
1455
|
+
if (headerUser) headerUser.textContent = data.username || u.username;
|
|
1456
|
+
toast('资料已更新');
|
|
1457
|
+
closeModal(modal);
|
|
1458
|
+
} catch (e) {
|
|
1459
|
+
errorEl.textContent = e.message;
|
|
1460
|
+
errorEl.hidden = false;
|
|
1461
|
+
}
|
|
1462
|
+
};
|
|
1463
|
+
if (!modal.dataset.bound) {
|
|
1464
|
+
modal.dataset.bound = '1';
|
|
1465
|
+
modal.addEventListener('click', e => { if (e.target === modal) closeModal(modal); });
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// ---------- 数据管理弹窗 ----------
|
|
1470
|
+
function formatBytes(bytes) {
|
|
1471
|
+
if (bytes === 0) return '0 B';
|
|
1472
|
+
const k = 1024;
|
|
1473
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
1474
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
1475
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
async function openBackupModal() {
|
|
1479
|
+
const modal = document.getElementById('backup-modal');
|
|
1480
|
+
modal.hidden = false;
|
|
1481
|
+
modal.setAttribute('aria-hidden', 'false');
|
|
1482
|
+
const statsEl = modal.querySelector('#backup-stats');
|
|
1483
|
+
statsEl.innerHTML = '<p style="color:var(--text-secondary)">加载中...</p>';
|
|
1484
|
+
|
|
1485
|
+
try {
|
|
1486
|
+
const stats = await api('/api/admin/stats');
|
|
1487
|
+
statsEl.innerHTML =
|
|
1488
|
+
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px 16px;font-size:14px">' +
|
|
1489
|
+
'<span style="color:var(--text-secondary)">文件数量</span><span>' + stats.fileCount + '</span>' +
|
|
1490
|
+
'<span style="color:var(--text-secondary)">数据库大小</span><span>' + formatBytes(stats.dbSize) + '</span>' +
|
|
1491
|
+
'<span style="color:var(--text-secondary)">上传文件大小</span><span>' + formatBytes(stats.uploadsSize) + '</span>' +
|
|
1492
|
+
'<span style="color:var(--text-secondary)">总大小</span><span style="font-weight:600">' + formatBytes(stats.totalSize) + '</span>' +
|
|
1493
|
+
'</div>';
|
|
1494
|
+
} catch (e) {
|
|
1495
|
+
statsEl.innerHTML = '<p class="login-error">加载统计失败: ' + esc(e.message) + '</p>';
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
const hideModal = () => { closeModal(modal); };
|
|
1499
|
+
modal.querySelector('#backup-modal-close').onclick = hideModal;
|
|
1500
|
+
modal.querySelector('#backup-modal-dismiss').onclick = hideModal;
|
|
1501
|
+
if (!modal.dataset.bound) { modal.dataset.bound = '1'; modal.addEventListener('click', e => { if (e.target === modal) hideModal(); }); }
|
|
1502
|
+
|
|
1503
|
+
modal.querySelector('#btn-export-backup').onclick = () => {
|
|
1504
|
+
window.location.href = '/api/admin/export';
|
|
1505
|
+
toast('备份下载已开始');
|
|
1506
|
+
};
|
|
1507
|
+
|
|
1508
|
+
const fileInput = modal.querySelector('#import-file-input');
|
|
1509
|
+
modal.querySelector('#btn-import-backup').onclick = () => { fileInput.click(); };
|
|
1510
|
+
fileInput.onchange = async () => {
|
|
1511
|
+
const file = fileInput.files[0];
|
|
1512
|
+
if (!file) return;
|
|
1513
|
+
fileInput.value = '';
|
|
1514
|
+
|
|
1515
|
+
const ok = await dialogModal.confirm({
|
|
1516
|
+
title: '警告:数据恢复',
|
|
1517
|
+
message: '导入将<strong style="color:var(--danger)">覆盖当前所有数据</strong>,此操作不可撤销!<br><br>当前数据会先备份到 data-backup-* 目录。',
|
|
1518
|
+
confirmText: '我已了解风险,继续',
|
|
1519
|
+
danger: true
|
|
1520
|
+
});
|
|
1521
|
+
if (!ok) return;
|
|
1522
|
+
|
|
1523
|
+
const confirmText = await dialogModal.prompt({
|
|
1524
|
+
title: '二次确认',
|
|
1525
|
+
label: '请输入 CONFIRM 以确认导入',
|
|
1526
|
+
placeholder: 'CONFIRM',
|
|
1527
|
+
validate: (v) => v !== 'CONFIRM' ? '请输入 CONFIRM 以确认' : null
|
|
1528
|
+
});
|
|
1529
|
+
if (confirmText !== 'CONFIRM') return;
|
|
1530
|
+
|
|
1531
|
+
const formData = new FormData();
|
|
1532
|
+
formData.append('file', file);
|
|
1533
|
+
try {
|
|
1534
|
+
const resp = await fetch('/api/admin/import', { method: 'POST', body: formData, credentials: 'same-origin' });
|
|
1535
|
+
const data = await resp.json();
|
|
1536
|
+
if (!resp.ok) throw new Error(data.error || '导入失败');
|
|
1537
|
+
await dialogModal.alert({ title: '导入成功', message: data.message });
|
|
1538
|
+
window.location.reload();
|
|
1539
|
+
} catch (e) {
|
|
1540
|
+
toast(e.message, 'error');
|
|
1541
|
+
}
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// ---------- Tags & Categories ----------
|
|
1546
|
+
async function loadTagsAndCategories(container) {
|
|
1547
|
+
try {
|
|
1548
|
+
const [tagData, catData] = await Promise.all([api('/api/tags'), api('/api/categories')]);
|
|
1549
|
+
allTags = tagData.tags || [];
|
|
1550
|
+
allCategories = catData.categories || [];
|
|
1551
|
+
renderFilterDropdowns(container);
|
|
1552
|
+
} catch (e) { /* ignore */ }
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
function renderFilterDropdowns(container) {
|
|
1556
|
+
const tagMenu = container.querySelector('#tag-filter-menu');
|
|
1557
|
+
if (tagMenu) {
|
|
1558
|
+
let html = '<button type="button" class="filter-dropdown-item" data-tag-id="">全部标签</button>';
|
|
1559
|
+
allTags.forEach(t => {
|
|
1560
|
+
html += `<button type="button" class="filter-dropdown-item${filterState.tagId === t.id ? ' active' : ''}" data-tag-id="${t.id}">${escapeHtml(t.name)} (${t.file_count})</button>`;
|
|
1561
|
+
});
|
|
1562
|
+
tagMenu.innerHTML = html;
|
|
1563
|
+
tagMenu.querySelectorAll('.filter-dropdown-item').forEach(item => {
|
|
1564
|
+
item.addEventListener('click', () => {
|
|
1565
|
+
filterState.tagId = item.dataset.tagId ? parseInt(item.dataset.tagId) : null;
|
|
1566
|
+
const dd = container.querySelector('#tag-filter-dropdown');
|
|
1567
|
+
if (dd) dd.classList.remove('open');
|
|
1568
|
+
renderFilterDropdowns(container);
|
|
1569
|
+
loadFiles(container, 1);
|
|
1570
|
+
});
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
const catMenu = container.querySelector('#category-filter-menu');
|
|
1574
|
+
if (catMenu) {
|
|
1575
|
+
let html = '<button type="button" class="filter-dropdown-item" data-category-id="">全部分类</button>';
|
|
1576
|
+
html += '<button type="button" class="filter-dropdown-item" data-category-id="uncategorized">未分类</button>';
|
|
1577
|
+
allCategories.forEach(c => {
|
|
1578
|
+
html += `<button type="button" class="filter-dropdown-item${filterState.categoryId === c.id ? ' active' : ''}" data-category-id="${c.id}">${escapeHtml(c.name)} (${c.file_count})</button>`;
|
|
1579
|
+
});
|
|
1580
|
+
catMenu.innerHTML = html;
|
|
1581
|
+
catMenu.querySelectorAll('.filter-dropdown-item').forEach(item => {
|
|
1582
|
+
item.addEventListener('click', () => {
|
|
1583
|
+
const val = item.dataset.categoryId;
|
|
1584
|
+
filterState.categoryId = val === '' ? null : (val === 'uncategorized' ? 'uncategorized' : parseInt(val));
|
|
1585
|
+
const dd = container.querySelector('#category-filter-dropdown');
|
|
1586
|
+
if (dd) dd.classList.remove('open');
|
|
1587
|
+
renderFilterDropdowns(container);
|
|
1588
|
+
loadFiles(container, 1);
|
|
1589
|
+
});
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
async function toggleStar(fileId, currentStarred) {
|
|
1595
|
+
try {
|
|
1596
|
+
if (currentStarred) {
|
|
1597
|
+
await api(`/api/files/${fileId}/star`, { method: 'DELETE' });
|
|
1598
|
+
toast('已取消收藏');
|
|
1599
|
+
} else {
|
|
1600
|
+
await api(`/api/files/${fileId}/star`, { method: 'POST' });
|
|
1601
|
+
toast('已收藏');
|
|
1602
|
+
}
|
|
1603
|
+
} catch (e) {
|
|
1604
|
+
toast(e.message, 'error');
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
function openTagEditor(container, fileId, currentTags) {
|
|
1609
|
+
const modal = document.getElementById('tag-editor-modal');
|
|
1610
|
+
if (!modal) return;
|
|
1611
|
+
const input = document.getElementById('tag-editor-input');
|
|
1612
|
+
const selected = document.getElementById('tag-editor-selected');
|
|
1613
|
+
const suggestions = document.getElementById('tag-editor-suggestions');
|
|
1614
|
+
|
|
1615
|
+
let selectedTags = [...(currentTags || [])];
|
|
1616
|
+
|
|
1617
|
+
function renderSelected() {
|
|
1618
|
+
selected.innerHTML = selectedTags.map(t =>
|
|
1619
|
+
`<span class="tag-editor-chip" data-tag-id="${t.id}">${escapeHtml(t.name)}<span class="tag-editor-chip-remove">×</span></span>`
|
|
1620
|
+
).join('');
|
|
1621
|
+
selected.querySelectorAll('.tag-editor-chip-remove').forEach(btn => {
|
|
1622
|
+
btn.addEventListener('click', e => {
|
|
1623
|
+
e.stopPropagation();
|
|
1624
|
+
const chip = btn.closest('.tag-editor-chip');
|
|
1625
|
+
const id = parseInt(chip.dataset.tagId);
|
|
1626
|
+
selectedTags = selectedTags.filter(t => t.id !== id);
|
|
1627
|
+
renderSelected();
|
|
1628
|
+
});
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
renderSelected();
|
|
1632
|
+
|
|
1633
|
+
input.value = '';
|
|
1634
|
+
suggestions.innerHTML = '';
|
|
1635
|
+
suggestions.classList.remove('visible');
|
|
1636
|
+
|
|
1637
|
+
input.oninput = () => {
|
|
1638
|
+
const q = input.value.trim().toLowerCase();
|
|
1639
|
+
if (!q) { suggestions.classList.remove('visible'); suggestions.innerHTML = ''; return; }
|
|
1640
|
+
const existing = allTags.filter(t => t.name.toLowerCase().includes(q) && !selectedTags.some(s => s.id === t.id));
|
|
1641
|
+
if (existing.length) {
|
|
1642
|
+
suggestions.innerHTML = existing.map(t => `<li data-tag-id="${t.id}" data-tag-name="${escapeHtml(t.name)}">${escapeHtml(t.name)}</li>`).join('') +
|
|
1643
|
+
`<li class="tag-create-new" data-new-name="${escapeHtml(input.value.trim())}">+ 创建 "${escapeHtml(input.value.trim())}"</li>`;
|
|
1644
|
+
} else {
|
|
1645
|
+
suggestions.innerHTML = `<li class="tag-create-new" data-new-name="${escapeHtml(input.value.trim())}">+ 创建 "${escapeHtml(input.value.trim())}"</li>`;
|
|
1646
|
+
}
|
|
1647
|
+
suggestions.classList.add('visible');
|
|
1648
|
+
suggestions.querySelectorAll('li').forEach(li => {
|
|
1649
|
+
li.addEventListener('click', () => {
|
|
1650
|
+
if (li.dataset.tagId) {
|
|
1651
|
+
const tag = allTags.find(t => t.id === parseInt(li.dataset.tagId));
|
|
1652
|
+
if (tag && !selectedTags.some(s => s.id === tag.id)) {
|
|
1653
|
+
selectedTags.push({ id: tag.id, name: tag.name });
|
|
1654
|
+
}
|
|
1655
|
+
} else if (li.dataset.newName) {
|
|
1656
|
+
const name = li.dataset.newName;
|
|
1657
|
+
if (!selectedTags.some(t => t.name.toLowerCase() === name.toLowerCase())) {
|
|
1658
|
+
selectedTags.push({ id: null, name });
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
input.value = '';
|
|
1662
|
+
suggestions.classList.remove('visible');
|
|
1663
|
+
renderSelected();
|
|
1664
|
+
});
|
|
1665
|
+
});
|
|
1666
|
+
};
|
|
1667
|
+
|
|
1668
|
+
input.onkeydown = e => {
|
|
1669
|
+
if (e.key === 'Enter') {
|
|
1670
|
+
e.preventDefault();
|
|
1671
|
+
const name = input.value.trim();
|
|
1672
|
+
if (!name) return;
|
|
1673
|
+
if (!selectedTags.some(t => t.name.toLowerCase() === name.toLowerCase())) {
|
|
1674
|
+
const existing = allTags.find(t => t.name.toLowerCase() === name.toLowerCase());
|
|
1675
|
+
if (existing) {
|
|
1676
|
+
selectedTags.push({ id: existing.id, name: existing.name });
|
|
1677
|
+
} else {
|
|
1678
|
+
selectedTags.push({ id: null, name });
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
input.value = '';
|
|
1682
|
+
suggestions.classList.remove('visible');
|
|
1683
|
+
renderSelected();
|
|
1684
|
+
}
|
|
1685
|
+
};
|
|
1686
|
+
|
|
1687
|
+
modal.hidden = false;
|
|
1688
|
+
modal.setAttribute('aria-hidden', 'false');
|
|
1689
|
+
input.focus();
|
|
1690
|
+
|
|
1691
|
+
const closeHandler = () => closeTagEditor();
|
|
1692
|
+
document.getElementById('tag-editor-cancel').onclick = closeHandler;
|
|
1693
|
+
document.getElementById('tag-editor-close').onclick = closeHandler;
|
|
1694
|
+
modal.onclick = e => { if (e.target === modal) closeHandler(); };
|
|
1695
|
+
|
|
1696
|
+
document.getElementById('tag-editor-save').onclick = async () => {
|
|
1697
|
+
try {
|
|
1698
|
+
const tagIds = [];
|
|
1699
|
+
for (const t of selectedTags) {
|
|
1700
|
+
if (t.id) {
|
|
1701
|
+
tagIds.push(t.id);
|
|
1702
|
+
} else {
|
|
1703
|
+
const created = await api('/api/tags', { method: 'POST', body: { name: t.name } });
|
|
1704
|
+
tagIds.push(created.id);
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
await api(`/api/files/${fileId}/tags`, { method: 'PUT', body: { tagIds } });
|
|
1708
|
+
toast('标签已更新');
|
|
1709
|
+
closeTagEditor();
|
|
1710
|
+
loadTagsAndCategories(document.querySelector('#app'));
|
|
1711
|
+
loadFiles(container);
|
|
1712
|
+
} catch (e) {
|
|
1713
|
+
toast(e.message, 'error');
|
|
1714
|
+
}
|
|
1715
|
+
};
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
function closeTagEditor() {
|
|
1719
|
+
const modal = document.getElementById('tag-editor-modal');
|
|
1720
|
+
if (!modal) return;
|
|
1721
|
+
modal.hidden = true;
|
|
1722
|
+
modal.setAttribute('aria-hidden', 'true');
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
function openCategorySelect(container, fileId, currentCategoryId) {
|
|
1726
|
+
const modal = document.getElementById('category-select-modal');
|
|
1727
|
+
if (!modal) return;
|
|
1728
|
+
const list = document.getElementById('category-select-list');
|
|
1729
|
+
|
|
1730
|
+
let html = `<div class="category-list-item${!currentCategoryId ? ' selected' : ''}" data-category-id="">
|
|
1731
|
+
<span>未分类</span>
|
|
1732
|
+
</div>`;
|
|
1733
|
+
allCategories.forEach(c => {
|
|
1734
|
+
html += `<div class="category-list-item${currentCategoryId === c.id ? ' selected' : ''}" data-category-id="${c.id}">
|
|
1735
|
+
<span>${escapeHtml(c.name)}</span>
|
|
1736
|
+
<div class="category-item-actions">
|
|
1737
|
+
<button type="button" class="btn btn-small category-rename" data-id="${c.id}" data-name="${escapeHtml(c.name)}">重命名</button>
|
|
1738
|
+
<button type="button" class="btn btn-small btn-danger category-delete" data-id="${c.id}">删除</button>
|
|
1739
|
+
</div>
|
|
1740
|
+
</div>`;
|
|
1741
|
+
});
|
|
1742
|
+
list.innerHTML = html;
|
|
1743
|
+
|
|
1744
|
+
list.querySelectorAll('.category-list-item').forEach(item => {
|
|
1745
|
+
item.addEventListener('click', async e => {
|
|
1746
|
+
if (e.target.closest('.category-rename') || e.target.closest('.category-delete')) return;
|
|
1747
|
+
const val = item.dataset.categoryId;
|
|
1748
|
+
try {
|
|
1749
|
+
await api(`/api/files/${fileId}/category`, { method: 'PUT', body: { categoryId: val ? parseInt(val) : null } });
|
|
1750
|
+
toast('分类已更新');
|
|
1751
|
+
closeCategorySelect();
|
|
1752
|
+
loadTagsAndCategories(document.querySelector('#app'));
|
|
1753
|
+
loadFiles(container);
|
|
1754
|
+
} catch (e) {
|
|
1755
|
+
toast(e.message, 'error');
|
|
1756
|
+
}
|
|
1757
|
+
});
|
|
1758
|
+
});
|
|
1759
|
+
|
|
1760
|
+
list.querySelectorAll('.category-rename').forEach(btn => {
|
|
1761
|
+
btn.addEventListener('click', async e => {
|
|
1762
|
+
e.stopPropagation();
|
|
1763
|
+
const name = await dialogModal.prompt({
|
|
1764
|
+
title: '重命名分类',
|
|
1765
|
+
label: '分类名',
|
|
1766
|
+
value: btn.dataset.name,
|
|
1767
|
+
validate: v => !v.trim() ? '分类名不能为空' : null,
|
|
1768
|
+
});
|
|
1769
|
+
if (!name) return;
|
|
1770
|
+
try {
|
|
1771
|
+
await api(`/api/categories/${btn.dataset.id}`, { method: 'PUT', body: { name } });
|
|
1772
|
+
toast('分类已重命名');
|
|
1773
|
+
await loadTagsAndCategories(document.querySelector('#app'));
|
|
1774
|
+
openCategorySelect(container, fileId, currentCategoryId);
|
|
1775
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
1776
|
+
});
|
|
1777
|
+
});
|
|
1778
|
+
|
|
1779
|
+
list.querySelectorAll('.category-delete').forEach(btn => {
|
|
1780
|
+
btn.addEventListener('click', async e => {
|
|
1781
|
+
e.stopPropagation();
|
|
1782
|
+
const ok = await dialogModal.confirm({
|
|
1783
|
+
title: '删除分类',
|
|
1784
|
+
message: '确定要删除该分类吗?文件将变为未分类。',
|
|
1785
|
+
confirmText: '删除',
|
|
1786
|
+
danger: true,
|
|
1787
|
+
});
|
|
1788
|
+
if (!ok) return;
|
|
1789
|
+
try {
|
|
1790
|
+
await api(`/api/categories/${btn.dataset.id}`, { method: 'DELETE' });
|
|
1791
|
+
toast('分类已删除');
|
|
1792
|
+
await loadTagsAndCategories(document.querySelector('#app'));
|
|
1793
|
+
openCategorySelect(container, fileId, null);
|
|
1794
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
1795
|
+
});
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
document.getElementById('category-select-create').onclick = async () => {
|
|
1799
|
+
const name = await dialogModal.prompt({
|
|
1800
|
+
title: '新建分类',
|
|
1801
|
+
label: '分类名',
|
|
1802
|
+
placeholder: '输入分类名称',
|
|
1803
|
+
validate: v => !v.trim() ? '分类名不能为空' : null,
|
|
1804
|
+
});
|
|
1805
|
+
if (!name) return;
|
|
1806
|
+
try {
|
|
1807
|
+
await api('/api/categories', { method: 'POST', body: { name } });
|
|
1808
|
+
toast('分类已创建');
|
|
1809
|
+
await loadTagsAndCategories(document.querySelector('#app'));
|
|
1810
|
+
openCategorySelect(container, fileId, currentCategoryId);
|
|
1811
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
1812
|
+
};
|
|
1813
|
+
|
|
1814
|
+
const closeHandler = () => closeCategorySelect();
|
|
1815
|
+
document.getElementById('category-select-cancel').onclick = closeHandler;
|
|
1816
|
+
document.getElementById('category-select-close').onclick = closeHandler;
|
|
1817
|
+
modal.onclick = e => { if (e.target === modal) closeHandler(); };
|
|
1818
|
+
|
|
1819
|
+
modal.hidden = false;
|
|
1820
|
+
modal.setAttribute('aria-hidden', 'false');
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
function closeCategorySelect() {
|
|
1824
|
+
const modal = document.getElementById('category-select-modal');
|
|
1825
|
+
if (!modal) return;
|
|
1826
|
+
modal.hidden = true;
|
|
1827
|
+
modal.setAttribute('aria-hidden', 'true');
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// --- 模板选择(首页) ---
|
|
1831
|
+
const TEMPLATE_VISUALS = {
|
|
1832
|
+
'default': { bg: '#ffffff', text: '#57606a', heading: '#1f2328', code: '#f6f8fa', border: '#d0d7de' },
|
|
1833
|
+
'github': { bg: '#ffffff', text: '#57606a', heading: '#1f2328', code: '#f6f8fa', border: '#d0d7de' },
|
|
1834
|
+
'academic': { bg: '#fefcf3', text: '#3b3b3b', heading: '#1a1a1a', code: '#f5f1e8', border: '#d4c9a8' },
|
|
1835
|
+
'dark-pro': { bg: '#1e1e2e', text: '#a6adc8', heading: '#f0f6fc', code: '#313244', border: '#45475a' },
|
|
1836
|
+
};
|
|
1837
|
+
let templateSelectBound = false;
|
|
1838
|
+
async function openTemplateSelect(container, fileId, currentTemplateId) {
|
|
1839
|
+
const modal = document.getElementById('template-select-modal');
|
|
1840
|
+
if (!modal) return;
|
|
1841
|
+
const list = document.getElementById('template-select-list');
|
|
1842
|
+
list.innerHTML = '<div class="loading">加载中…</div>';
|
|
1843
|
+
modal.hidden = false;
|
|
1844
|
+
modal.setAttribute('aria-hidden', 'false');
|
|
1845
|
+
|
|
1846
|
+
let allTemplates;
|
|
1847
|
+
try {
|
|
1848
|
+
const data = await api('/api/templates');
|
|
1849
|
+
allTemplates = data.templates || [];
|
|
1850
|
+
} catch (e) {
|
|
1851
|
+
list.innerHTML = '<div class="empty-state">加载失败</div>';
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
const defaultTpl = allTemplates.find(t => t.name === 'default');
|
|
1856
|
+
const isSelected = (t) => t.id === currentTemplateId || (!currentTemplateId && t.name === 'default');
|
|
1857
|
+
|
|
1858
|
+
list.innerHTML = allTemplates.map(t => {
|
|
1859
|
+
const v = TEMPLATE_VISUALS[t.name] || TEMPLATE_VISUALS['default'];
|
|
1860
|
+
const sel = isSelected(t);
|
|
1861
|
+
return `<div class="tpl-card ${sel ? 'selected' : ''}" data-tpl-id="${t.id}">
|
|
1862
|
+
<div class="tpl-preview" style="background:${v.bg};border-bottom:1px solid ${v.border}">
|
|
1863
|
+
<div class="tpl-preview-heading" style="background:${v.heading}"></div>
|
|
1864
|
+
<div class="tpl-preview-line" style="background:${v.text};opacity:.45"></div>
|
|
1865
|
+
<div class="tpl-preview-line" style="background:${v.text};opacity:.3"></div>
|
|
1866
|
+
<div class="tpl-preview-code" style="background:${v.code};border:1px solid ${v.border}"></div>
|
|
1867
|
+
</div>
|
|
1868
|
+
<div class="tpl-card-label">
|
|
1869
|
+
<span>${t.description || t.name}</span>
|
|
1870
|
+
<span class="tpl-card-check">✓</span>
|
|
1871
|
+
</div>
|
|
1872
|
+
</div>`;
|
|
1873
|
+
}).join('');
|
|
1874
|
+
|
|
1875
|
+
list.querySelectorAll('.tpl-card').forEach(item => {
|
|
1876
|
+
item.addEventListener('click', async () => {
|
|
1877
|
+
const tplId = parseInt(item.dataset.tplId);
|
|
1878
|
+
try {
|
|
1879
|
+
await api(`/api/files/${fileId}`, {
|
|
1880
|
+
method: 'PUT',
|
|
1881
|
+
body: { templateId: tplId === defaultTpl?.id ? null : tplId }
|
|
1882
|
+
});
|
|
1883
|
+
} catch (e) { toast(e.message || '切换模板失败', 'error'); }
|
|
1884
|
+
closeTemplateSelect();
|
|
1885
|
+
renderHome(container);
|
|
1886
|
+
});
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
if (!templateSelectBound) {
|
|
1890
|
+
document.getElementById('template-select-close').addEventListener('click', closeTemplateSelect);
|
|
1891
|
+
document.getElementById('template-select-cancel').addEventListener('click', closeTemplateSelect);
|
|
1892
|
+
templateSelectBound = true;
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
function closeTemplateSelect() {
|
|
1897
|
+
const modal = document.getElementById('template-select-modal');
|
|
1898
|
+
if (!modal) return;
|
|
1899
|
+
modal.hidden = true;
|
|
1900
|
+
modal.setAttribute('aria-hidden', 'true');
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
export { renderHome, closeTemplateSelect };
|