900 lines
33 KiB
JavaScript
900 lines
33 KiB
JavaScript
(() => {
|
||
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('<br>');
|
||
|
||
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 = `<p>${message}</p>`;
|
||
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);
|
||
});
|
||
})();
|