(function () { 'use strict' const API = '/api/oss_file_cabinet' const state = { view: 'icon', prefix: '', history: [], historyIndex: -1, items: [], selected: new Set(), lastSelectedIdx: -1, clipboard: null, search: '', mode: 'files', shareToken: '', toastTimer: null, columnStack: [], columnCache: {} } const $ = (sel) => document.querySelector(sel) const $$ = (sel) => Array.from(document.querySelectorAll(sel)) const showToast = (msg, type) => { let t = document.getElementById('globalToast') if (!t) { t = document.createElement('div') t.id = 'globalToast' t.className = 'toast' document.body.appendChild(t) } t.textContent = msg t.className = 'toast toast-' + (type || 'info') t.style.display = 'flex' clearTimeout(state.toastTimer) state.toastTimer = setTimeout(() => { t.style.display = 'none' }, 2200) } const formatSize = (bytes) => { const b = Number(bytes) || 0 if (b < 1024) return b + ' B' if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB' if (b < 1024 * 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + ' MB' return (b / 1024 / 1024 / 1024).toFixed(2) + ' GB' } const formatDate = (ts) => { if (!ts) return '' const d = new Date(ts) const y = d.getFullYear() const m = String(d.getMonth() + 1).padStart(2, '0') const day = String(d.getDate()).padStart(2, '0') const hh = String(d.getHours()).padStart(2, '0') const mm = String(d.getMinutes()).padStart(2, '0') return y + '-' + m + '-' + day + ' ' + hh + ':' + mm } const getExt = (name) => { const n = String(name || '') const idx = n.lastIndexOf('.') if (idx < 0 || idx === n.length - 1) return '' return n.slice(idx + 1).toLowerCase() } const getFileIcon = (name, isFolder) => { if (isFolder) return '📁' const ext = getExt(name) const iconMap = { jpg: '🖼️', jpeg: '🖼️', png: '🖼️', gif: '🖼️', webp: '🖼️', svg: '🖼️', bmp: '🖼️', ico: '🖼️', pdf: '📕', doc: '📝', docx: '📝', odt: '📝', xls: '📗', xlsx: '📗', csv: '📗', ods: '📗', ppt: '📙', pptx: '📙', odp: '📙', txt: '📄', md: '📄', log: '📄', zip: '📦', rar: '📦', '7z': '📦', tar: '📦', gz: '📦', mp3: '🎵', wav: '🎵', flac: '🎵', aac: '🎵', ogg: '🎵', mp4: '🎬', mov: '🎬', avi: '🎬', mkv: '🎬', webm: '🎬', flv: '🎬', m4v: '🎬', json: '📋', js: '📋', ts: '📋', jsx: '📋', tsx: '📋', html: '🌐', css: '🌐', xml: '🌐', py: '🐍', java: '☕', c: '⚙️', cpp: '⚙️', h: '⚙️', go: '🐹', rs: '⚙️', sh: '⚙️' } return iconMap[ext] || '📄' } const getFileIconClass = (name) => { const ext = getExt(name) if (['pdf'].includes(ext)) return 'file-red' if (['doc', 'docx', 'odt'].includes(ext)) return 'file-blue' if (['xls', 'xlsx', 'csv', 'ods'].includes(ext)) return 'file-green' if (['ppt', 'pptx', 'odp'].includes(ext)) return 'file-orange' if (['mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'm4v'].includes(ext)) return 'file-purple' if (['mp3', 'wav', 'flac', 'aac', 'ogg'].includes(ext)) return 'file-pink' if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) return 'file-yellow' if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico'].includes(ext)) return 'file-image' if (['txt', 'md', 'log'].includes(ext)) return 'file-text' return 'file-gray' } const apiGet = (url) => fetch(url, { credentials: 'same-origin' }).then(r => r.json()) const apiPost = (url, body) => fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: body ? JSON.stringify(body) : '{}' }).then(r => r.json()) const apiPostFormData = (url, formData) => fetch(url, { method: 'POST', credentials: 'same-origin', body: formData }).then(r => r.json()) const doLogin = async (user, pass) => { const r = await apiPost(API + '/auth/login', { username: user, password: pass }) return r && r.ok } const checkAuth = async () => { try { const r = await apiGet(API + '/auth/me') return r && r.ok && r.authorized } catch { return false } } const tryAutoFill = async () => { try { const token = localStorage.getItem('toolbox_creds_token') || '' if (!token) return const r = await apiGet(API + '/auth/local_creds?token=' + encodeURIComponent(token)) if (r && r.ok && r.creds) { if (r.creds.username) $('#loginUsername').value = r.creds.username if (r.creds.password) $('#loginPassword').value = r.creds.password } } catch {} } const clearUrlQuery = () => { try { if (!window.location.search) return const clean = location.pathname + location.hash history.replaceState(null, document.title, clean) } catch {} } const main = async () => { initTheme() // ===== set_token 处理:仅存入 localStorage,不自动登录 ===== try { const params = new URLSearchParams(location.search) const setToken = params.get('set_token') if (setToken) { localStorage.setItem('toolbox_creds_token', setToken) } } catch {} clearUrlQuery() // ===== 先检查是否已登录 ===== const already = await checkAuth() if (already) { showApp() initEvents() initDragDrop() navigateTo('') return } // ===== 未登录,仅自动填充,不自动提交 ===== showLogin() await tryAutoFill() const $loginBtn = $('#loginBtn') if ($loginBtn && !$loginBtn.dataset.bound) { $loginBtn.dataset.bound = '1' $loginBtn.addEventListener('click', async () => { const u = $('#loginUsername').value.trim() const p = $('#loginPassword').value $('#loginError').textContent = '' if (!u || !p) { $('#loginError').textContent = '请输入用户名和密码'; return } const ok = await doLogin(u, p) if (ok) { showApp() initEvents() initDragDrop() navigateTo('') } else $('#loginError').textContent = '用户名或密码错误' }) } $('#loginPassword').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('#loginBtn').click() }) $('#loginUsername').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('#loginBtn').click() }) } const showLogin = () => { $('#loginScreen').style.display = 'flex' $('#app').style.display = 'none' } const showApp = () => { $('#loginScreen').style.display = 'none' $('#app').style.display = 'block' } const initTheme = () => { const saved = localStorage.getItem('oss_file_cabinet_theme') if (saved === 'dark' || (!saved && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)) { document.documentElement.classList.add('theme-dark') } } const toggleTheme = () => { const isDark = document.documentElement.classList.toggle('theme-dark') localStorage.setItem('oss_file_cabinet_theme', isDark ? 'dark' : 'light') } const setBreadcrumb = () => { const pb = $('#pathBar') pb.innerHTML = '' const parts = state.prefix ? state.prefix.split('/').filter(Boolean) : [] const homeItem = document.createElement('span') homeItem.className = 'crumb active' homeItem.textContent = ' ' pb.appendChild(homeItem) let accPath = '' parts.forEach((p, idx) => { const sep = document.createElement('span') sep.className = 'crumb-sep' sep.textContent = ' | ' pb.appendChild(sep) accPath = accPath ? accPath + '/' + p : p const isLast = idx === parts.length - 1 const target = isLast && state.prefix && state.prefix.endsWith('/') ? accPath + '/' : accPath const crumb = document.createElement('span') crumb.className = 'crumb' crumb.textContent = p crumb.addEventListener('click', () => navigateTo(target)) pb.appendChild(crumb) }) } const setStatusBar = () => { const total = state.items ? state.items.length : 0 const sel = state.selected.size $('#statusInfo').textContent = total + ' 个项目' $('#statusSelect').textContent = sel > 0 ? '已选择 ' + sel + ' 个' : '' } const buildColumnStack = (prefix) => { const parts = String(prefix || '').split('/').filter(Boolean) const stack = [] let currentPrefix = '' for (let i = 0; i < parts.length; i++) { stack.push({ prefix: currentPrefix, selectedKey: parts[i] }) currentPrefix = parts.slice(0, i + 1).join('/') + '/' } stack.push({ prefix: currentPrefix, selectedKey: null }) return stack } const navigateTo = (prefix, addHistory) => { let clean = String(prefix || '').replace(/[\\]+/g, '/').replace(/\/+/g, '/') if (clean.startsWith('/')) clean = clean.slice(1) if (clean === '/') clean = '' if (clean === state.prefix && addHistory === false) return if (addHistory !== false) { state.history = state.history.slice(0, state.historyIndex + 1) state.history.push(clean) state.historyIndex = state.history.length - 1 } state.prefix = clean state.selected.clear() state.lastSelectedIdx = -1 state.columnStack = buildColumnStack(clean) loadItems() setBreadcrumb() } const goBack = () => { if (state.historyIndex > 0) { state.historyIndex-- state.prefix = state.history[state.historyIndex] state.selected.clear() loadItems() setBreadcrumb() } } const goForward = () => { if (state.historyIndex < state.history.length - 1) { state.historyIndex++ state.prefix = state.history[state.historyIndex] state.selected.clear() loadItems() setBreadcrumb() } } const goUp = () => { if (!state.prefix) return const parts = state.prefix.split('/').filter(Boolean) parts.pop() navigateTo(parts.length ? parts.join('/') + '/' : '') } const loadItems = async () => { if (state.mode === 'trash') { await loadTrash(); return } if (state.mode === 'images') { await loadImages(); return } const r = await apiGet(API + '/files/list?prefix=' + encodeURIComponent(state.prefix) + (state.search ? '&search=' + encodeURIComponent(state.search) : '')) console.log('[oss_file_cabinet] list response', r) if (!r || !r.ok) { state.items = []; render(); return } state.items = r.items || [] state.items.sort((a, b) => { if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1 return (a.name || '').localeCompare(b.name || '') }) render() } const loadTrash = async () => { const r = await apiGet(API + '/trash/list') if (!r || !r.ok) { state.items = []; render(); return } state.items = r.items || [] render() } const loadImages = async () => { const r = await apiGet(API + '/files/list?prefix=') if (!r || !r.ok) { state.items = []; render(); return } const collect = async (prefix) => { const res = await apiGet(API + '/files/list?prefix=' + encodeURIComponent(prefix)) const out = [] if (res && res.ok) { for (const it of res.items) { if (it.isFolder) { const nested = await collect(it.key) out.push(...nested) } else if (it.isImage) out.push(it) } } return out } state.items = await collect('') render() } const render = () => { const area = $('#fileArea') area.className = 'file-area ' + state.view + '-view' area.innerHTML = '' setStatusBar() if (!state.items || state.items.length === 0) { const empty = document.createElement('div') empty.className = 'empty-state' empty.innerHTML = '
🗑
' + '
空文件夹
' + '
'+(state.mode === 'trash' ? '废纸篓为空' : '拖拽文件到此处或点击「上传」')+'
' area.appendChild(empty) return } if (state.view === 'icon') renderIconView(area) else if (state.view === 'list') renderListView(area) else renderColumnView(area).catch(() => {}) } const renderIconView = (area) => { state.items.forEach((it, idx) => { const div = document.createElement('div') div.className = 'icon-item' + (state.selected.has(it.key) ? ' selected' : '') div.dataset.key = it.key div.dataset.idx = String(idx) const iconWrap = document.createElement('div') iconWrap.className = 'icon-wrap ' + getFileIconClass(it.name) if (it.isImage && state.mode !== 'trash') { const img = document.createElement('img') img.src = API + '/files/preview?key=' + encodeURIComponent(it.key) img.className = 'icon-thumb' img.addEventListener('error', () => { iconWrap.textContent = getFileIcon(it.name, it.isFolder) }) iconWrap.appendChild(img) } else { iconWrap.textContent = getFileIcon(it.name, it.isFolder) } div.appendChild(iconWrap) const nameEl = document.createElement('div') nameEl.className = 'icon-name' nameEl.textContent = it.name || '未命名' div.appendChild(nameEl) bindItemEvents(div, it, idx) area.appendChild(div) }) } const renderListView = (area) => { const table = document.createElement('div') table.className = 'list-table' const header = document.createElement('div') header.className = 'list-row list-header' header.innerHTML = '
名称
大小
修改日期
类型
' table.appendChild(header) state.items.forEach((it, idx) => { const row = document.createElement('div') row.className = 'list-row' + (state.selected.has(it.key) ? ' selected' : '') row.dataset.key = it.key row.dataset.idx = String(idx) const nameCell = document.createElement('div') nameCell.className = 'col-name' nameCell.innerHTML = '' + getFileIcon(it.name, it.isFolder) + '' + (it.name || '未命名') + '' row.appendChild(nameCell) const szCell = document.createElement('div') szCell.className = 'col-size' szCell.textContent = it.isFolder ? '-' : formatSize(it.size) row.appendChild(szCell) const modCell = document.createElement('div') modCell.className = 'col-modified' modCell.textContent = it.deleted_at ? '删除于 ' + formatDate(it.deleted_at) : formatDate(it.lastModified) row.appendChild(modCell) const kindCell = document.createElement('div') kindCell.className = 'col-kind' kindCell.textContent = it.isFolder ? '文件夹' : (it.ext ? it.ext.toUpperCase() : '文件') row.appendChild(kindCell) bindItemEvents(row, it, idx) table.appendChild(row) }) area.appendChild(table) } const loadColumnItems = async (prefix) => { const cacheKey = prefix || '__root__' if (state.columnCache[cacheKey]) return state.columnCache[cacheKey] try { const r = await apiGet(API + '/files/list?prefix=' + encodeURIComponent(prefix)) const items = (r && r.ok ? r.items : []) || [] items.sort((a, b) => { if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1 return (a.name || '').localeCompare(b.name || '') }) state.columnCache[cacheKey] = items return items } catch { return [] } } const renderColumnView = async (area) => { area.innerHTML = '' if (!state.columnStack || state.columnStack.length === 0) { state.columnStack = buildColumnStack(state.prefix) } let lastSelectedItem = null for (let i = 0; i < state.columnStack.length; i++) { const colDef = state.columnStack[i] const colEl = document.createElement('div') colEl.className = 'column' const items = await loadColumnItems(colDef.prefix) items.forEach((it) => { const row = document.createElement('div') row.className = 'column-row' + (colDef.selectedKey === it.key ? ' selected' : '') row.innerHTML = '' + getFileIcon(it.name, it.isFolder) + '' + '' + (it.name || '未命名') + '' row.addEventListener('click', (ev) => { ev.stopPropagation() onColumnItemClick(i, it) }) row.addEventListener('dblclick', (ev) => { ev.stopPropagation() if (it.isFolder) navigateTo(it.key) else if (isPreviewable(it.name)) openFilePreview(it.key, it.name) else downloadFile(it.key, it.name) }) colEl.appendChild(row) if (colDef.selectedKey === it.key) lastSelectedItem = it }) area.appendChild(colEl) } const lastCol = state.columnStack[state.columnStack.length - 1] if (lastSelectedItem && !lastSelectedItem.isFolder) { const infoCol = document.createElement('div') infoCol.className = 'column column-hint' infoCol.innerHTML = '
' + '
' + (lastSelectedItem.name || '') + '
' + '
类型:' + (lastSelectedItem.ext ? lastSelectedItem.ext.toUpperCase() + ' 文件' : '文件') + '
' + '
大小:' + formatSize(lastSelectedItem.size) + '
' + '
修改日期:' + formatDate(lastSelectedItem.lastModified) + '
' + '
' area.appendChild(infoCol) } else if (!lastCol.selectedKey) { const hintCol = document.createElement('div') hintCol.className = 'column column-hint' hintCol.innerHTML = '
选择一项以查看详情
' area.appendChild(hintCol) } } const onColumnItemClick = async (colIndex, item) => { // Truncate stack after this column state.columnStack = state.columnStack.slice(0, colIndex + 1) const colDef = state.columnStack[colIndex] colDef.selectedKey = item.name if (item.isFolder) { const nextPrefix = colDef.prefix + item.name + '/' state.columnStack.push({ prefix: nextPrefix, selectedKey: null }) // Pre-load next column into cache for smoother feel await loadColumnItems(nextPrefix) } if (state.view === 'column') renderColumnView($('#fileArea')) } const updateSelectionUI = () => { const elems = $$('#fileArea [data-key]') elems.forEach(el => { const k = el.dataset.key if (state.selected.has(k)) el.classList.add('selected') else el.classList.remove('selected') }) } const bindItemEvents = (el, item, idx) => { el.addEventListener('click', (ev) => { ev.stopPropagation() const multi = ev.metaKey || ev.ctrlKey const range = ev.shiftKey if (range && state.lastSelectedIdx >= 0) { const lo = Math.min(idx, state.lastSelectedIdx) const hi = Math.max(idx, state.lastSelectedIdx) for (let i = lo; i <= hi; i++) { const k = state.items[i] && state.items[i].key if (k) state.selected.add(k) } } else if (multi) { if (state.selected.has(item.key)) state.selected.delete(item.key) else state.selected.add(item.key) } else { state.selected.clear() state.selected.add(item.key) } state.lastSelectedIdx = idx updateSelectionUI() }) el.addEventListener('dblclick', (ev) => { ev.stopPropagation() if (item.isFolder) { if (state.mode === 'files') navigateTo(item.key) } else if (isPreviewable(item.name) && state.mode !== 'trash') { openFilePreview(item.key, item.name) } else if (state.mode !== 'trash') { downloadFile(item.key, item.name) } }) el.addEventListener('contextmenu', (ev) => { ev.preventDefault() ev.stopPropagation() openContextMenu(ev.clientX, ev.clientY, item) }) } const openContextMenu = (x, y, firstItem) => { hideAllContextMenus() const menuId = state.mode === 'trash' ? 'trashContextMenu' : (firstItem ? 'contextMenu' : 'folderContextMenu') const menu = document.getElementById(menuId) if (!menu) return // 文件夹显示"打开",文件隐藏"打开" const openItem = menu.querySelector('.cm-item[data-action="open"]') if (openItem) { openItem.style.display = (firstItem && firstItem.isFolder) ? 'block' : 'none' } menu.style.left = x + 'px' menu.style.top = y + 'px' menu.style.display = 'block' const rect = menu.getBoundingClientRect() if (rect.right > window.innerWidth - 8) menu.style.left = Math.max(8, window.innerWidth - rect.width - 8) + 'px' if (rect.bottom > window.innerHeight - 8) menu.style.top = Math.max(8, window.innerHeight - rect.height - 8) + 'px' menu._firstItem = firstItem } const hideAllContextMenus = () => { ['contextMenu', 'folderContextMenu', 'trashContextMenu'].forEach(id => { const el = document.getElementById(id) if (el) el.style.display = 'none' }) } let previewScale = 1 const isVideo = name => ['mp4', 'mov', 'avi', 'webm', 'mkv', 'flv', 'm4v'].includes(getExt(name)) const isPdf = name => getExt(name) === 'pdf' const isText = name => ['txt', 'md', 'json', 'js', 'ts', 'html', 'css', 'xml', 'log', 'csv', 'yml', 'yaml', 'sh', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs'].includes(getExt(name)) const isImage = name => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico'].includes(getExt(name)) const isPreviewable = name => isImage(name) || isVideo(name) || isPdf(name) || isText(name) const escapeHtml = s => String(s || '').replace(/&/g, '&').replace(//g, '>') const openFilePreview = async (key, name) => { previewScale = 1 const modal = $('#filePreview') const container = $('#previewContainer') container.innerHTML = '' modal.style.display = 'flex' const url = API + '/files/preview?key=' + encodeURIComponent(key) if (isVideo(name)) { container.innerHTML = '' } else if (isPdf(name)) { container.innerHTML = '' } else if (isText(name)) { container.innerHTML = '
正在加载…
' try { const r = await fetch(url, { credentials: 'include' }) if (!r.ok) throw new Error('加载失败') const text = await r.text() container.innerHTML = '
' + escapeHtml(text) + '
' } catch (e) { container.innerHTML = '
预览加载失败
' } } else { container.innerHTML = '' + (name || '预览') + '' } } const closeFilePreview = () => { $('#filePreview').style.display = 'none' previewScale = 1 $('#previewContainer').innerHTML = '' } const downloadFile = (key, name) => { const url = API + '/files/download?key=' + encodeURIComponent(key) const a = document.createElement('a') a.href = url a.download = name || '文件' document.body.appendChild(a) a.click() document.body.removeChild(a) } const showModal = (title, bodyHtml, onOk, okText) => { $('#modalTitle').textContent = title $('#modalBody').innerHTML = bodyHtml || '' $('#modalOk').textContent = okText || '确定' $('#modalDialog').style.display = 'flex' $('#modalCancel').onclick = () => { $('#modalDialog').style.display = 'none' } $('#modalOk').onclick = () => { if (onOk) { const result = onOk() if (result === false) return } $('#modalDialog').style.display = 'none' } const firstInput = $('#modalBody').querySelector('input, select, textarea') if (firstInput) setTimeout(() => firstInput.focus(), 50) } const handleMenuAction = async (action, firstItem) => { if (action === 'open') { if (!firstItem) return if (firstItem.isFolder) navigateTo(firstItem.key) else if (isPreviewable(firstItem.name)) openFilePreview(firstItem.key, firstItem.name) else downloadFile(firstItem.key, firstItem.name) return } if (action === 'preview') { if (!firstItem || !isPreviewable(firstItem.name)) return openFilePreview(firstItem.key, firstItem.name).catch(() => {}) return } if (action === 'rename') { if (state.selected.size !== 1) return const key = Array.from(state.selected)[0] const it = state.items.find(x => x.key === key) if (!it) return showModal('重命名', '', async () => { const newName = $('#renameInput').value.trim() if (!newName) { showToast('名称不能为空', 'error'); return false } const r = await apiPost(API + '/files/rename', { key: key, name: newName }) if (r && r.ok) { showToast('重命名成功', 'success'); loadItems() } else showToast(r && r.error ? r.error : '重命名失败', 'error') }) return } if (action === 'info') { const keys = Array.from(state.selected) if (keys.length === 0) return const key = keys[0] const r = await apiGet(API + '/files/info?key=' + encodeURIComponent(key)) if (!r || !r.ok) { showToast('获取信息失败', 'error'); return } const info = r.info || {} const lines = [ ['名称', info.name], ['类型', info.isFolder ? '文件夹' : info.ext ? info.ext.toUpperCase() + ' 文件' : '文件'], ['大小', info.isFolder ? '-' : formatSize(info.size)], ['修改日期', info.lastModified ? formatDate(info.lastModified) : '-'], ['路径', '' + (info.fullPath || '') + ''] ] const html = lines.map(l => '
' + l[0] + '' + l[1] + '
').join('') showModal('简介', html, null, '关闭') return } if (action === 'copy') { if (state.selected.size === 0) return state.clipboard = { op: 'copy', keys: Array.from(state.selected) } showToast('已复制 ' + state.clipboard.keys.length + ' 个项目到剪贴板', 'success') return } if (action === 'move') { if (state.selected.size === 0) return state.clipboard = { op: 'move', keys: Array.from(state.selected) } showToast('已选择 ' + state.clipboard.keys.length + ' 个项目,点击目标文件夹粘贴', 'info') return } if (action === 'paste') { if (!state.clipboard || state.clipboard.keys.length === 0) return const op = state.clipboard.op let okCount = 0 for (const k of state.clipboard.keys) { const r = op === 'copy' ? await apiPost(API + '/files/copy', { source: k, dest: state.prefix }) : await apiPost(API + '/files/move', { source: k, dest: state.prefix }) if (r && r.ok) okCount++ } if (okCount === state.clipboard.keys.length) { showToast(op === 'copy' ? '复制成功' : '移动成功', 'success') if (op === 'move') state.clipboard = null } else showToast('部分操作失败', 'error') loadItems() return } if (action === 'download') { const keys = Array.from(state.selected) if (keys.length === 0) return // 多选或单个文件夹:打包 zip 下载 if (keys.length > 1 || (keys.length === 1 && state.items.find(x => x.key === keys[0])?.isFolder)) { const a = document.createElement('a') a.href = API + '/files/batch_download' a.download = '归档.zip' a.style.display = 'none' document.body.appendChild(a) // 使用 POST 触发下载 fetch(API + '/files/batch_download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ keys, mode: 'download' }) }).then(r => { if (!r.ok) throw new Error('下载失败') return r.blob() }).then(blob => { const url = URL.createObjectURL(blob) const a2 = document.createElement('a') a2.href = url a2.download = '归档.zip' document.body.appendChild(a2) a2.click() document.body.removeChild(a2) URL.revokeObjectURL(url) }).catch(() => showToast('下载失败', 'error')) return } // 单选文件:直接下载 const k = keys[0] const it = state.items.find(x => x.key === k) const name = it ? it.name : '文件' downloadFile(k, name) return } if (action === 'share') { if (state.selected.size !== 1) { showToast('请选择一个项目分享', 'info'); return } const key = Array.from(state.selected)[0] showModal('分享临时链接', '
', () => { const out = $('#shareOutput') if (out.dataset.done === '1') return false const ttl = Number($('#shareTtl').value) || 24 out.dataset.done = '1' out.innerHTML = '
正在生成链接…
' apiPost(API + '/share/create', { key: key, ttl_hours: ttl }).then(async (r) => { if (!r || !r.ok) { showToast('分享失败', 'error'); return } const shareUrl = location.origin + API + '/share/access/' + r.token let copied = false try { await navigator.clipboard.writeText(shareUrl); copied = true } catch {} out.innerHTML = '
临时链接(' + ttl + ' 小时后过期)
' + '
' + shareUrl + '
' + (copied ? '
已复制到剪贴板
' : '') }) return false }, '生成并复制') return } if (action === 'raw_url') { if (state.selected.size !== 1) { showToast('请选择一个文件', 'info'); return } const key = Array.from(state.selected)[0] const r = await apiGet(API + '/files/raw_url?key=' + encodeURIComponent(key)) if (!r || !r.ok || !r.url) { showToast('获取原始链接失败', 'error'); return } try { await navigator.clipboard.writeText(r.url) showToast('原始 OSS 链接已复制', 'success') } catch { showToast('复制失败', 'error') } return } if (action === 'delete') { const keys = Array.from(state.selected) if (keys.length === 0) return showModal('确认删除', '
将 ' + keys.length + ' 个项目移到废纸篓?
', async () => { let okCount = 0 for (const k of keys) { const r = await apiPost(API + '/files/delete', { key: k }) if (r && r.ok) okCount++ } if (okCount === keys.length) showToast('已移到废纸篓', 'success') else showToast('部分删除失败', 'error') loadItems() }, '删除') return } if (action === 'new_folder') { showModal('新建文件夹', '', async () => { const name = $('#newFolderName').value.trim() if (!name) { showToast('名称不能为空', 'error'); return false } const r = await apiPost(API + '/files/mkdir', { parent: state.prefix, name: name }) if (r && r.ok) { showToast('创建成功', 'success'); loadItems() } else showToast(r && r.error ? r.error : '创建失败', 'error') }) return } if (action === 'upload') { triggerFileUpload() return } if (action === 'compress') { const keys = Array.from(state.selected) if (keys.length === 0) return showToast('正在压缩...', 'info') const r = await apiPost(API + '/files/batch_download', { keys, mode: 'compress', prefix: state.prefix }) if (r && r.ok) { showToast('已生成 ' + (r.name || '归档.zip'), 'success'); loadItems() } else showToast(r && r.error ? r.error : '压缩失败', 'error') return } if (action === 'refresh') { loadItems(); return } if (action === 'restore') { const keys = Array.from(state.selected) if (keys.length === 0) return let okCount = 0 for (const k of keys) { const it = state.items.find(x => x.key === k) const id = it && it.id if (!id) continue const r = await apiPost(API + '/trash/restore', { id: id }) if (r && r.ok) okCount++ } if (okCount === keys.length) showToast('已还原', 'success') else showToast('部分还原失败', 'error') loadItems() return } if (action === 'empty_perm') { const keys = Array.from(state.selected) showModal('彻底删除', '
此操作不可撤销。确认从 OSS 彻底删除 ' + keys.length + ' 个项目?
', async () => { const ids = state.items.filter(it => keys.indexOf(it.key) >= 0).map(it => it.id).filter(Boolean) const r = await apiPost(API + '/trash/empty', { ids: ids }) if (r && r.ok) showToast('已彻底删除 ' + r.deleted_count + ' 个项目', 'success') else showToast(r && r.error ? r.error : '删除失败', 'error') loadItems() }, '彻底删除') return } } const triggerFileUpload = () => { const input = $('#fileInput') input.value = '' input.click() } const doUpload = async (files) => { const fd = new FormData() fd.append('prefix', state.prefix) for (const f of files) fd.append('files', f) const r = await apiPostFormData(API + '/files/upload', fd) if (r && r.ok) { showToast('已上传 ' + (r.results ? r.results.length : 0) + ' 个文件', 'success'); loadItems() } else showToast(r && r.error ? r.error : '上传失败', 'error') } const uploadFiles = async (files) => { if (!files || files.length === 0) return const oversized = files.some(f => f.size > 50 * 1024 * 1024) if (oversized) { showToast('文件超过 50MB 限制', 'error'); return } await doUpload(files) } const initDragDrop = () => { const overlay = $('#dropOverlay') let dragOver = false window.addEventListener('dragenter', (e) => { if (state.mode !== 'files') return if (e.dataTransfer && Array.from(e.dataTransfer.types || []).indexOf('Files') >= 0) { dragOver = true overlay.style.display = 'flex' } }) window.addEventListener('dragover', (e) => { if (dragOver) { e.preventDefault(); e.stopPropagation() } }) window.addEventListener('dragleave', (e) => { if (!e.relatedTarget || e.relatedTarget === document.documentElement) { dragOver = false overlay.style.display = 'none' } }) window.addEventListener('drop', (e) => { if (dragOver) { e.preventDefault() overlay.style.display = 'none' dragOver = false const files = Array.from(e.dataTransfer && e.dataTransfer.files || []) uploadFiles(files) } }) } const initEvents = () => { $('#btnThemeToggle').addEventListener('click', toggleTheme) $('#btnLogout').addEventListener('click', async () => { try { await apiPost(API + '/auth/logout', {}) } catch {} showLogin() }) $('#btnBack').addEventListener('click', goBack) $('#btnForward').addEventListener('click', goForward) $$('.view-btn').forEach(btn => btn.addEventListener('click', () => { const view = btn.dataset.view if (!view || state.view === view) return state.view = view $$('.view-btn').forEach(b => b.classList.remove('active')) btn.classList.add('active') render() })) $('#searchInput').addEventListener('input', (e) => { state.search = e.target.value.trim() loadItems() }) $$('.sidebar-item').forEach(item => item.addEventListener('click', () => { $$('.sidebar-item').forEach(i => i.classList.remove('active')) item.classList.add('active') const target = item.dataset.target if (target === 'home') { state.mode = 'files'; navigateTo('') } else if (target === 'trash') { state.mode = 'trash'; state.prefix = ''; loadItems() } else if (target === 'images') { state.mode = 'images'; state.prefix = ''; loadItems() } })) $('#pathBar').addEventListener('click', (ev) => { const crumb = ev.target.closest('.crumb') if (!crumb) return }) $('#fileArea').addEventListener('click', (ev) => { if (ev.target === $('#fileArea') || ev.target.classList.contains('empty-state') || ev.target.closest('.empty-state')) { state.selected.clear() state.lastSelectedIdx = -1 updateSelectionUI() } }) $('#fileArea').addEventListener('dblclick', (ev) => { if (ev.target === $('#fileArea') || ev.target.classList.contains('empty-state') || ev.target.closest('.empty-state')) { if (state.prefix) goUp() } }) $('#fileArea').addEventListener('contextmenu', (ev) => { if (ev.target === $('#fileArea') || ev.target.classList.contains('empty-state') || ev.target.closest('.empty-state')) { ev.preventDefault() state.selected.clear() updateSelectionUI() if (state.mode === 'files') openContextMenu(ev.clientX, ev.clientY, null) } }) let dragStart = null let dragRect = null $('#fileArea').addEventListener('mousedown', (ev) => { if (ev.button !== 0) return const target = ev.target if (target.closest('.icon-item') || target.closest('.list-row') || target.closest('.column-row')) return const area = $('#fileArea') const rect = area.getBoundingClientRect() dragStart = { x: ev.clientX, y: ev.clientY, rect: rect } dragRect = document.createElement('div') dragRect.className = 'selection-rect' dragRect.style.left = ev.clientX + 'px' dragRect.style.top = ev.clientY + 'px' dragRect.style.width = '0px' dragRect.style.height = '0px' document.body.appendChild(dragRect) ev.preventDefault() }) window.addEventListener('mousemove', (ev) => { if (!dragStart || !dragRect) return const x1 = dragStart.x const y1 = dragStart.y const x2 = ev.clientX const y2 = ev.clientY const left = Math.min(x1, x2) const top = Math.min(y1, y2) const width = Math.abs(x2 - x1) const height = Math.abs(y2 - y1) dragRect.style.left = left + 'px' dragRect.style.top = top + 'px' dragRect.style.width = width + 'px' dragRect.style.height = height + 'px' const sel = { left, top, right: left + width, bottom: top + height } const els = $$('#fileArea .icon-item, #fileArea .list-row, #fileArea .column-row') for (const el of els) { const r = el.getBoundingClientRect() const overlap = !(r.right < sel.left || r.left > sel.right || r.bottom < sel.top || r.top > sel.bottom) const k = el.dataset.key if (!k) continue if (overlap) state.selected.add(k) else state.selected.delete(k) } updateSelectionUI() }) window.addEventListener('mouseup', () => { if (dragRect) { try { document.body.removeChild(dragRect) } catch {}; dragRect = null } dragStart = null }) document.addEventListener('click', (ev) => { if (!ev.target.closest('.context-menu') && !ev.target.closest('.icon-item') && !ev.target.closest('.list-row') && !ev.target.closest('.column-row')) { hideAllContextMenus() } }) $$('.context-menu .cm-item').forEach(item => { item.addEventListener('click', (ev) => { ev.stopPropagation() const parentMenu = item.closest('.context-menu') const first = parentMenu && parentMenu._firstItem const action = item.dataset.action hideAllContextMenus() if (action) handleMenuAction(action, first) }) }) $('#fileInput').addEventListener('change', (e) => { const files = Array.from(e.target.files || []) uploadFiles(files) }) $('#closePreview').addEventListener('click', closeFilePreview) $('#filePreview').addEventListener('click', (e) => { if (e.target.id === 'filePreview' || e.target.classList.contains('modal-backdrop')) closeFilePreview() }) $('#filePreview').addEventListener('wheel', (e) => { const img = $('#previewImg') if (!img) return e.preventDefault() const delta = e.deltaY > 0 ? -0.15 : 0.15 previewScale = Math.max(0.3, Math.min(5, previewScale + delta)) img.style.transform = 'scale(' + previewScale + ')' }, { passive: false }) document.addEventListener('keydown', (e) => { if ($('#filePreview').style.display === 'flex') { if (e.key === 'Escape') { e.preventDefault(); closeFilePreview() } return } if ($('#modalDialog').style.display === 'flex') { if (e.key === 'Escape') { e.preventDefault(); $('#modalDialog').style.display = 'none' } if (e.key === 'Enter') { e.preventDefault(); $('#modalOk').click() } return } if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a') { if (state.items.length > 0 && document.activeElement && document.activeElement.tagName !== 'INPUT') { e.preventDefault() state.selected.clear() state.items.forEach(it => state.selected.add(it.key)) render() } } if (e.key === 'Backspace' && document.activeElement && document.activeElement.tagName !== 'INPUT') { if (state.mode === 'files') { e.preventDefault(); goUp() } } if (e.key === 'Delete' && document.activeElement && document.activeElement.tagName !== 'INPUT') { if (state.selected.size > 0) { e.preventDefault(); handleMenuAction(state.mode === 'trash' ? 'empty_perm' : 'delete') } } if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'c' && state.selected.size > 0) { e.preventDefault() state.clipboard = { op: 'copy', keys: Array.from(state.selected) } showToast('已复制 ' + state.clipboard.keys.length + ' 个项目到剪贴板', 'success') } if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'v' && state.clipboard && state.mode === 'files') { e.preventDefault() handleMenuAction('paste') } }) // 系统剪贴板粘贴:Ctrl+V 时如果剪贴板有文件,直接上传 document.addEventListener('paste', (e) => { if (state.mode !== 'files') return const items = e.clipboardData && e.clipboardData.items if (!items) return const files = [] for (let i = 0; i < items.length; i++) { if (items[i].kind === 'file') files.push(items[i].getAsFile()) } if (files.length > 0) { e.preventDefault() uploadFiles(files) } }) } document.addEventListener('DOMContentLoaded', main) })()