'use strict'; const $ = id => document.getElementById(id); const dropzone = $('dropzone'); const fileInput = $('fileInput'); const progressList = $('progressList'); const actionRow = $('actionRow'); const uploadBtn = $('uploadBtn'); const clearBtn = $('clearBtn'); const filesList = $('filesList'); const filesMeta = $('filesMeta'); const quotaWrap = $('quotaWrap'); const quotaText = $('quotaText'); const quotaPercent = $('quotaPercent'); const quotaBar = $('quotaBar'); const toastEl = $('toast'); const totalProgress= $('totalProgress'); const totalLabel = $('totalLabel'); // pending[i] = { id, file, xhr, status: 'pending'|'uploading'|'done'|'error'|'cancelled', progress, error } let pending = []; let nextId = 1; let isUploading = false; // --------------------------------------------------------------------------- // Toast // --------------------------------------------------------------------------- let toastTimer = null; function toast(msg, type) { toastEl.textContent = msg; toastEl.className = 'toast show ' + (type || 'info'); if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => toastEl.classList.remove('show'), 2400); } // --------------------------------------------------------------------------- // Drop zone (用 counter 解决子元素上 dragleave 反复触发的 bug) // --------------------------------------------------------------------------- let dragCounter = 0; dropzone.addEventListener('dragenter', e => { e.preventDefault(); dragCounter++; dropzone.classList.add('dragover'); }); dropzone.addEventListener('dragover', e => e.preventDefault()); dropzone.addEventListener('dragleave', e => { e.preventDefault(); dragCounter--; if (dragCounter <= 0) { dragCounter = 0; dropzone.classList.remove('dragover'); } }); dropzone.addEventListener('drop', e => { e.preventDefault(); dragCounter = 0; dropzone.classList.remove('dragover'); if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files); }); fileInput.addEventListener('change', e => { addFiles(e.target.files); // reset 后允许重复选择同一文件 e.target.value = ''; }); // 点击 / 键盘 触发文件选择 dropzone.addEventListener('click', () => fileInput.click()); dropzone.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); } }); // --------------------------------------------------------------------------- // Pending list // --------------------------------------------------------------------------- function addFiles(fileList) { for (const f of fileList) { pending.push({ id: nextId++, file: f, xhr: null, status: 'pending', progress: 0, loaded: 0, }); } renderPending(); } function renderPending() { progressList.innerHTML = ''; if (pending.length === 0) { actionRow.style.display = 'none'; return; } actionRow.style.display = 'flex'; pending.forEach(item => { const div = document.createElement('div'); div.className = 'progress-item'; if (item.status === 'done') div.classList.add('done'); else if (item.status === 'error') div.classList.add('error'); const statusChar = item.status === 'done' ? '✓' : item.status === 'error' ? '✗' : item.status === 'cancelled' ? '⊘' : ''; const sizeInfo = item.file.size > 0 ? `${formatSize(item.loaded)} / ${formatSize(item.file.size)}` : formatSize(item.file.size); div.innerHTML = `
${escapeHtml(item.file.name)} ${sizeInfo} ${statusChar}
`; const actions = div.querySelector('.progress-item-actions'); if (item.status === 'pending' || item.status === 'uploading') { const cancel = document.createElement('button'); cancel.textContent = item.status === 'uploading' ? '取消' : '移除'; cancel.onclick = () => cancelItem(item.id); actions.appendChild(cancel); } else if (item.status === 'error' || item.status === 'cancelled') { const retry = document.createElement('button'); retry.textContent = '重试'; retry.onclick = () => { item.status = 'pending'; item.progress = 0; item.loaded = 0; item.error = null; renderPending(); }; const remove = document.createElement('button'); remove.textContent = '移除'; remove.onclick = () => removeItem(item.id); actions.appendChild(retry); actions.appendChild(remove); } else if (item.status === 'done') { const remove = document.createElement('button'); remove.textContent = '移除'; remove.onclick = () => removeItem(item.id); actions.appendChild(remove); } progressList.appendChild(div); }); // 总进度 const total = pending.length; const finished = pending.filter(p => p.status === 'done').length; if (isUploading && total > 0) { totalProgress.style.display = 'block'; totalLabel.style.display = 'inline'; const pct = Math.round(finished / total * 100); totalProgress.firstElementChild.style.width = pct + '%'; totalLabel.textContent = `${finished} / ${total} · ${pct}%`; } else { totalProgress.style.display = 'none'; totalLabel.style.display = 'none'; } } function cancelItem(id) { const item = pending.find(p => p.id === id); if (!item) return; if (item.xhr) { item.xhr.abort(); item.status = 'cancelled'; } else { removeItem(id); return; } renderPending(); } function removeItem(id) { const item = pending.find(p => p.id === id); if (item && item.xhr && item.xhr.readyState !== XMLHttpRequest.DONE) { item.xhr.abort(); } pending = pending.filter(p => p.id !== id); renderPending(); } clearBtn.addEventListener('click', () => { // 取消所有进行中的上传 pending.forEach(p => { if (p.xhr && p.xhr.readyState !== XMLHttpRequest.DONE) p.xhr.abort(); }); pending = []; isUploading = false; renderPending(); uploadBtn.disabled = false; uploadBtn.textContent = '开始上传'; }); // --------------------------------------------------------------------------- // Upload // --------------------------------------------------------------------------- uploadBtn.addEventListener('click', async () => { if (isUploading) return; const queue = pending.filter(p => p.status === 'pending' || p.status === 'error' || p.status === 'cancelled'); if (queue.length === 0) { toast('没有可上传的文件', 'error'); return; } await loadFiles(); const totalSize = queue.reduce((s, i) => s + i.file.size, 0); if (quotaState.cap > 0 && totalSize > quotaState.cap - quotaState.used) { toast(`队列总大小 ${formatSize(totalSize)} 超过剩余配额 ${formatSize(quotaState.cap - quotaState.used)}`, 'error'); return; } isUploading = true; uploadBtn.disabled = true; uploadBtn.textContent = '上传中...'; const tasks = queue.map(item => async () => { if (item.status !== 'pending' && item.status !== 'error' && item.status !== 'cancelled') return; item.status = 'uploading'; item.progress = 0; item.loaded = 0; item.error = null; renderPending(); try { await uploadOne(item); item.status = 'done'; item.progress = 100; } catch (err) { if (err.name !== 'AbortError') { item.status = 'error'; item.error = err && err.message || 'unknown'; toast(`上传失败: ${item.file.name} - ${item.error}`, 'error'); } } renderPending(); }); await runWithLimit(tasks, 3); isUploading = false; uploadBtn.disabled = false; uploadBtn.textContent = '开始上传'; await loadFiles(); // 已完成的进度条 2.5s 后从 pending 移除, 避免列表里堆一堆"已完成" const doneItems = pending.filter(p => p.status === 'done'); if (doneItems.length > 0) { setTimeout(() => { const doneIds = new Set(doneItems.map(p => p.id)); pending = pending.filter(p => !doneIds.has(p.id)); renderPending(); }, 2500); } }); async function runWithLimit(tasks, limit) { const workers = Array.from({ length: limit }, async () => { while (true) { const t = tasks.shift(); if (!t) return; await t(); } }); await Promise.all(workers); } function uploadOne(item) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); item.xhr = xhr; const fd = new FormData(); fd.append('file', item.file); xhr.open('POST', '/upload'); xhr.upload.onprogress = e => { if (e.lengthComputable) { item.progress = Math.round(e.loaded / e.total * 100); item.loaded = e.loaded; renderPending(); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { try { resolve(JSON.parse(xhr.responseText)); } catch { resolve({}); } } else { let msg = 'HTTP ' + xhr.status; try { msg = JSON.parse(xhr.responseText).error || msg; } catch {} reject(new Error(msg)); } }; xhr.onerror = () => reject(new Error('network error')); xhr.onabort = () => { const e = new Error('aborted'); e.name = 'AbortError'; reject(e); }; xhr.send(fd); }); } // --------------------------------------------------------------------------- // File list // --------------------------------------------------------------------------- let loadSeq = 0; let quotaState = { cap: 0, used: 0 }; async function loadFiles() { const my = ++loadSeq; try { const r = await fetch('/files'); if (my !== loadSeq) return; const data = await r.json(); filesMeta.textContent = `共 ${data.count} 个文件 · TTL ${data.ttl}`; renderQuota(data.quota); quotaState = data.quota; if (!data.files || data.files.length === 0) { filesList.innerHTML = '
暂无文件
'; return; } filesList.innerHTML = ''; data.files.sort((a, b) => new Date(b.mod_time) - new Date(a.mod_time)); data.files.forEach(f => filesList.appendChild(fileRow(f))); } catch (e) { filesMeta.textContent = '加载失败'; } } function renderQuota(q) { if (!q || !q.cap) { quotaWrap.style.display = 'none'; return; } quotaWrap.style.display = 'block'; const pct = q.cap > 0 ? Math.min(100, (q.used / q.cap) * 100) : 0; quotaText.innerHTML = `已用 ${q.used_str || formatSize(q.used)} / ${q.cap_str || formatSize(q.cap)}`; quotaPercent.textContent = pct.toFixed(1) + '%'; quotaBar.firstElementChild.style.width = pct + '%'; // 颜色随占用率 quotaBar.classList.remove('warn', 'danger'); quotaText.classList.remove('warn', 'danger'); if (pct >= 95) { quotaBar.classList.add('danger'); quotaText.classList.add('danger'); } else if (pct >= 80) { quotaBar.classList.add('warn'); quotaText.classList.add('warn'); } } function fileRow(f) { const div = document.createElement('div'); div.className = 'file-item'; const ext = (f.filename.split('.').pop() || '').toLowerCase(); const ttlHtml = `${formatRemain(f.expires_at)}`; div.innerHTML = `
${iconFor(ext)}
${escapeHtml(f.filename)}
${formatSize(f.size)} · ${ttlHtml}
`; div.querySelector('[data-url]').onclick = () => copyToClipboard(location.origin + f.url); div.querySelector('[data-delete]').onclick = () => deleteFile(f.filename, div); return div; } async function deleteFile(filename, rowEl) { if (!confirm(`确定删除 "${filename}" 吗? 该操作不可撤销.`)) return; rowEl.style.opacity = '0.4'; rowEl.style.pointerEvents = 'none'; try { const r = await fetch('/files/' + encodeURIComponent(filename), { method: 'DELETE' }); if (r.status === 404) { toast('文件已不存在 (可能已过期被清理)', 'error'); await loadFiles(); return; } if (!r.ok) { let msg = 'HTTP ' + r.status; try { msg = (await r.json()).error || msg; } catch {} throw new Error(msg); } toast(`已删除: ${filename}`, 'success'); await loadFiles(); } catch (e) { rowEl.style.opacity = ''; rowEl.style.pointerEvents = ''; toast('删除失败: ' + e.message, 'error'); } } async function copyToClipboard(text) { // 兼容 HTTP 环境: clipboard API 不可用时用 fallback try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); toast('链接已复制', 'success'); return; } } catch {} // fallback: 临时 textarea const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { const ok = document.execCommand('copy'); toast(ok ? '链接已复制' : '复制失败, 请手动复制', ok ? 'success' : 'error'); } catch { toast('复制失败, 请手动复制', 'error'); } finally { document.body.removeChild(ta); } } // --------------------------------------------------------------------------- // 倒计时: 每秒刷新一次剩余时间 // --------------------------------------------------------------------------- setInterval(() => { document.querySelectorAll('[data-expires]').forEach(el => { el.textContent = formatRemain(el.dataset.expires); el.classList.toggle('danger', isAlmostExpired(el.dataset.expires)); }); }, 1000); function isAlmostExpired(iso) { const ms = new Date(iso).getTime() - Date.now(); return ms > 0 && ms < 60 * 60 * 1000; // < 1h } function formatRemain(iso) { const ms = new Date(iso).getTime() - Date.now(); if (ms <= 0) return '已过期'; const s = Math.floor(ms / 1000); if (s < 60) return `剩 ${s} 秒`; const m = Math.floor(s / 60); if (m < 60) return `剩 ${m} 分钟`; const h = Math.floor(m / 60); if (h < 24) return `剩 ${h} 小时${m % 60 ? ' ' + (m % 60) + ' 分' : ''}`; const d = Math.floor(h / 24); return `剩 ${d} 天${h % 24 ? ' ' + (h % 24) + ' 小时' : ''}`; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function iconFor(ext) { const map = { pdf:'📄', zip:'📦', rar:'📦', '7z':'📦', txt:'📄', md:'📄', png:'🖼', jpg:'🖼', jpeg:'🖼', gif:'🖼', webp:'🖼', svg:'🖼', mp4:'🎬', mov:'🎬', avi:'🎬', mkv:'🎬', mp3:'🎵', wav:'🎵', flac:'🎵', xls:'📊', xlsx:'📊', csv:'📊', doc:'📃', docx:'📃', ppt:'📽', pptx:'📽' }; return map[ext] || '📁'; } function formatSize(b) { if (b == null || isNaN(b)) return '-'; 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(2) + ' MB'; return (b / 1024 / 1024 / 1024).toFixed(2) + ' GB'; } function formatTime(iso) { const d = new Date(iso); const pad = n => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } // --------------------------------------------------------------------------- // Boot // --------------------------------------------------------------------------- loadFiles(); setInterval(loadFiles, 30000);