(() => { 'use strict'; const THEME_STORAGE_KEY = 'memind-theme'; const THEME_MIGRATION_KEY = 'memind-theme-migration'; const THEME_MIGRATION_VERSION = 'light-default-2026-09-12'; const FORCE_DARK_MIGRATION_VERSION = 'dark-default-2026-09-11'; const DEFAULT_THEME = 'light'; function migrateThemePreferenceIfNeeded() { if (localStorage.getItem(THEME_MIGRATION_KEY) === THEME_MIGRATION_VERSION) return; const previousMigration = localStorage.getItem(THEME_MIGRATION_KEY); const stored = localStorage.getItem(THEME_STORAGE_KEY); if (previousMigration === FORCE_DARK_MIGRATION_VERSION && stored !== 'light') { localStorage.setItem(THEME_STORAGE_KEY, 'light'); } localStorage.setItem(THEME_MIGRATION_KEY, THEME_MIGRATION_VERSION); } function getStoredTheme() { const stored = localStorage.getItem(THEME_STORAGE_KEY); if (stored === 'dark' || stored === 'light') return stored; return DEFAULT_THEME; } function applyTheme(theme) { const normalized = theme === 'dark' ? 'dark' : 'light'; document.documentElement.dataset.theme = normalized; document.documentElement.style.colorScheme = normalized; } function updateThemeToggleLabel() { const themeToggle = document.getElementById('themeToggle'); if (!themeToggle) return; themeToggle.textContent = getStoredTheme() === 'light' ? '深色' : '浅色'; themeToggle.title = getStoredTheme() === 'light' ? '切换深色模式' : '切换浅色模式'; } function toggleTheme() { const next = getStoredTheme() === 'light' ? 'dark' : 'light'; localStorage.setItem(THEME_STORAGE_KEY, next); applyTheme(next); updateThemeToggleLabel(); window.dispatchEvent(new CustomEvent('memind-theme-change', { detail: { theme: next } })); return next; } function initTheme() { migrateThemePreferenceIfNeeded(); applyTheme(getStoredTheme()); updateThemeToggleLabel(); window.addEventListener('storage', (event) => { if (event.key === THEME_STORAGE_KEY) { applyTheme(getStoredTheme()); updateThemeToggleLabel(); } }); window.addEventListener('memind-theme-change', (event) => { const detail = event.detail; if (detail?.theme) { applyTheme(detail.theme); updateThemeToggleLabel(); } }); const themeToggle = document.getElementById('themeToggle'); if (themeToggle) { themeToggle.addEventListener('click', () => toggleTheme()); } } initTheme(); const API_BASE = '/api/mindspace/v1/oa/drive'; const fetchOpts = { credentials: 'same-origin' }; /* ── DOM refs ── */ const $ = (id) => document.getElementById(id); const dropZone = $('dropZone'); const fileInput = $('fileInput'); const folderInput = $('folderInput'); const statusEl = $('status'); const progressEl = $('progress'); const progressBar = progressEl.querySelector('i'); const progressDetail = $('progressDetail'); const progressSummary = $('progressSummary'); const progressSpeed = $('progressSpeed'); const progressList = $('progressList'); const fileTreeEl = $('fileTree'); const fileCountEl = $('fileCount'); const linksEl = $('links'); const syncBadgeEl = $('syncBadge'); const syncTextEl = $('syncText'); const previewModal = $('previewModal'); const previewTitle = $('previewTitle'); const previewBody = $('previewBody'); const previewDownload = $('previewDownload'); const previewOpen = $('previewOpen'); const selectAllCheck = $('selectAllCheck'); const bulkDeleteBtn = $('bulkDeleteBtn'); const downloadZipBtn = $('downloadZipBtn'); const breadcrumbEl = $('breadcrumb'); const fileListEl = $('fileList'); const viewListBtn = $('viewListBtn'); const viewIconsBtn = $('viewIconsBtn'); const panelShell = $('filePanelShell'); const filePanel = $('filePanel'); const panelResizer = $('panelResizer'); const searchInput = $('searchInput'); const conflictSelect = $('conflictSelect'); const notifyBtn = $('notifyBtn'); const dragOverlay = $('dragOverlay'); const dragOverlayHint = $('dragOverlayHint'); const detailModal = $('detailModal'); const detailGrid = $('detailGrid'); const detailTitle = $('detailTitle'); const conflictModal = $('conflictModal'); const conflictList = $('conflictList'); const trashDrawer = $('trashDrawer'); const trashBackdrop = $('trashBackdrop'); const trashBody = $('trashBody'); /* ── Storage keys ── */ const VIEW_KEY = 'file-drop-sync-view-mode'; const PANEL_SIZE_KEY = 'file-drop-sync-panel-size'; const SORT_KEY = 'file-drop-sync-sort-key'; const SORT_ASC_KEY = 'file-drop-sync-sort-asc'; const PATH_KEY = 'file-drop-sync-current-path'; const ON_CONFLICT_KEY = 'file-drop-sync-on-conflict'; const CLIENT_ID_KEY = 'fds-client-id'; const CLIENT_LABEL_KEY = 'fds-client-label'; const NOTIFY_KEY = 'fds-notify-enabled'; const DEFAULT_PANEL_HEIGHT = 520; const MIN_PANEL_WIDTH = 520; const MIN_PANEL_HEIGHT = 280; const CHUNK_THRESHOLD = 8 * 1024 * 1024; /* ── State ── */ let serverInfo = { pageSize: 100, chunkSize: 4 * 1024 * 1024 }; let lastSnapshot = ''; let currentFiles = []; let currentDirs = []; let currentPath = localStorage.getItem(PATH_KEY) || ''; let sortKey = localStorage.getItem(SORT_KEY) || 'name'; let sortAsc = localStorage.getItem(SORT_ASC_KEY) !== 'false'; let onConflict = localStorage.getItem(ON_CONFLICT_KEY) || 'rename'; let viewMode = localStorage.getItem(VIEW_KEY) === 'icons' ? 'icons' : 'list'; let searchQuery = ''; let displayLimit = 100; let selected = new Set(); let visibleKeys = []; let lastSelectedIndex = -1; let renameKey = null; let sseConnected = false; let lastSyncTime = null; let dragDepth = 0; let dragTargetFolder = ''; let internalClipboard = null; let pendingConflictResolve = null; let uploadAbort = false; let filesHydrated = false; let currentUser = null; let panelSize = (() => { try { const saved = JSON.parse(localStorage.getItem(PANEL_SIZE_KEY) || 'null'); if (saved && Number(saved.height) >= MIN_PANEL_HEIGHT) { return { width: saved.width ? Number(saved.width) : null, height: Number(saved.height) }; } } catch {} return { width: null, height: DEFAULT_PANEL_HEIGHT }; })(); /* ── Client identity ── */ function generateUuid() { if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID(); if (globalThis.crypto?.getRandomValues) { const bytes = new Uint8Array(16); globalThis.crypto.getRandomValues(bytes); bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join(''); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } return `fds-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } function getClientId() { let id = localStorage.getItem(CLIENT_ID_KEY); if (!id) { id = generateUuid(); localStorage.setItem(CLIENT_ID_KEY, id); } return id; } function getClientLabel() { let label = localStorage.getItem(CLIENT_LABEL_KEY); if (!label) { label = `浏览器 · ${navigator.platform || 'Web'}`; localStorage.setItem(CLIENT_LABEL_KEY, label); } return label; } function clientHeaders(extra = {}) { return { 'X-Fds-Client-Id': getClientId(), 'X-Fds-Client-Label': encodeURIComponent(getClientLabel()), ...extra, }; } /* ── Utilities ── */ function fmtSize(n) { if (n == null || n === '') return '—'; if (n < 1024) return n + ' B'; if (n < 1024 ** 2) return (n / 1024).toFixed(1) + ' KB'; if (n < 1024 ** 3) return (n / 1024 ** 2).toFixed(1) + ' MB'; return (n / 1024 ** 3).toFixed(2) + ' GB'; } function fmtTime(ms) { if (!ms) return '—'; const d = new Date(ms); return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; } function fmtSpeed(bytesPerSec) { if (!bytesPerSec || !Number.isFinite(bytesPerSec)) return '—'; return fmtSize(bytesPerSec) + '/s'; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', })[c]); } function setStatus(text, type = '') { statusEl.textContent = text; statusEl.className = 'status ' + type; } function redirectToLogin() { const returnTo = encodeURIComponent('/space/oa/drive'); const target = window.top && window.top !== window ? window.top : window; target.location.href = `/?return_to=${returnTo}`; } async function ensureAuthenticated() { const res = await fetch('/auth/status', fetchOpts); if (!res.ok) { redirectToLogin(); return null; } const data = await res.json(); if (!data.authenticated || !data.user?.id) { redirectToLogin(); return null; } return data.user; } function renderUserBanner(user, quota) { if (!linksEl || !user) return; const name = user.displayName || user.username || user.slug || '用户'; const chips = [`${escapeHtml(name)} 的 OA 网盘`]; if (quota) { chips.push(`剩余 ${fmtSize(quota.availableBytes)} / ${fmtSize(quota.quotaBytes)}`); } linksEl.innerHTML = chips.join(''); } async function driveFetch(url, opts = {}) { const res = await fetch(url, { ...fetchOpts, ...opts }); if (res.status === 401 || res.status === 403) { redirectToLogin(); throw new Error('未登录'); } return res; } function viewUrl(name) { return API_BASE + '/view/' + encodeURIComponent(name).replace(/%2F/g, '/'); } function downloadUrl(name) { return API_BASE + '/download/' + encodeURIComponent(name).replace(/%2F/g, '/'); } function extOf(name) { const i = name.lastIndexOf('.'); return i >= 0 ? name.slice(i + 1).toLowerCase() : ''; } function fileIconClass(name) { const ext = extOf(name); if (['xlsx', 'xls', 'csv'].includes(ext)) return 'sheet'; if (['doc', 'docx'].includes(ext)) return 'word'; if (ext === 'pdf') return 'pdf'; if (['mp4', 'webm', 'mov'].includes(ext)) return 'video'; return 'doc'; } function isImageFile(name) { return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(extOf(name)); } function rowKey(row) { return row.type === 'folder' ? row.fullPath : row.fullName; } function parentPath(key) { const i = key.lastIndexOf('/'); return i >= 0 ? key.slice(0, i) : ''; } function persistPath() { localStorage.setItem(PATH_KEY, currentPath); } function persistSort() { localStorage.setItem(SORT_KEY, sortKey); localStorage.setItem(SORT_ASC_KEY, String(sortAsc)); } function updateSyncBadge(connected) { sseConnected = connected; syncBadgeEl.classList.toggle('offline', !connected); if (!connected) { syncTextEl.textContent = '离线 · 重连中…'; return; } if (lastSyncTime) { const t = lastSyncTime.toLocaleTimeString('zh-CN', { hour12: false }); syncTextEl.textContent = `已连接 · ${t}`; } else { syncTextEl.textContent = '已连接'; } } /* ── Panel resize ── */ function maxPanelHeight() { return Math.max(MIN_PANEL_HEIGHT, Math.floor(window.innerHeight * 0.85)); } function maxPanelWidth() { const wrap = panelShell.parentElement; return Math.max(MIN_PANEL_WIDTH, (wrap?.clientWidth || window.innerWidth - 32)); } function applyPanelSize() { filePanel.style.height = `${Math.min(panelSize.height, maxPanelHeight())}px`; if (panelSize.width) { panelShell.style.width = `${Math.min(panelSize.width, maxPanelWidth())}px`; } else { panelShell.style.width = ''; } } function savePanelSize() { localStorage.setItem(PANEL_SIZE_KEY, JSON.stringify(panelSize)); } function initPanelResize() { applyPanelSize(); panelResizer.addEventListener('mousedown', (e) => { e.preventDefault(); const startX = e.clientX; const startY = e.clientY; const rect = panelShell.getBoundingClientRect(); const startW = rect.width; const startH = filePanel.getBoundingClientRect().height; document.body.classList.add('panel-shell-resizing'); const onMove = (ev) => { panelSize.width = Math.min(maxPanelWidth(), Math.max(MIN_PANEL_WIDTH, Math.round(startW + ev.clientX - startX))); panelSize.height = Math.min(maxPanelHeight(), Math.max(MIN_PANEL_HEIGHT, Math.round(startH + ev.clientY - startY))); applyPanelSize(); }; const onUp = () => { document.body.classList.remove('panel-shell-resizing'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); savePanelSize(); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); window.addEventListener('resize', () => { if (panelSize.width && panelSize.width > maxPanelWidth()) { panelSize.width = maxPanelWidth(); applyPanelSize(); savePanelSize(); } if (panelSize.height > maxPanelHeight()) { panelSize.height = maxPanelHeight(); applyPanelSize(); savePanelSize(); } }); } /* ── Tree building ── */ function folderSize(node) { let total = 0; for (const f of node.files) total += f.size; for (const child of node.dirs.values()) total += folderSize(child); return total; } function folderMtime(node) { let max = 0; for (const f of node.files) max = Math.max(max, f.mtime); for (const child of node.dirs.values()) max = Math.max(max, folderMtime(child)); return max; } function buildTree(files, dirs = []) { const root = { name: '', dirs: new Map(), files: [] }; for (const dirPath of dirs) { const parts = dirPath.split('/'); let node = root; for (const part of parts) { if (!node.dirs.has(part)) node.dirs.set(part, { name: part, dirs: new Map(), files: [] }); node = node.dirs.get(part); } } for (const f of files) { const parts = f.name.split('/'); let node = root; for (let i = 0; i < parts.length - 1; i++) { if (!node.dirs.has(parts[i])) node.dirs.set(parts[i], { name: parts[i], dirs: new Map(), files: [] }); node = node.dirs.get(parts[i]); } node.files.push({ ...f, baseName: parts[parts.length - 1] }); } return root; } function getNodeAtPath(tree, path) { if (!path) return tree; let node = tree; for (const part of path.split('/')) { if (!node.dirs.has(part)) return null; node = node.dirs.get(part); } return node; } function compareRows(a, b) { let va = a.sortVal[sortKey]; let vb = b.sortVal[sortKey]; if (sortKey === 'name') { va = String(va).toLowerCase(); vb = String(vb).toLowerCase(); } if (va < vb) return sortAsc ? -1 : 1; if (va > vb) return sortAsc ? 1 : -1; return 0; } function originBadgeHtml(origin) { if (!origin?.clientLabel) return ''; const self = origin.clientId === getClientId(); return `${escapeHtml(origin.clientLabel)}`; } /* ── Selection ── */ function updateSelectionUi() { const count = selected.size; bulkDeleteBtn.disabled = count === 0; bulkDeleteBtn.textContent = count ? `删除所选 (${count})` : '删除所选'; downloadZipBtn.disabled = count === 0; const allSelected = visibleKeys.length > 0 && visibleKeys.every((k) => selected.has(k)); const someSelected = visibleKeys.some((k) => selected.has(k)); selectAllCheck.checked = allSelected; selectAllCheck.indeterminate = !allSelected && someSelected; fileTreeEl.querySelectorAll('.list-row, .icon-item').forEach((el) => { const key = el.dataset.key; el.classList.toggle('selected', key ? selected.has(key) : false); const cb = el.querySelector('.row-check, .item-check'); if (cb) cb.checked = key ? selected.has(key) : false; }); } function syncCheckboxSelection(checkbox, shiftKey = false) { const rowEl = checkbox.closest('[data-key]'); if (!rowEl) return; const key = rowEl.dataset.key; const index = visibleKeys.indexOf(key); if (index < 0) return; if (shiftKey && lastSelectedIndex >= 0) { const start = Math.min(lastSelectedIndex, index); const end = Math.max(lastSelectedIndex, index); setSelected(visibleKeys.slice(start, end + 1), 'add'); } else if (checkbox.checked) { setSelected([key], 'add'); } else { selected.delete(key); updateSelectionUi(); } lastSelectedIndex = index; } function setSelected(keys, mode = 'replace') { if (mode === 'replace') selected = new Set(keys); else if (mode === 'toggle') { for (const key of keys) { if (selected.has(key)) selected.delete(key); else selected.add(key); } } else if (mode === 'add') { for (const key of keys) selected.add(key); } updateSelectionUi(); } function selectAllVisible() { setSelected(visibleKeys, 'replace'); lastSelectedIndex = visibleKeys.length - 1; } function clearSelection() { selected.clear(); lastSelectedIndex = -1; updateSelectionUi(); } function handleRowSelect(e, key, index) { if (e.metaKey || e.ctrlKey) { setSelected([key], 'toggle'); lastSelectedIndex = index; return; } if (e.shiftKey && lastSelectedIndex >= 0) { const start = Math.min(lastSelectedIndex, index); const end = Math.max(lastSelectedIndex, index); setSelected(visibleKeys.slice(start, end + 1), 'add'); return; } setSelected([key], 'replace'); lastSelectedIndex = index; } /* ── View mode ── */ function setViewMode(mode) { viewMode = mode === 'icons' ? 'icons' : 'list'; localStorage.setItem(VIEW_KEY, viewMode); fileListEl.classList.toggle('icon-mode', viewMode === 'icons'); viewListBtn.classList.toggle('active', viewMode === 'list'); viewIconsBtn.classList.toggle('active', viewMode === 'icons'); lastSnapshot = ''; renderFiles(currentFiles); } function updateSortHeader() { document.querySelectorAll('.list-header .sortable').forEach((el) => { const key = el.dataset.sort; el.classList.toggle('active', key === sortKey); let mark = el.querySelector('.sort-mark'); if (key === sortKey) { if (!mark) { mark = document.createElement('span'); mark.className = 'sort-mark'; el.appendChild(document.createTextNode(' ')); el.appendChild(mark); } mark.textContent = sortAsc ? '▲' : '▼'; } else if (mark) { mark.remove(); } }); } /* ── Breadcrumb ── */ function updateBreadcrumb() { if (!currentPath) { breadcrumbEl.innerHTML = '全部文件'; return; } const parts = currentPath.split('/'); let html = ''; let acc = ''; for (const part of parts) { acc = acc ? `${acc}/${part}` : part; html += '/'; if (acc === currentPath) { html += `${escapeHtml(part)}`; } else { html += ``; } } breadcrumbEl.innerHTML = html; } function enterFolder(path) { currentPath = path; persistPath(); displayLimit = serverInfo.pageSize || 100; clearSelection(); lastSnapshot = ''; renderFiles(currentFiles); } /* ── Render ── */ function renderFolderContents(node, pathPrefix = '') { const siblings = []; for (const [dirName, child] of node.dirs) { const fullPath = pathPrefix ? `${pathPrefix}/${dirName}` : dirName; siblings.push({ type: 'folder', name: dirName, fullPath, size: folderSize(child), mtime: folderMtime(child), sortVal: { name: dirName, size: folderSize(child), mtime: folderMtime(child) }, }); } for (const f of node.files) { siblings.push({ type: 'file', name: f.baseName, fullName: f.name, size: f.size, mtime: f.mtime, previewable: f.previewable, origin: f.origin, sortVal: { name: f.baseName, size: f.size, mtime: f.mtime }, }); } siblings.sort(compareRows); if (searchQuery) { const q = searchQuery.toLowerCase(); return siblings.filter((r) => r.name.toLowerCase().includes(q)); } return siblings; } function listIconHtml(row) { if (row.type === 'folder') return ''; return ``; } function iconThumbHtml(row) { if (row.type === 'folder') return ''; if (isImageFile(row.fullName)) { return ``; } return ``; } function rowHtml(row) { const key = rowKey(row); const isRenaming = renameKey === key; const originHtml = row.type === 'file' ? originBadgeHtml(row.origin) : ''; if (row.type === 'folder') { const nameCell = isRenaming ? `` : `${escapeHtml(row.name)}`; return `
▸ ${listIconHtml(row)} ${nameCell}
${fmtSize(row.size)}
${fmtTime(row.mtime)}
`; } const cls = row.previewable ? 'previewable' : ''; const nameCell = isRenaming ? `` : `${escapeHtml(row.name)}`; return `
${listIconHtml(row)} ${nameCell} ${originHtml}
${fmtSize(row.size)}
${fmtTime(row.mtime)}
下载
`; } function iconHtml(row) { const key = rowKey(row); const isRenaming = renameKey === key; const originHtml = row.type === 'file' && row.origin?.clientLabel ? `
${escapeHtml(row.origin.clientLabel)}
` : ''; if (row.type === 'folder') { const nameInner = isRenaming ? `` : escapeHtml(row.name); return `
${iconThumbHtml(row)}
${nameInner}
${fmtSize(row.size)}
`; } const cls = row.previewable ? 'previewable' : ''; const nameInner = isRenaming ? `` : row.previewable ? `${escapeHtml(row.name)}` : escapeHtml(row.name); return `
${iconThumbHtml(row)}
${nameInner}
${fmtSize(row.size)}
${originHtml}
下载
`; } function renderFiles(files, meta = {}) { if (Array.isArray(files)) currentFiles = files; if (Array.isArray(meta.dirs)) currentDirs = meta.dirs; const snapshot = JSON.stringify(currentFiles.length) + '|' + currentPath + '|' + sortKey + '|' + sortAsc + '|' + viewMode + '|' + searchQuery + '|' + displayLimit + '|' + renameKey; if (snapshot === lastSnapshot && !meta.force) return; lastSnapshot = snapshot; const tree = buildTree(currentFiles, currentDirs); let node = getNodeAtPath(tree, currentPath); if (!node) { currentPath = ''; persistPath(); node = tree; } const allRows = renderFolderContents(node, currentPath); const totalCount = allRows.length; const rows = allRows.slice(0, displayLimit); const hasMore = totalCount > displayLimit; fileCountEl.textContent = searchQuery ? `${rows.length}/${totalCount} 项` : `${totalCount} 项`; updateBreadcrumb(); if (meta.added?.length) { setStatus(`已同步 ${meta.added.length} 个新文件`, 'ok'); notifyExternalChanges(meta.added, meta.files || currentFiles); } if (!allRows.length) { const backBtn = currentPath ? `` : ''; const emptyHtml = currentPath ? `
📥
此文件夹为空
拖入文件到此处上传,或返回上级查看其它文件
${backBtn}
` : `
还没有文件,先拖一个进来吧${backBtn}
`; fileTreeEl.innerHTML = emptyHtml; visibleKeys = []; clearSelection(); updateSortHeader(); return; } visibleKeys = rows.map(rowKey); for (const key of [...selected]) { if (!visibleKeys.includes(key)) selected.delete(key); } let html = rows.map(viewMode === 'icons' ? iconHtml : rowHtml).join(''); if (hasMore) { html += `
`; } fileTreeEl.innerHTML = html; updateSortHeader(); updateSelectionUi(); if (renameKey) { const input = fileTreeEl.querySelector('.rename-input'); if (input) { input.focus(); input.select(); } } } /* ── File list merge (SSE delta) ── */ function mergeFilePayload(payload) { const useFullSnapshot = !payload.delta || !filesHydrated; if (useFullSnapshot) { return { files: payload.files || [], dirs: payload.dirs || [] }; } const byName = new Map(currentFiles.map((f) => [f.name, f])); for (const name of payload.removed || []) byName.delete(name); for (const f of payload.files || []) byName.set(f.name, f); const dirs = payload.dirs || currentDirs; return { files: [...byName.values()], dirs }; } function applyFilePayload(payload) { const merged = mergeFilePayload(payload); filesHydrated = true; lastSyncTime = new Date(); updateSyncBadge(true); lastSnapshot = ''; renderFiles(merged.files, { ...payload, dirs: merged.dirs, files: merged.files }); } /* ── API: load info ── */ async function loadInfo() { const res = await driveFetch(API_BASE + '/info'); if (!res.ok) throw new Error(`HTTP ${res.status}`); serverInfo = await res.json(); displayLimit = serverInfo.pageSize || 100; renderUserBanner(currentUser, serverInfo.quota); } async function refreshFiles() { try { const res = await driveFetch(API_BASE + '/files'); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); applyFilePayload({ ...data, delta: false }); } catch (err) { setStatus(`无法加载文件:${err.message}`, 'err'); } } /* ── SSE ── */ function connectEvents() { const es = new EventSource(API_BASE + '/events'); es.onopen = () => updateSyncBadge(true); es.onmessage = (e) => { try { applyFilePayload(JSON.parse(e.data)); } catch {} }; es.onerror = () => { updateSyncBadge(false); es.close(); setTimeout(connectEvents, 2000); }; } /* ── Notifications ── */ function isNotifyEnabled() { return localStorage.getItem(NOTIFY_KEY) === 'true'; } async function requestNotifyPermission() { if (!('Notification' in window)) { setStatus('此浏览器不支持桌面通知', 'err'); return false; } const perm = await Notification.requestPermission(); const enabled = perm === 'granted'; localStorage.setItem(NOTIFY_KEY, String(enabled)); notifyBtn.classList.toggle('active', enabled); return enabled; } function notifyExternalChanges(addedNames, allFiles) { if (!isNotifyEnabled() || Notification.permission !== 'granted') return; const myId = getClientId(); const external = addedNames.filter((name) => { const f = allFiles.find((x) => x.name === name); return f?.origin?.clientId && f.origin.clientId !== myId; }); if (!external.length) return; const label = external.length === 1 ? external[0].split('/').pop() : `${external.length} 个文件`; const origin = allFiles.find((x) => x.name === external[0])?.origin?.clientLabel || '其他设备'; new Notification('文件互传 · 新文件', { body: `${origin} 添加了 ${label}`, icon: '/favicon.ico', }); } /* ── Conflict modal ── */ function showConflictModal(conflicts) { return new Promise((resolve) => { conflictList.innerHTML = conflicts.map((c) => `
${escapeHtml(c.name)}
${escapeHtml(c.path)}
` ).join(''); const chosen = document.querySelector('input[name="conflictChoice"]:checked'); if (chosen) chosen.checked = true; conflictModal.classList.add('show'); pendingConflictResolve = resolve; }); } function closeConflictModal(result) { conflictModal.classList.remove('show'); if (pendingConflictResolve) { const choice = document.querySelector('input[name="conflictChoice"]:checked')?.value || 'rename'; pendingConflictResolve(result === false ? null : choice); pendingConflictResolve = null; } } function detectLocalConflicts(fileEntries, prefix) { const existing = new Set(currentFiles.map((f) => f.name)); const conflicts = []; for (const { file, relPath } of fileEntries) { let fullPath = relPath.replace(/\\/g, '/'); if (prefix) fullPath = `${prefix}/${fullPath}`.replace(/\/+/g, '/'); if (existing.has(fullPath)) { conflicts.push({ name: file.name, path: fullPath }); } } return conflicts; } /* ── Upload progress UI ── */ function createProgressItem(name) { const id = 'pi-' + Math.random().toString(36).slice(2, 8); const el = document.createElement('div'); el.className = 'progress-item'; el.id = id; el.innerHTML = ` ${escapeHtml(name)} 0% 等待
`; progressList.appendChild(el); return { id, el, setProgress(pct, speed) { el.querySelector('.progress-item-bar i').style.width = `${Math.round(pct * 100)}%`; el.querySelector('.progress-item-meta').textContent = `${Math.round(pct * 100)}% · ${fmtSpeed(speed)}`; }, setStatus(text, type = '') { const s = el.querySelector('.progress-item-status'); s.textContent = text; s.className = 'progress-item-status' + (type ? ' ' + type : ''); }, }; } function showUploadProgress(show) { progressEl.classList.toggle('show', show); progressDetail.classList.toggle('show', show); if (!show) { progressBar.style.width = '0%'; progressList.innerHTML = ''; } } /* ── Upload: single file XHR ── */ function uploadSingleXHR(file, relPath, prefix, conflictPolicy, onProgress) { return new Promise((resolve, reject) => { const form = new FormData(); if (prefix) form.append('prefix', prefix); form.append('file', file, relPath); const xhr = new XMLHttpRequest(); xhr.open('POST', `${API_BASE}/upload?onConflict=${encodeURIComponent(conflictPolicy)}`); for (const [k, v] of Object.entries(clientHeaders())) { try { xhr.setRequestHeader(k, v); } catch (err) { console.warn('skip header', k, err); } } let lastLoaded = 0; let lastTime = Date.now(); xhr.upload.onprogress = (e) => { if (e.lengthComputable) { const now = Date.now(); const dt = (now - lastTime) / 1000; const speed = dt > 0 ? (e.loaded - lastLoaded) / dt : 0; lastLoaded = e.loaded; lastTime = now; onProgress(e.loaded / e.total, speed); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { try { resolve(JSON.parse(xhr.responseText)); } catch { resolve({ saved: [relPath], skipped: [] }); } } else reject(new Error(xhr.responseText || xhr.statusText)); }; xhr.onerror = () => reject(new Error('网络错误')); xhr.send(form); }); } /* ── Upload: chunked session ── */ async function uploadChunked(file, relPath, prefix, conflictPolicy, onProgress) { const sessionRes = await fetch(API_BASE + '/upload/session', { ...fetchOpts, method: 'POST', headers: clientHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ name: relPath, size: file.size, prefix }), }); if (!sessionRes.ok) throw new Error(await sessionRes.text()); const session = await sessionRes.json(); const chunkSize = serverInfo.chunkSize || 4 * 1024 * 1024; let offset = 0; let lastTime = Date.now(); let lastOffset = 0; while (offset < file.size) { if (uploadAbort) throw new Error('已取消'); const end = Math.min(offset + chunkSize, file.size); const chunk = file.slice(offset, end); const rangeEnd = end - 1; const patchRes = await fetch(`${API_BASE}/upload/session/${session.id}`, { ...fetchOpts, method: 'PATCH', headers: clientHeaders({ 'Content-Range': `bytes ${offset}-${rangeEnd}/${file.size}` }), body: chunk, }); if (!patchRes.ok) throw new Error(await patchRes.text()); const now = Date.now(); const dt = (now - lastTime) / 1000; const speed = dt > 0 ? (end - lastOffset) / dt : 0; lastTime = now; lastOffset = end; offset = end; onProgress(offset / file.size, speed); } const completeRes = await fetch(`${API_BASE}/upload/session/${session.id}/complete`, { ...fetchOpts, method: 'POST', headers: clientHeaders(), }); if (!completeRes.ok) throw new Error(await completeRes.text()); return completeRes.json(); } /* ── Upload batch ── */ async function uploadFiles(fileList, prefix = currentPath) { const files = [...fileList].filter((f) => f && (typeof f.size !== 'number' || f.size >= 0)); if (!files.length) { setStatus('没有可上传的文件', 'err'); return; } const entries = files.map((file) => ({ file, relPath: (file.webkitRelativePath || file.name).replace(/\\/g, '/'), })); let conflictPolicy = onConflict; uploadAbort = false; showUploadProgress(true); progressSummary.textContent = `上传 0/${files.length}`; setStatus(`正在上传 ${files.length} 个文件${prefix ? ' 到 ' + prefix : ''}…`); const trackers = entries.map(({ file }) => createProgressItem(file.name)); let doneCount = 0; let savedTotal = 0; let failed = 0; let lastError = ''; for (let i = 0; i < entries.length; i++) { if (uploadAbort) break; const { file, relPath } = entries[i]; const tracker = trackers[i]; tracker.setStatus('上传中'); const onProgress = (pct, speed) => { tracker.setProgress(pct, speed); progressSpeed.textContent = fmtSpeed(speed); const overall = (doneCount + pct) / files.length; progressBar.style.width = `${Math.round(overall * 100)}%`; }; try { if (file.size > CHUNK_THRESHOLD) { await uploadChunked(file, relPath, prefix, conflictPolicy, onProgress); } else { await uploadSingleXHR(file, relPath, prefix, conflictPolicy, onProgress); } tracker.setProgress(1, 0); tracker.setStatus('完成', 'done'); doneCount += 1; savedTotal += 1; } catch (err) { lastError = err?.message || String(err); tracker.setStatus('失败', 'err'); failed += 1; doneCount += 1; } progressSummary.textContent = `上传 ${doneCount}/${files.length}`; } lastSnapshot = ''; await refreshFiles(); if (failed) { const detail = lastError ? `:${lastError.slice(0, 120)}` : ''; setStatus(`上传完成,${savedTotal} 成功,${failed} 失败${detail}`, failed === files.length ? 'err' : ''); } else setStatus(`已上传 ${savedTotal} 个文件`, 'ok'); setTimeout(() => showUploadProgress(false), 800); fileInput.value = ''; folderInput.value = ''; } /* ── DataTransfer helpers ── */ async function collectEntry(entry, base = '') { if (entry.isFile) { return new Promise((resolve) => { entry.file((file) => { const rel = base ? `${base}/${file.name}` : file.name; Object.defineProperty(file, 'webkitRelativePath', { value: rel }); resolve([file]); }); }); } if (entry.isDirectory) { const reader = entry.createReader(); const out = []; const read = () => new Promise((resolve) => reader.readEntries(resolve)); let entries = await read(); while (entries.length) { for (const child of entries) { const childBase = base ? `${base}/${entry.name}` : entry.name; out.push(...await collectEntry(child, childBase)); } entries = await read(); } return out; } return []; } function normalizePastedFile(file, index = 0) { if (file.name) return file; const ext = (file.type || '').split('/')[1] || 'bin'; const suffix = index > 0 ? `-${index + 1}` : ''; return new File([file], `粘贴文件${suffix}.${ext}`, { type: file.type || 'application/octet-stream' }); } /** 必须在 paste/drop 事件回调里同步调用 getAsFile(),不能先 await */ function extractDataTransferSync(dt) { const syncFiles = []; const asyncEntries = []; if (!dt) return { syncFiles, asyncEntries }; for (const item of [...(dt.items || [])]) { if (item.kind !== 'file') continue; const file = item.getAsFile(); if (file) { syncFiles.push(normalizePastedFile(file, syncFiles.length)); continue; } const entry = item.webkitGetAsEntry?.(); if (entry) asyncEntries.push(entry); } if (!syncFiles.length && dt.files?.length) { [...dt.files].forEach((file, i) => syncFiles.push(normalizePastedFile(file, i))); } return { syncFiles, asyncEntries }; } async function fromDataTransfer(dt) { const { syncFiles, asyncEntries } = extractDataTransferSync(dt); const files = [...syncFiles]; for (const entry of asyncEntries) { files.push(...await collectEntry(entry)); } return files; } async function fromClipboard(clipboardData) { return fromDataTransfer(clipboardData); } /* ── File operations ── */ async function deleteKeys(keys) { const list = [...keys]; if (!list.length) return; const label = list.length === 1 ? '这个文件' : `这 ${list.length} 项`; if (!confirm(`确定删除${label}?(移入回收站)`)) return; bulkDeleteBtn.disabled = true; setStatus(`正在删除 ${list.length} 项…`); let failed = 0; for (const key of list) { try { const res = await fetch(API_BASE + '/files/' + encodeURIComponent(key).replace(/%2F/g, '/'), { method: 'DELETE' }); if (!res.ok) failed += 1; else selected.delete(key); } catch { failed += 1; } } lastSnapshot = ''; await refreshFiles(); if (failed) setStatus(`删除完成,${failed} 项失败`, 'err'); else setStatus(`已移入回收站 ${list.length - failed} 项`, 'ok'); } async function createFolder() { const name = prompt('新建文件夹名称:'); if (!name) return; const trimmed = name.trim(); if (!trimmed || /[\\/]/.test(trimmed) || trimmed === '.' || trimmed === '..') { setStatus('文件夹名称无效', 'err'); return; } try { const res = await fetch(API_BASE + '/folders', { ...fetchOpts, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: trimmed, parent: currentPath }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { if (data.error === 'exists') throw new Error('名称已存在'); throw new Error(data.error || res.statusText); } setStatus(`已创建文件夹「${trimmed}」`, 'ok'); lastSnapshot = ''; await refreshFiles(); } catch (err) { setStatus('创建失败: ' + err.message, 'err'); } } async function moveItems(keys, destFolder) { const dest = destFolder || ''; const toMove = keys.filter((key) => { if (parentPath(key) === dest) return false; if (dest === key || dest.startsWith(key + '/')) return false; return true; }); if (!toMove.length) return; setStatus(`正在移动 ${toMove.length} 项…`); try { const res = await fetch(API_BASE + '/move', { ...fetchOpts, method: 'POST', headers: clientHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ items: toMove, dest, onConflict }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || res.statusText); lastSnapshot = ''; clearSelection(); await refreshFiles(); if (data.errors?.length) { setStatus(`已移动 ${data.moved?.length || 0} 项,${data.errors.length} 项失败`, 'err'); } else { setStatus(`已移动 ${data.moved?.length || 0} 项`, 'ok'); } } catch (err) { setStatus('移动失败: ' + err.message, 'err'); } } async function copyItems(keys, destFolder) { const dest = destFolder || currentPath; setStatus(`正在复制 ${keys.length} 项…`); try { const res = await fetch(API_BASE + '/copy', { ...fetchOpts, method: 'POST', headers: clientHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ items: keys, dest, onConflict }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || res.statusText); lastSnapshot = ''; await refreshFiles(); setStatus(`已复制 ${data.copied?.length || 0} 项`, 'ok'); } catch (err) { setStatus('复制失败: ' + err.message, 'err'); } } async function pasteClipboard() { if (!internalClipboard?.items?.length) return; const dest = currentPath; if (internalClipboard.mode === 'cut') { await moveItems(internalClipboard.items, dest); internalClipboard = null; } else { await copyItems(internalClipboard.items, dest); } } function startRename(key) { renameKey = key; lastSnapshot = ''; renderFiles(currentFiles, { force: true }); } async function commitRename(key, newName) { renameKey = null; const trimmed = newName.trim(); if (!trimmed || trimmed === key.split('/').pop()) { renderFiles(currentFiles, { force: true }); return; } if (/[\\/]/.test(trimmed)) { setStatus('名称不能包含 / 或 \\', 'err'); renderFiles(currentFiles, { force: true }); return; } try { const res = await fetch(API_BASE + '/rename', { ...fetchOpts, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from: key, name: trimmed, onConflict }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || res.statusText); if (selected.has(key) && data.to) { selected.delete(key); selected.add(data.to); } setStatus('已重命名', 'ok'); lastSnapshot = ''; await refreshFiles(); } catch (err) { setStatus('重命名失败: ' + err.message, 'err'); renderFiles(currentFiles, { force: true }); } } function downloadSelectedZip() { const items = [...selected].filter((k) => { const f = currentFiles.find((x) => x.name === k); return f || currentDirs.includes(k); }); if (!items.length) return; const url = API_BASE + '/download-zip?items=' + items.map(encodeURIComponent).join(','); window.location.href = url; } /* ── Detail modal ── */ function openDetail(key) { const file = currentFiles.find((f) => f.name === key); const folder = currentDirs.includes(key); detailTitle.textContent = key.split('/').pop() || key; const rows = [ ['路径', key], ['类型', folder ? '文件夹' : '文件'], ]; if (file) { rows.push(['大小', fmtSize(file.size)]); rows.push(['修改时间', fmtTime(file.mtime)]); if (file.origin) { rows.push(['来源设备', file.origin.clientLabel || '—']); rows.push(['来源 ID', file.origin.clientId || '—']); } } else if (folder) { const tree = buildTree(currentFiles, currentDirs); const node = getNodeAtPath(tree, key); if (node) { rows.push(['大小', fmtSize(folderSize(node))]); rows.push(['修改时间', fmtTime(folderMtime(node))]); } } detailGrid.innerHTML = rows.map(([k, v]) => `
${escapeHtml(k)}
${escapeHtml(String(v))}
` ).join(''); detailModal.classList.add('show'); } /* ── Preview ── */ function openPreview(name) { const url = viewUrl(name); const ext = extOf(name); previewTitle.textContent = name; previewDownload.href = downloadUrl(name); previewOpen.href = url; previewBody.innerHTML = ''; if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) { previewBody.innerHTML = `${escapeHtml(name)}`; } else if (['mp4', 'webm'].includes(ext)) { previewBody.innerHTML = ``; } else if (['mp3', 'wav', 'ogg'].includes(ext)) { previewBody.innerHTML = ``; } else if (ext === 'pdf') { previewBody.innerHTML = ``; } else if (['txt', 'md', 'json', 'js', 'mjs', 'css', 'html', 'htm', 'csv', 'xml'].includes(ext)) { fetch(url).then((r) => r.text()).then((text) => { previewBody.innerHTML = `
${escapeHtml(text)}
`; }); } else { window.open(url, '_blank'); return; } previewModal.classList.add('show'); } /* ── Trash ── */ async function loadTrash() { trashBody.innerHTML = '
加载中…
'; try { const res = await fetch(API_BASE + '/trash', fetchOpts); const data = await res.json(); const items = data.items || []; if (!items.length) { trashBody.innerHTML = '
回收站为空
'; return; } trashBody.innerHTML = items.map((item) => `
${escapeHtml(item.original.split('/').pop())}
${escapeHtml(item.original)} · ${fmtTime(item.trashedAt)} · ${item.isDirectory ? '文件夹' : fmtSize(item.size)}
`).join(''); } catch { trashBody.innerHTML = '
加载失败
'; } } function openTrash() { trashDrawer.classList.add('show'); trashBackdrop.classList.add('show'); loadTrash(); } function closeTrash() { trashDrawer.classList.remove('show'); trashBackdrop.classList.remove('show'); } async function restoreTrashItem(id) { try { const res = await fetch(API_BASE + '/trash/restore', { ...fetchOpts, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, onConflict }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || res.statusText); setStatus('已恢复', 'ok'); await loadTrash(); lastSnapshot = ''; await refreshFiles(); } catch (err) { setStatus('恢复失败: ' + err.message, 'err'); } } async function purgeTrashItem(id) { if (!confirm('永久删除此项?不可恢复。')) return; try { await fetch(API_BASE + '/trash/' + encodeURIComponent(id), { method: 'DELETE' }); await loadTrash(); setStatus('已永久删除', 'ok'); } catch (err) { setStatus('删除失败: ' + err.message, 'err'); } } async function emptyTrash() { if (!confirm('清空回收站?所有项目将永久删除。')) return; try { await fetch(API_BASE + '/trash', { ...fetchOpts, method: 'DELETE' }); await loadTrash(); setStatus('回收站已清空', 'ok'); } catch (err) { setStatus('清空失败: ' + err.message, 'err'); } } /* ── Drag overlay ── */ function showDragOverlay(target) { const label = target || currentPath || '根目录'; dragOverlayHint.textContent = `松手移入 ${label || '根目录'}`; dragOverlay.classList.add('show'); } function hideDragOverlay() { dragOverlay.classList.remove('show'); } /* ── Event bindings ── */ function bindEvents() { conflictSelect.value = onConflict; conflictSelect.addEventListener('change', () => { onConflict = conflictSelect.value; localStorage.setItem(ON_CONFLICT_KEY, onConflict); }); notifyBtn.classList.toggle('active', isNotifyEnabled() && Notification.permission === 'granted'); notifyBtn.addEventListener('click', () => requestNotifyPermission()); searchInput.addEventListener('input', () => { searchQuery = searchInput.value.trim(); displayLimit = serverInfo.pageSize || 100; lastSnapshot = ''; renderFiles(currentFiles); }); dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); }); dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover')); dropZone.addEventListener('drop', async (e) => { e.preventDefault(); dropZone.classList.remove('dragover'); hideDragOverlay(); await uploadFiles(await fromDataTransfer(e.dataTransfer)); }); document.addEventListener('dragenter', (e) => { if (![...e.dataTransfer.types].includes('Files')) return; dragDepth += 1; showDragOverlay(dragTargetFolder || currentPath || '根目录'); }); document.addEventListener('dragleave', () => { dragDepth = Math.max(0, dragDepth - 1); if (dragDepth === 0) hideDragOverlay(); }); document.addEventListener('drop', () => { dragDepth = 0; hideDragOverlay(); }); fileTreeEl.addEventListener('dragstart', (e) => { const row = e.target.closest('[data-key][draggable="true"]'); if (!row || e.target.closest('.col-check, .item-check-wrap, .row-check, .item-check, .col-actions, .icon-actions, a, button, .rename-input')) { e.preventDefault(); return; } const key = row.dataset.key; const keys = selected.has(key) && selected.size > 1 ? [...selected] : [key]; e.dataTransfer.setData('application/x-fds-keys', JSON.stringify(keys)); e.dataTransfer.effectAllowed = 'move'; row.classList.add('dragging'); }); fileTreeEl.addEventListener('dragend', () => { fileTreeEl.querySelectorAll('.dragging').forEach((el) => el.classList.remove('dragging')); fileTreeEl.querySelectorAll('.drop-target').forEach((el) => el.classList.remove('drop-target')); dragTargetFolder = ''; }); fileTreeEl.addEventListener('dragover', (e) => { const hasInternal = e.dataTransfer.types.includes('application/x-fds-keys'); const hasExternal = [...e.dataTransfer.types].includes('Files'); if (!hasInternal && !hasExternal) return; e.preventDefault(); e.dataTransfer.dropEffect = hasInternal ? 'move' : 'copy'; const folderRow = e.target.closest('[data-folder]'); fileTreeEl.querySelectorAll('.drop-target').forEach((el) => el.classList.remove('drop-target')); if (folderRow) { folderRow.classList.add('drop-target'); dragTargetFolder = folderRow.getAttribute('data-folder'); showDragOverlay(dragTargetFolder); } else { fileTreeEl.classList.add('drop-target'); dragTargetFolder = currentPath || '根目录'; showDragOverlay(dragTargetFolder); } }); fileTreeEl.addEventListener('dragleave', (e) => { if (!fileTreeEl.contains(e.relatedTarget)) { fileTreeEl.classList.remove('drop-target'); fileTreeEl.querySelectorAll('.drop-target').forEach((el) => el.classList.remove('drop-target')); } }); fileTreeEl.addEventListener('drop', async (e) => { e.preventDefault(); fileTreeEl.classList.remove('drop-target'); fileTreeEl.querySelectorAll('.drop-target').forEach((el) => el.classList.remove('drop-target')); hideDragOverlay(); const internal = e.dataTransfer.getData('application/x-fds-keys'); const folderRow = e.target.closest('[data-folder]'); const destFolder = folderRow ? folderRow.getAttribute('data-folder') : currentPath; if (internal) { try { await moveItems(JSON.parse(internal), destFolder); } catch {} return; } await uploadFiles(await fromDataTransfer(e.dataTransfer), destFolder); }); breadcrumbEl.addEventListener('click', (e) => { const btn = e.target.closest('.bc-link'); if (!btn) return; enterFolder(btn.getAttribute('data-path') || ''); }); document.querySelector('.list-header').addEventListener('click', (e) => { const btn = e.target.closest('.sortable'); if (!btn) return; const key = btn.dataset.sort; if (sortKey === key) sortAsc = !sortAsc; else { sortKey = key; sortAsc = key === 'name'; } persistSort(); lastSnapshot = ''; renderFiles(currentFiles); }); fileTreeEl.addEventListener('change', (e) => { const rowCheck = e.target.closest('.row-check, .item-check'); if (!rowCheck || e.target !== rowCheck) return; e.stopPropagation(); syncCheckboxSelection(rowCheck, e.shiftKey); fileTreeEl.focus(); }); fileTreeEl.addEventListener('click', async (e) => { if (e.target.id === 'loadMoreBtn') { displayLimit += serverInfo.pageSize || 100; lastSnapshot = ''; renderFiles(currentFiles); return; } if (e.target.id === 'backToRootBtn') { enterFolder(''); return; } const rowEl = e.target.closest('[data-key]'); const checkWrap = e.target.closest('.col-check, .item-check-wrap'); const rowCheck = e.target.closest('.row-check, .item-check') || checkWrap?.querySelector('.row-check, .item-check'); if (rowCheck && rowEl && e.shiftKey) { e.preventDefault(); const nextChecked = !selected.has(rowEl.dataset.key); rowCheck.checked = nextChecked; syncCheckboxSelection(rowCheck, true); fileTreeEl.focus(); return; } if (rowCheck || checkWrap) { return; } const infoBtn = e.target.closest('[data-info]'); if (infoBtn) { openDetail(decodeURIComponent(infoBtn.getAttribute('data-info'))); return; } const delBtn = e.target.closest('[data-del]'); if (delBtn) { await deleteKeys([decodeURIComponent(delBtn.getAttribute('data-del'))]); return; } const link = e.target.closest('[data-name]'); if (link && link.dataset.preview === '1') { e.preventDefault(); openPreview(link.dataset.name); return; } if (rowEl && !e.target.closest('.col-check, .item-check-wrap, .col-actions, .icon-actions, [data-name], .rename-input')) { const key = rowEl.dataset.key; const index = visibleKeys.indexOf(key); handleRowSelect(e, key, index); fileTreeEl.focus(); } }); fileTreeEl.addEventListener('dblclick', (e) => { const folderRow = e.target.closest('[data-folder]'); if (folderRow && !e.target.closest('.col-check, .item-check-wrap, .col-actions, .icon-actions, .row-check, .item-check')) { enterFolder(folderRow.getAttribute('data-folder')); } }); fileTreeEl.addEventListener('keydown', (e) => { if (renameKey) return; if (e.key === 'F2' && selected.size === 1) { e.preventDefault(); startRename([...selected][0]); return; } if (e.key === ' ' && selected.size === 1) { e.preventDefault(); openDetail([...selected][0]); return; } if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a') { e.preventDefault(); selectAllVisible(); return; } if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'c') { e.preventDefault(); if (selected.size) internalClipboard = { mode: 'copy', items: [...selected] }; return; } if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'x') { e.preventDefault(); if (selected.size) internalClipboard = { mode: 'cut', items: [...selected] }; return; } /* ⌘V 交给 document paste 事件:同时支持桌面文件粘贴与内部剪切/复制 */ if (e.key === 'Escape') { e.preventDefault(); if (selected.size) clearSelection(); else if (currentPath) { const parts = currentPath.split('/'); parts.pop(); enterFolder(parts.join('/')); } return; } if (e.key === 'Delete' || e.key === 'Backspace') { if (selected.size) { e.preventDefault(); deleteKeys(selected); } } }); fileTreeEl.addEventListener('blur', (e) => { const input = e.target.closest?.('.rename-input'); if (input) { commitRename(input.dataset.rename, input.value); } }, true); fileTreeEl.addEventListener('keydown', (e) => { const input = e.target.closest?.('.rename-input'); if (!input) return; if (e.key === 'Enter') { e.preventDefault(); commitRename(input.dataset.rename, input.value); } else if (e.key === 'Escape') { e.preventDefault(); renameKey = null; renderFiles(currentFiles, { force: true }); } }, true); document.addEventListener('paste', (e) => { if (document.activeElement?.closest('.rename-input, input, textarea')) return; const { syncFiles, asyncEntries } = extractDataTransferSync(e.clipboardData); if (syncFiles.length || asyncEntries.length) { e.preventDefault(); void (async () => { const files = [...syncFiles]; for (const entry of asyncEntries) { files.push(...await collectEntry(entry)); } if (files.length) await uploadFiles(files); else setStatus('无法读取剪贴板文件,请改用拖放或「选择文件」', 'err'); })(); return; } if (internalClipboard?.items?.length) { e.preventDefault(); void pasteClipboard(); return; } const types = [...(e.clipboardData?.types || [])]; if (types.includes('Files') || types.some((t) => t.includes('uri-list'))) { setStatus('浏览器无法读取 Finder 复制的文件,请直接拖放到页面', 'err'); } }, true); selectAllCheck.addEventListener('change', () => { if (selectAllCheck.checked) selectAllVisible(); else clearSelection(); fileTreeEl.focus(); }); bulkDeleteBtn.addEventListener('click', () => deleteKeys(selected)); downloadZipBtn.addEventListener('click', () => downloadSelectedZip()); viewListBtn.addEventListener('click', () => setViewMode('list')); viewIconsBtn.addEventListener('click', () => setViewMode('icons')); $('newFolderBtn').addEventListener('click', () => createFolder()); $('refreshBtn').addEventListener('click', () => { lastSnapshot = ''; refreshFiles(); }); $('pickFiles').onclick = () => fileInput.click(); $('pickFolder').onclick = () => folderInput.click(); fileInput.onchange = () => uploadFiles(fileInput.files); folderInput.onchange = () => uploadFiles(folderInput.files); $('previewClose').onclick = () => previewModal.classList.remove('show'); previewModal.addEventListener('click', (e) => { if (e.target === previewModal) previewModal.classList.remove('show'); }); $('detailClose').onclick = () => detailModal.classList.remove('show'); detailModal.addEventListener('click', (e) => { if (e.target === detailModal) detailModal.classList.remove('show'); }); $('conflictClose').onclick = () => closeConflictModal(false); $('conflictCancel').onclick = () => closeConflictModal(false); $('conflictConfirm').onclick = () => closeConflictModal(true); $('trashBtn').addEventListener('click', openTrash); $('trashClose').addEventListener('click', closeTrash); trashBackdrop.addEventListener('click', closeTrash); $('trashRefreshBtn').addEventListener('click', loadTrash); $('trashEmptyBtn').addEventListener('click', emptyTrash); trashBody.addEventListener('click', (e) => { const restoreBtn = e.target.closest('[data-restore]'); if (restoreBtn) { restoreTrashItem(restoreBtn.getAttribute('data-restore')); return; } const purgeBtn = e.target.closest('[data-purge]'); if (purgeBtn) { purgeTrashItem(purgeBtn.getAttribute('data-purge')); } }); } /* ── Init ── */ bindEvents(); initPanelResize(); setViewMode(viewMode); void (async () => { currentUser = await ensureAuthenticated(); if (!currentUser) { setStatus('未登录,正在跳转…', 'err'); return; } await loadInfo(); await refreshFiles(); connectEvents(); })(); })();