(() => { const API_BASE = '/api/gui/files'; const EDITOR_PAGE = '/file-manager/editor'; const state = { currentPath: '', items: [], selected: new Set(), lastSelectedIndex: null, clipboard: null, // {mode: 'copy'|'cut', items: []} treeCache: new Map(), treeExpanded: new Set(['']), isDraggingSelection: false, dragStart: null, selectionRect: null, selectionJustFinished: false, selectionDisabled: false, }; const icons = { directory: '📁', default: '📄', editable: '📝', code: '💻', markdown: '🧾', image: '🖼️', archive: '🗃️', }; const fileGrid = document.getElementById('fileGrid'); const directoryTree = document.getElementById('directoryTree'); const breadcrumbEl = document.getElementById('breadcrumb'); const selectionInfo = document.getElementById('selectionInfo'); const statusBar = document.getElementById('statusBar'); const contextMenu = document.getElementById('contextMenu'); const dialogBackdrop = document.getElementById('dialogBackdrop'); const dialogTitle = document.getElementById('dialogTitle'); const dialogContent = document.getElementById('dialogContent'); const dialogCancel = document.getElementById('dialogCancel'); const dialogConfirm = document.getElementById('dialogConfirm'); const hiddenUploader = document.getElementById('hiddenUploader'); const pasteBtn = document.getElementById('btnPaste'); const newFolderBtn = document.getElementById('btnNewFolder'); const newFileBtn = document.getElementById('btnNewFile'); const refreshBtn = document.getElementById('btnRefresh'); const uploadBtn = document.getElementById('btnUpload'); const backBtn = document.getElementById('btnBack'); const returnChatBtn = document.getElementById('btnReturnChat'); const downloadBtn = document.getElementById('btnDownload'); const renameBtn = document.getElementById('btnRename'); const copyBtn = document.getElementById('btnCopy'); const cutBtn = document.getElementById('btnCut'); const deleteBtn = document.getElementById('btnDelete'); const toggleSelectionBtn = document.getElementById('btnToggleSelection'); const clamp = (value, min, max) => Math.max(min, Math.min(value, max)); const urlParams = new URLSearchParams(window.location.search); const initialPathParam = (urlParams.get('path') || '').replace(/^\//, '').replace(/\/$/, ''); dialogBackdrop.hidden = true; let dialogHandlers = { confirm: null, cancel: null }; function clearDialogHandlers() { dialogHandlers.confirm = null; dialogHandlers.cancel = null; } function registerDialogHandlers(confirmHandler, cancelHandler) { dialogHandlers.confirm = confirmHandler || null; dialogHandlers.cancel = cancelHandler || null; } function closeDialog() { dialogBackdrop.hidden = true; clearDialogHandlers(); } dialogCancel.addEventListener('click', () => { if (dialogHandlers.cancel) { const handler = dialogHandlers.cancel; clearDialogHandlers(); handler(); } else { closeDialog(); } }); dialogConfirm.addEventListener('click', () => { if (dialogHandlers.confirm) { const handler = dialogHandlers.confirm; clearDialogHandlers(); handler(); } else { closeDialog(); } }); dialogBackdrop.addEventListener('click', (event) => { if (event.target === dialogBackdrop) { if (dialogHandlers.cancel) { const handler = dialogHandlers.cancel; clearDialogHandlers(); handler(); } else { closeDialog(); } } }); document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && !dialogBackdrop.hidden) { if (dialogHandlers.cancel) { const handler = dialogHandlers.cancel; clearDialogHandlers(); handler(); } else { closeDialog(); } } }); closeDialog(); function showStatus(message) { statusBar.textContent = message; } function formatSize(size) { if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`; return `${(size / 1024 / 1024 / 1024).toFixed(1)} GB`; } function formatTime(ts) { const d = new Date(ts * 1000); return d.toLocaleString(); } function joinPath(base, name) { if (!base) return name; return `${base.replace(/\/$/, '')}/${name}`; } function getIcon(entry) { if (entry.type === 'directory') return icons.directory; if (entry.is_editable) return icons.editable; const ext = entry.extension || ''; if (['.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp'].includes(ext)) { return icons.image; } if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) { return icons.archive; } if (['.js', '.ts', '.py', '.rb', '.php', '.java', '.kt', '.go', '.rs', '.c', '.cpp', '.h', '.hpp'].includes(ext)) { return icons.code; } if (['.md', '.markdown'].includes(ext)) { return icons.markdown; } return icons.default; } async function request(url, options = {}) { const response = await fetch(url, options); const data = await response.json().catch(() => ({})); if (!response.ok || data.success === false) { const message = data.error || data.message || `请求失败 (${response.status})`; throw new Error(message); } return data; } function updateUrl(path) { const url = new URL(window.location.href); if (path) { url.searchParams.set('path', path); } else { url.searchParams.delete('path'); } window.history.replaceState({}, '', url.pathname + url.search); } async function ensureAncestors(path) { await ensureTreeNode('', false); state.treeExpanded.add(''); if (!path) { renderTree(); return; } const segments = path.split('/').filter(Boolean); let current = ''; for (let index = 0; index < segments.length; index += 1) { const segment = segments[index]; current = current ? `${current}/${segment}` : segment; if (index < segments.length - 1) { state.treeExpanded.add(current); } await ensureTreeNode(current, false); } renderTree(); } async function loadDirectory(path = '', { updateHistory = true } = {}) { hideContextMenu(); showStatus('加载中...'); try { const result = await request(`${API_BASE}/entries?path=${encodeURIComponent(path)}`); const resolvedPath = result.data.path || ''; state.currentPath = resolvedPath; state.items = result.data.items || []; const directoryEntries = state.items.filter((item) => item.type === 'directory'); state.treeCache.set(resolvedPath, directoryEntries); if (updateHistory) { updateUrl(resolvedPath); } await ensureAncestors(resolvedPath); renderBreadcrumb(result.data.breadcrumb || []); renderGrid(); updateSelection([]); state.lastSelectedIndex = null; showStatus(`已加载 ${state.items.length} 项`); } catch (err) { showStatus(err.message); } } function renderBreadcrumb(crumbs) { breadcrumbEl.innerHTML = ''; crumbs.forEach((crumb, index) => { const span = document.createElement('span'); span.textContent = crumb.name; span.dataset.path = crumb.path; span.addEventListener('click', () => { loadDirectory(crumb.path); }); breadcrumbEl.appendChild(span); if (index < crumbs.length - 1) { const sep = document.createElement('span'); sep.textContent = '›'; sep.classList.add('fm-breadcrumb-sep'); breadcrumbEl.appendChild(sep); } }); } function renderGrid() { fileGrid.innerHTML = ''; state.items.forEach((entry, index) => { const card = document.createElement('div'); card.className = 'fm-card'; card.tabIndex = 0; card.dataset.path = entry.path; card.dataset.index = index; if (state.selected.has(entry.path)) { card.classList.add('selected'); } const icon = document.createElement('div'); icon.className = 'fm-card-icon'; icon.textContent = getIcon(entry); const name = document.createElement('div'); name.className = 'fm-card-name'; name.textContent = entry.name; const meta = document.createElement('div'); meta.className = 'fm-card-meta'; const lines = []; if (entry.type === 'file') { lines.push(formatSize(entry.size)); } else { lines.push('目录'); } lines.push(formatTime(entry.modified_at)); meta.innerHTML = lines.join('
'); card.appendChild(icon); card.appendChild(name); card.appendChild(meta); card.addEventListener('click', (event) => handleItemClick(event, entry, index)); card.addEventListener('dblclick', () => handleItemDoubleClick(entry)); card.addEventListener('contextmenu', (event) => handleItemContextMenu(event, entry)); fileGrid.appendChild(card); }); const overlay = document.createElement('div'); overlay.className = 'fm-drop-overlay'; overlay.textContent = '释放即可上传到此目录'; fileGrid.appendChild(overlay); } function updateSelection(paths, options = { append: false, range: false }) { if (!options.append && !options.range) { state.selected.clear(); if (paths.length === 0) { state.lastSelectedIndex = null; } } paths.forEach((path) => { if (state.selected.has(path) && options.append) { state.selected.delete(path); } else { state.selected.add(path); } }); syncSelectionUI(); } function syncSelectionUI() { const cards = fileGrid.querySelectorAll('.fm-card'); cards.forEach((card) => { if (state.selected.has(card.dataset.path)) { card.classList.add('selected'); } else { card.classList.remove('selected'); } }); selectionInfo.textContent = `已选中 ${state.selected.size} 项`; pasteBtn.disabled = !state.clipboard || !state.clipboard.items.length; } function handleItemClick(event, entry, index) { const isMetaKey = event.metaKey || event.ctrlKey; const isShiftKey = event.shiftKey; if (isShiftKey && state.lastSelectedIndex !== null) { const start = Math.min(state.lastSelectedIndex, index); const end = Math.max(state.lastSelectedIndex, index); const paths = state.items.slice(start, end + 1).map((item) => item.path); updateSelection(paths, { range: true }); } else if (isMetaKey) { updateSelection([entry.path], { append: true }); state.lastSelectedIndex = index; } else { updateSelection([entry.path], { append: false }); state.lastSelectedIndex = index; } } function handleItemDoubleClick(entry) { if (entry.type === 'directory') { loadDirectory(entry.path); return; } if (entry.is_editable) { window.location.href = `${EDITOR_PAGE}?path=${encodeURIComponent(entry.path)}`; return; } window.open(`${API_BASE}/download?path=${encodeURIComponent(entry.path)}`, '_blank'); } function handleItemContextMenu(event, entry) { event.preventDefault(); if (!state.selected.has(entry.path)) { updateSelection([entry.path], { append: false }); } showContextMenu(event.clientX, event.clientY); } function showContextMenu(x, y) { const single = state.selected.size === 1; const singleEntry = single ? getSingleSelected() : null; contextMenu.innerHTML = ''; const entries = []; if (singleEntry) { if (singleEntry.type === 'directory') { entries.push({ label: '打开', action: openSelected, disabled: false }); } else if (singleEntry.is_editable) { entries.push({ label: '在编辑器中打开', action: openEditor, disabled: false }); if (singleEntry.extension === '.html' || singleEntry.extension === '.htm') { entries.push({ label: '预览', action: previewSelected, disabled: false }); } entries.push({ label: '下载', action: downloadSelected, disabled: false }); } else { const isHtml = singleEntry.extension === '.html' || singleEntry.extension === '.htm'; if (isHtml) { entries.push({ label: '预览', action: previewSelected, disabled: false }); } entries.push({ label: '下载', action: downloadSelected, disabled: false }); } } else if (state.selected.size > 0) { entries.push({ label: '下载', action: downloadSelected, disabled: false }); } entries.push( { label: '重命名', action: renameSelected, disabled: !single }, { label: '复制', action: copySelected, disabled: state.selected.size === 0 }, { label: '剪切', action: cutSelected, disabled: state.selected.size === 0 }, { label: '粘贴', action: pasteClipboard, disabled: !state.clipboard || !state.clipboard.items.length }, { label: '删除', action: deleteSelected, disabled: state.selected.size === 0 }, ); entries.forEach((item) => { const btn = document.createElement('button'); btn.textContent = item.label; btn.disabled = item.disabled; btn.addEventListener('click', () => { hideContextMenu(); item.action(); }); contextMenu.appendChild(btn); }); contextMenu.style.display = 'block'; const { innerWidth, innerHeight } = window; const menuRect = contextMenu.getBoundingClientRect(); const left = clamp(x, 0, innerWidth - menuRect.width); const top = clamp(y, 0, innerHeight - menuRect.height); contextMenu.style.left = `${left}px`; contextMenu.style.top = `${top}px`; } function hideContextMenu() { contextMenu.style.display = 'none'; } function getSingleSelected() { if (state.selected.size !== 1) return null; const path = Array.from(state.selected)[0]; return state.items.find((item) => item.path === path) || null; } function openSelected() { const entry = getSingleSelected(); if (!entry) return; handleItemDoubleClick(entry); } function previewSelected() { const entry = getSingleSelected(); if (!entry) return; if (entry.extension !== '.html' && entry.extension !== '.htm') { showStatus('仅支持预览 HTML 文件'); return; } window.open(`/file-preview/${encodeURIComponent(entry.path)}`, '_blank'); } function openEditor() { const entry = getSingleSelected(); if (!entry || !entry.is_editable) return; window.location.href = `${EDITOR_PAGE}?path=${encodeURIComponent(entry.path)}`; } function triggerBlobDownload(filename, blob) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = filename; document.body.appendChild(anchor); anchor.click(); anchor.remove(); URL.revokeObjectURL(url); } async function downloadSelected() { if (!state.selected.size) return; if (state.selected.size === 1) { const path = Array.from(state.selected)[0]; window.open(`${API_BASE}/download?path=${encodeURIComponent(path)}`, '_blank'); return; } try { const resp = await fetch(`${API_BASE}/download/batch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paths: Array.from(state.selected) }) }); if (!resp.ok) { let message = '批量下载失败'; try { const data = await resp.json(); message = data.error || data.message || message; } catch (_) { // ignore } throw new Error(message); } const blob = await resp.blob(); triggerBlobDownload(`selected_${Date.now()}.zip`, blob); showStatus(`已开始下载 ${state.selected.size} 个项目`); } catch (err) { showStatus(err.message); } } async function renameSelected() { const entry = getSingleSelected(); if (!entry) return; const newName = await promptDialog('重命名', entry.name); if (!newName) return; try { await request(`${API_BASE}/rename`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: entry.path, new_name: newName }), }); await loadDirectory(state.currentPath); } catch (err) { showStatus(err.message); } } function copySelected() { if (!state.selected.size) return; state.clipboard = { mode: 'copy', items: Array.from(state.selected) }; showStatus(`已复制 ${state.clipboard.items.length} 项`); syncSelectionUI(); } function cutSelected() { if (!state.selected.size) return; state.clipboard = { mode: 'cut', items: Array.from(state.selected) }; showStatus(`已剪切 ${state.clipboard.items.length} 项`); syncSelectionUI(); } async function pasteClipboard() { if (!state.clipboard || !state.clipboard.items.length) return; const endpoint = state.clipboard.mode === 'copy' ? 'copy' : 'move'; try { await request(`${API_BASE}/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paths: state.clipboard.items, target_dir: state.currentPath, }), }); if (state.clipboard.mode === 'cut') { state.clipboard = null; } await loadDirectory(state.currentPath); } catch (err) { showStatus(err.message); } } async function deleteSelected() { if (!state.selected.size) return; const confirm = await confirmDialog(`确认删除选中的 ${state.selected.size} 项吗?该操作不可撤销。`); if (!confirm) return; try { await request(`${API_BASE}/delete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paths: Array.from(state.selected) }), }); await loadDirectory(state.currentPath); } catch (err) { showStatus(err.message); } } function promptDialog(title, defaultValue = '') { return new Promise((resolve) => { dialogTitle.textContent = title; dialogContent.innerHTML = ''; const input = document.createElement('input'); input.value = defaultValue; input.autofocus = true; dialogContent.appendChild(input); const finish = (value) => { closeDialog(); resolve(value); }; registerDialogHandlers(() => finish(input.value.trim()), () => finish(null)); dialogBackdrop.hidden = false; input.addEventListener('keydown', (evt) => { if (evt.key === 'Enter') { evt.preventDefault(); finish(input.value.trim()); } else if (evt.key === 'Escape') { evt.preventDefault(); finish(null); } }); setTimeout(() => input.select(), 50); }); } function confirmDialog(message) { return new Promise((resolve) => { dialogTitle.textContent = '确认操作'; dialogContent.innerHTML = `

${message}

`; const finish = (value) => { closeDialog(); resolve(value); }; registerDialogHandlers(() => finish(true), () => finish(false)); dialogBackdrop.hidden = false; }); } function handleGlobalClick(event) { if (!contextMenu.contains(event.target)) { hideContextMenu(); } } function handleGridBackgroundClick(event) { if (event.target === fileGrid) { if (state.selectionJustFinished) { return; } updateSelection([]); } } function handleDragEnter(event) { event.preventDefault(); fileGrid.classList.add('drop-target'); } function handleDragOver(event) { event.preventDefault(); } function handleDragLeave(event) { if (event.target === fileGrid) { fileGrid.classList.remove('drop-target'); } } async function handleDrop(event) { event.preventDefault(); fileGrid.classList.remove('drop-target'); if (!event.dataTransfer || !event.dataTransfer.files.length) return; const files = event.dataTransfer.files; await uploadFiles(files, state.currentPath); } async function uploadFiles(fileList, targetPath) { for (const file of fileList) { const form = new FormData(); form.append('file', file, file.name); form.append('filename', file.name); form.append('path', targetPath); try { await request(`${API_BASE}/upload`, { method: 'POST', body: form, }); showStatus(`已上传 ${file.name}`); } catch (err) { showStatus(`上传失败:${err.message}`); } } await loadDirectory(state.currentPath); } function initSelectionRectangle() { fileGrid.addEventListener('pointerdown', (event) => { if (state.selectionDisabled) return; if (event.target !== fileGrid) return; state.isDraggingSelection = true; state.dragStart = { x: event.clientX, y: event.clientY }; state.selectionRect = document.createElement('div'); state.selectionRect.className = 'fm-selection-rect'; fileGrid.appendChild(state.selectionRect); updateSelection([]); state.selectionJustFinished = false; fileGrid.setPointerCapture(event.pointerId); }); fileGrid.addEventListener('pointermove', (event) => { if (!state.isDraggingSelection || !state.selectionRect) return; const rect = fileGrid.getBoundingClientRect(); const current = { x: event.clientX, y: event.clientY }; const x = Math.min(state.dragStart.x, current.x) - rect.left + fileGrid.scrollLeft; const y = Math.min(state.dragStart.y, current.y) - rect.top + fileGrid.scrollTop; const width = Math.abs(state.dragStart.x - current.x); const height = Math.abs(state.dragStart.y - current.y); Object.assign(state.selectionRect.style, { left: `${x}px`, top: `${y}px`, width: `${width}px`, height: `${height}px`, }); const selectionBox = { left: Math.min(state.dragStart.x, current.x), right: Math.max(state.dragStart.x, current.x), top: Math.min(state.dragStart.y, current.y), bottom: Math.max(state.dragStart.y, current.y), }; const selected = []; const cards = fileGrid.querySelectorAll('.fm-card'); cards.forEach((card) => { const bounds = card.getBoundingClientRect(); const intersects = !(selectionBox.right < bounds.left || selectionBox.left > bounds.right || selectionBox.bottom < bounds.top || selectionBox.top > bounds.bottom); if (intersects) { selected.push(card.dataset.path); } }); updateSelection(selected); }); fileGrid.addEventListener('pointerup', (event) => { if (!state.isDraggingSelection) return; state.isDraggingSelection = false; if (state.selectionRect) { state.selectionRect.remove(); state.selectionRect = null; } state.selectionJustFinished = true; requestAnimationFrame(() => { state.selectionJustFinished = false; }); fileGrid.releasePointerCapture(event.pointerId); }); } async function ensureTreeNode(path, shouldRender = true) { let changed = false; if (!state.treeCache.has(path)) { try { const result = await request(`${API_BASE}/entries?path=${encodeURIComponent(path)}`); const directories = result.data.items.filter((item) => item.type === 'directory'); state.treeCache.set(path, directories); changed = true; } catch (err) { showStatus(err.message); } } if (shouldRender && changed) { renderTree(); } return changed; } function renderTree() { directoryTree.innerHTML = ''; const rootNode = createTreeNode('', '根目录'); directoryTree.appendChild(rootNode); } function createTreeNode(path, name) { const li = document.createElement('li'); const header = document.createElement('div'); header.className = 'fm-tree-item'; if (path === state.currentPath) { header.classList.add('active'); } const toggle = document.createElement('span'); toggle.className = 'fm-tree-toggle'; toggle.textContent = state.treeExpanded.has(path) ? '▾' : '▸'; toggle.addEventListener('click', async (event) => { event.stopPropagation(); if (state.treeExpanded.has(path)) { state.treeExpanded.delete(path); } else { state.treeExpanded.add(path); await ensureTreeNode(path, false); } renderTree(); }); const label = document.createElement('span'); label.textContent = name; label.addEventListener('click', () => loadDirectory(path)); header.addEventListener('click', () => loadDirectory(path)); header.appendChild(toggle); header.appendChild(label); li.appendChild(header); if (state.treeExpanded.has(path)) { const children = document.createElement('ul'); children.className = 'fm-tree-children'; const dirs = state.treeCache.get(path) || []; dirs.forEach((dir) => { const child = createTreeNode(dir.path, dir.name); children.appendChild(child); }); li.appendChild(children); } return li; } function bindToolbar() { newFolderBtn.addEventListener('click', async () => { const name = await promptDialog('新建文件夹', '新建文件夹'); if (!name) return; try { await request(`${API_BASE}/create`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: state.currentPath, name, type: 'directory', }), }); await loadDirectory(state.currentPath); } catch (err) { showStatus(err.message); } }); newFileBtn.addEventListener('click', async () => { const name = await promptDialog('新建文件', '新建文件.txt'); if (!name) return; try { await request(`${API_BASE}/create`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: state.currentPath, name, type: 'file', }), }); await loadDirectory(state.currentPath); } catch (err) { showStatus(err.message); } }); refreshBtn.addEventListener('click', () => loadDirectory(state.currentPath)); uploadBtn.addEventListener('click', () => hiddenUploader.click()); backBtn.addEventListener('click', () => { if (!state.currentPath) { loadDirectory(''); return; } const segments = state.currentPath.split('/').filter(Boolean); if (segments.length === 0) { loadDirectory(''); return; } segments.pop(); const parentPath = segments.join('/'); loadDirectory(parentPath); }); returnChatBtn.addEventListener('click', () => { window.location.href = '/new'; }); downloadBtn.addEventListener('click', downloadSelected); renameBtn.addEventListener('click', renameSelected); copyBtn.addEventListener('click', copySelected); cutBtn.addEventListener('click', cutSelected); pasteBtn.addEventListener('click', pasteClipboard); deleteBtn.addEventListener('click', deleteSelected); toggleSelectionBtn.addEventListener('click', () => { state.selectionDisabled = !state.selectionDisabled; toggleSelectionBtn.textContent = state.selectionDisabled ? '启用框选' : '禁用框选'; const msg = state.selectionDisabled ? '已禁用框选' : '已启用框选'; showStatus(msg); }); hiddenUploader.addEventListener('change', async (event) => { const files = event.target.files; if (files && files.length) { await uploadFiles(files, state.currentPath); } hiddenUploader.value = ''; }); } function bindGlobalEvents() { document.addEventListener('click', handleGlobalClick); fileGrid.addEventListener('click', handleGridBackgroundClick); fileGrid.addEventListener('contextmenu', (event) => { if (event.target === fileGrid) { event.preventDefault(); if (state.selected.size) { showContextMenu(event.clientX, event.clientY); } } }); fileGrid.addEventListener('dragenter', handleDragEnter); fileGrid.addEventListener('dragover', handleDragOver); fileGrid.addEventListener('dragleave', handleDragLeave); fileGrid.addEventListener('drop', handleDrop); initSelectionRectangle(); } async function bootstrap() { bindToolbar(); bindGlobalEvents(); await loadDirectory(initialPathParam, { updateHistory: false }); } bootstrap().catch((err) => { console.error(err); showStatus(err.message); }); })();