Files
lzwlab_cn/transfer/templates/index.html
T

918 lines
35 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#667eea">
<meta name="color-scheme" content="light dark">
<title>文件快传 · lzwlab.cn</title>
<style>
:root{
--primary:#4f6ef7;
--primary-strong:#3b57e0;
--grad:linear-gradient(135deg,#667eea 0%,#764ba2 100%);
--bg:#f5f6fa;
--card:#ffffff;
--text:#1f2430;
--muted:#6b7280;
--border:#e5e7eb;
--border-strong:#d1d5db;
--success:#16a34a;
--danger:#dc2626;
--warning:#d97706;
--shadow:0 4px 16px rgba(31,36,48,.06);
--shadow-lg:0 12px 32px rgba(31,36,48,.12);
--focus:0 0 0 3px rgba(79,110,247,.28);
}
@media (prefers-color-scheme: dark){
:root{
--bg:#12141a;
--card:#1c1f27;
--text:#eef0f5;
--muted:#9aa1ad;
--border:#2c3039;
--border-strong:#3a3f4b;
--shadow:0 4px 16px rgba(0,0,0,.35);
--shadow-lg:0 12px 32px rgba(0,0,0,.5);
}
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{min-height:100%}
body{
background:var(--bg);
color:var(--text);
font-family:"PingFang SC","Microsoft YaHei","Segoe UI",system-ui,-apple-system,sans-serif;
font-size:16px;
line-height:1.5;
-webkit-font-smoothing:antialiased;
}
.hidden{display:none !important}
button,input,select{font:inherit;color:inherit}
button{-webkit-tap-highlight-color:transparent}
button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:none;box-shadow:var(--focus)}
.mono{font-family:ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",Menlo,monospace;font-variant-numeric:tabular-nums}
/* ---------- 登录页 ---------- */
.auth-wrap{
min-height:100vh;min-height:100dvh;
display:flex;align-items:center;justify-content:center;
padding:16px;
background:var(--grad);
}
.auth-card{
width:100%;max-width:400px;
background:var(--card);
border-radius:16px;
box-shadow:var(--shadow-lg);
padding:32px 28px;
text-align:center;
}
.logo{
width:64px;height:64px;margin:0 auto 14px;
display:flex;align-items:center;justify-content:center;
font-size:32px;
border-radius:18px;
background:var(--grad);
box-shadow:var(--shadow);
}
.auth-card h1{font-size:24px;font-weight:700;letter-spacing:.5px}
.auth-card .sub{color:var(--muted);font-size:14px;margin:6px 0 24px}
.field{display:block;text-align:left;margin-bottom:14px}
.field span{display:block;font-size:14px;color:var(--muted);margin-bottom:6px}
.field input,select{
width:100%;min-height:44px;
padding:10px 12px;
border:1px solid var(--border);
border-radius:10px;
background:var(--card);
transition:border-color .15s, box-shadow .15s;
}
.field input:focus,select:focus{border-color:var(--primary);outline:none;box-shadow:var(--focus)}
.form-error{color:var(--danger);font-size:14px;min-height:20px;margin:2px 0 10px;text-align:left}
.btn{
display:inline-flex;align-items:center;justify-content:center;gap:8px;
min-height:44px;padding:10px 18px;
border:1px solid transparent;border-radius:10px;
background:transparent;cursor:pointer;
font-size:15px;font-weight:600;line-height:1.2;
transition:filter .15s, transform .05s, background .15s, border-color .15s, color .15s;
text-decoration:none;
}
.btn:active{transform:translateY(1px)}
.btn:disabled{opacity:.55;cursor:not-allowed;filter:none}
.btn-primary{background:var(--grad);color:#fff;box-shadow:var(--shadow)}
.btn-primary:not(:disabled):hover{filter:brightness(1.06)}
.btn-ghost{border-color:var(--border);color:var(--text);background:var(--card)}
.btn-ghost:not(:disabled):hover{border-color:var(--border-strong)}
.btn-danger{background:var(--danger);color:#fff}
.btn-danger-ghost{border-color:var(--danger);color:var(--danger);background:transparent}
.btn-danger-ghost:not(:disabled):hover{background:rgba(220,38,38,.08)}
.btn-sm{min-height:36px;padding:7px 12px;font-size:14px;border-radius:8px}
.btn-block{width:100%}
/* ---------- 主界面 ---------- */
.topbar{
position:sticky;top:0;z-index:20;
background:var(--card);
border-bottom:1px solid var(--border);
box-shadow:var(--shadow);
}
.topbar-inner{
max-width:980px;margin:0 auto;padding:12px 16px;
display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;
}
.brand{display:flex;align-items:center;gap:10px;font-size:18px;font-weight:700}
.brand .logo-sm{
width:38px;height:38px;border-radius:10px;background:var(--grad);
display:flex;align-items:center;justify-content:center;font-size:20px;
}
.brand-sub{font-size:12px;font-weight:400;color:var(--muted)}
.user-box{display:flex;align-items:center;gap:10px}
.user-badge{display:inline-flex;align-items:center;gap:6px;font-size:14px;color:var(--muted);padding:6px 12px;border-radius:999px;background:var(--bg);border:1px solid var(--border)}
.user-badge b{color:var(--text);font-weight:600}
.container{max-width:980px;margin:0 auto;padding:20px 16px calc(32px + env(safe-area-inset-bottom));display:grid;gap:16px}
.card{
background:var(--card);border:1px solid var(--border);border-radius:12px;
box-shadow:var(--shadow);padding:20px;
}
.card h2{font-size:18px;font-weight:700;margin-bottom:14px}
/* 上传区 */
.drop-zone{
border:2px dashed var(--border-strong);border-radius:12px;
padding:32px 16px;text-align:center;cursor:pointer;
transition:border-color .15s, background .15s;
}
.drop-zone:hover,.drop-zone.dragover{border-color:var(--primary);background:rgba(79,110,247,.05)}
.dz-icon{font-size:34px;line-height:1;margin-bottom:8px}
.drop-zone p{color:var(--text)}
.drop-zone .hint{color:var(--muted);font-size:13px;margin-top:4px}
.upload-options{
display:flex;align-items:center;gap:16px;flex-wrap:wrap;
margin-top:14px;padding-top:14px;border-top:1px solid var(--border);
}
.field-inline{display:flex;align-items:center;gap:10px;font-size:14px;color:var(--muted);white-space:nowrap}
.field-inline select{width:auto;min-width:120px}
.upload-options .hint{font-size:12px;color:var(--muted)}
.queue{list-style:none;margin:14px 0 0;display:grid;gap:10px}
.queue-item{
display:flex;align-items:center;gap:12px;flex-wrap:wrap;
border:1px solid var(--border);border-radius:10px;padding:12px;
}
.q-info{flex:1 1 240px;min-width:0}
.q-name{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.q-meta{font-size:12px;color:var(--muted);margin:3px 0 6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.q-side{display:flex;align-items:center;gap:10px}
.q-pct{min-width:44px;text-align:right;font-size:13px;color:var(--muted)}
.q-status{font-size:13px;font-weight:600}
.q-status.ok{color:var(--success)}
.q-status.err{color:var(--danger)}
.q-status.cancel{color:var(--muted)}
.progress{
width:100%;height:8px;border-radius:999px;background:var(--border);
overflow:hidden;
}
.progress-bar{
height:100%;width:0;border-radius:999px;
background:linear-gradient(90deg,#667eea,#8b5cf6);
transition:width .15s linear;
}
.q-progress{width:100%;flex-basis:100%;display:flex;align-items:center;gap:10px}
.q-progress .progress{flex:1}
.q-progress .mono{font-size:12px;color:var(--muted);min-width:40px;text-align:right}
/* 文件列表 */
.section-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:12px}
.section-head h2{margin-bottom:0}
.count{font-size:13px;color:var(--muted);font-weight:400;margin-left:6px}
.head-actions{display:flex;gap:8px;flex-wrap:wrap}
.file-list{list-style:none;display:grid;gap:10px}
.file-row{
display:grid;grid-template-columns:auto minmax(0,1fr) auto;
gap:12px;align-items:center;
border:1px solid var(--border);border-radius:12px;padding:14px;
transition:border-color .15s, box-shadow .15s;
}
.file-row:hover{border-color:var(--border-strong);box-shadow:var(--shadow)}
.file-icon{
width:44px;height:44px;border-radius:10px;background:var(--bg);
display:flex;align-items:center;justify-content:center;font-size:22px;
border:1px solid var(--border);
}
.file-main{min-width:0}
.file-name{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.file-meta{display:flex;flex-wrap:wrap;gap:6px 16px;color:var(--muted);font-size:13px;margin-top:4px}
.file-meta .sep{display:none}
.expire{font-weight:600}
.expire.ok{color:var(--success)}
.expire.warn{color:var(--warning)}
.expire.danger{color:var(--danger)}
.file-actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}
.dl-progress{grid-column:1/-1;display:flex;align-items:center;gap:10px;margin-top:2px}
.dl-progress .progress{flex:1}
.dl-progress .mono{font-size:12px;color:var(--muted);min-width:44px;text-align:right}
.empty{
text-align:center;padding:40px 16px;color:var(--muted);
border:1px dashed var(--border-strong);border-radius:12px;
}
.empty .empty-icon{font-size:36px;display:block;margin-bottom:8px}
/* Toast 与确认框 */
.toasts{
position:fixed;top:16px;right:16px;z-index:1000;
display:flex;flex-direction:column;gap:8px;
max-width:min(360px,calc(100vw - 32px));
}
.toast{
background:var(--card);border:1px solid var(--border);border-radius:10px;
box-shadow:var(--shadow-lg);padding:12px 14px;font-size:14px;
display:flex;align-items:center;gap:8px;
animation:toast-in .2s ease-out;
}
.toast.info{border-left:4px solid var(--primary)}
.toast.success{border-left:4px solid var(--success)}
.toast.error{border-left:4px solid var(--danger)}
@keyframes toast-in{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}
.modal-root{
position:fixed;inset:0;z-index:900;background:rgba(15,18,26,.5);
display:flex;align-items:center;justify-content:center;padding:16px;
}
.modal-card{
background:var(--card);border-radius:12px;box-shadow:var(--shadow-lg);
width:100%;max-width:400px;padding:20px;
}
.modal-card h3{font-size:17px;margin-bottom:8px}
.modal-card p{color:var(--muted);font-size:14px;margin-bottom:18px;word-break:break-all}
.modal-actions{display:flex;justify-content:flex-end;gap:10px}
@media (max-width:640px){
.card{padding:16px}
.file-row{grid-template-columns:auto minmax(0,1fr);padding:12px}
.file-actions{grid-column:1/-1;justify-content:stretch}
.file-actions .btn{flex:1}
.file-meta{font-size:12px;gap:4px 10px}
.topbar-inner{padding:10px 12px}
.container{padding:14px 12px calc(28px + env(safe-area-inset-bottom));gap:12px}
.toasts{left:16px;right:16px;top:12px;align-items:stretch}
.q-side{width:100%;justify-content:flex-end}
.q-pct{flex:1;text-align:right}
}
@media (prefers-reduced-motion: reduce){
*{transition:none !important;animation:none !important}
}
</style>
</head>
<body>
<section id="loginView" class="auth-wrap hidden" aria-label="登录">
<form id="loginForm" class="auth-card" autocomplete="on">
<div class="logo" aria-hidden="true">📦</div>
<h1>文件快传</h1>
<p class="sub">lzwlab.cn 私人在线文件暂存</p>
<label class="field">
<span>用户名</span>
<input id="username" name="username" type="text" autocomplete="username" required maxlength="64">
</label>
<label class="field">
<span>密码</span>
<input id="password" name="password" type="password" autocomplete="current-password" required>
</label>
<p id="loginError" class="form-error hidden" role="alert"></p>
<button id="loginBtn" class="btn btn-primary btn-block" type="submit">登 录</button>
</form>
</section>
<div id="appView" class="hidden">
<header class="topbar">
<div class="topbar-inner">
<div class="brand"><span class="logo-sm" aria-hidden="true">📦</span>文件快传<span class="brand-sub">lzwlab.cn</span></div>
<div class="user-box">
<span class="user-badge">👤 <b id="userName"></b></span>
<button id="logoutBtn" class="btn btn-ghost btn-sm" type="button">退出</button>
</div>
</div>
</header>
<main class="container">
<section class="card" aria-label="上传文件">
<h2>上传文件</h2>
<div id="dropZone" class="drop-zone" role="button" tabindex="0" aria-label="选择或拖拽上传文件">
<div class="dz-icon" aria-hidden="true">⬆️</div>
<p>点击选择文件,或将文件拖到此处</p>
<p class="hint">可一次选择多个文件,单个文件最大 5 GB</p>
</div>
<input type="file" id="fileInput" multiple hidden>
<div class="upload-options">
<label class="field-inline">文件有效期
<select id="expiresSelect" aria-label="文件有效期">
<option value="1">1 小时</option>
<option value="24">1 天</option>
<option value="72">3 天</option>
<option value="168" selected>7 天(默认)</option>
<option value="720">30 天</option>
</select>
</label>
<p class="hint">到期后文件自动删除;最短 1 小时,最长 30 天</p>
</div>
<ul id="queue" class="queue" aria-live="polite"></ul>
</section>
<section class="card" aria-label="我的暂存文件">
<div class="section-head">
<h2>我的暂存文件<span id="fileCount" class="count"></span></h2>
<div class="head-actions">
<button id="cleanupBtn" class="btn btn-ghost btn-sm" type="button">立即清理过期</button>
<button id="refreshBtn" class="btn btn-ghost btn-sm" type="button">刷新</button>
</div>
</div>
<ul id="fileList" class="file-list" aria-live="polite"></ul>
<div id="emptyState" class="empty hidden">
<span class="empty-icon" aria-hidden="true">📭</span>
<p>还没有暂存文件,先上传一个吧</p>
</div>
</section>
</main>
</div>
<div id="toasts" class="toasts" aria-live="polite" aria-atomic="false"></div>
<div id="modalRoot" class="modal-root hidden" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="modal-card">
<h3 id="modalTitle">确认</h3>
<p id="modalMessage"></p>
<div class="modal-actions">
<button id="modalCancel" class="btn btn-ghost" type="button">取消</button>
<button id="modalConfirm" class="btn btn-primary" type="button">确定</button>
</div>
</div>
</div>
<script>
(function () {
'use strict';
var $ = function (sel, root) { return (root || document).querySelector(sel); };
var show = function (el) { el.classList.remove('hidden'); };
var hide = function (el) { el.classList.add('hidden'); };
var loginView = $('#loginView'), appView = $('#appView');
var loginForm = $('#loginForm'), loginError = $('#loginError'), loginBtn = $('#loginBtn');
var usernameInput = $('#username'), passwordInput = $('#password');
var userName = $('#userName'), logoutBtn = $('#logoutBtn');
var dropZone = $('#dropZone'), fileInput = $('#fileInput'), expiresSelect = $('#expiresSelect');
var queueEl = $('#queue'), fileListEl = $('#fileList'), emptyState = $('#emptyState');
var fileCountEl = $('#fileCount'), cleanupBtn = $('#cleanupBtn'), refreshBtn = $('#refreshBtn');
var toastsEl = $('#toasts');
var modalRoot = $('#modalRoot'), modalTitle = $('#modalTitle'), modalMessage = $('#modalMessage');
var modalCancel = $('#modalCancel'), modalConfirm = $('#modalConfirm');
var currentUser = null;
var files = [];
var queue = [];
var queueSeq = 0;
var uploadBusy = false;
var modalResolver = null;
/* ---------- 工具函数 ---------- */
function fmtSize(bytes) {
if (!Number.isFinite(bytes) || bytes < 0) return '-';
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
var v = bytes, i = 0;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return (i === 0 ? v.toFixed(0) : v.toFixed(1)) + ' ' + units[i];
}
function fmtTime(iso) {
var d = new Date(iso);
if (Number.isNaN(d.getTime())) return '-';
return d.toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false
});
}
function fmtRemaining(seconds) {
if (seconds <= 0) return '已过期';
var d = Math.floor(seconds / 86400);
var h = Math.floor((seconds % 86400) / 3600);
var m = Math.floor((seconds % 3600) / 60);
if (d > 0) return '剩 ' + d + ' 天 ' + h + ' 小时';
if (h > 0) return '剩 ' + h + ' 小时 ' + m + ' 分';
if (m > 0) return '剩 ' + m + ' 分钟';
return '即将过期';
}
function toast(message, type) {
type = type || 'info';
var el = document.createElement('div');
el.className = 'toast ' + type;
var icon = type === 'success' ? '✅' : type === 'error' ? '⚠️' : '️';
el.innerHTML = '<span aria-hidden="true">' + icon + '</span><span></span>';
el.lastChild.textContent = message;
toastsEl.appendChild(el);
setTimeout(function () { el.remove(); }, 4000);
}
function openModal(title, message, danger, confirmText) {
modalTitle.textContent = title;
modalMessage.textContent = message;
modalConfirm.className = 'btn ' + (danger ? 'btn-danger' : 'btn-primary');
modalConfirm.textContent = confirmText || (danger ? '删除' : '确定');
show(modalRoot);
modalConfirm.focus();
return new Promise(function (resolve) { modalResolver = resolve; });
}
function closeModal(result) {
hide(modalRoot);
if (modalResolver) { modalResolver(result); modalResolver = null; }
}
modalCancel.addEventListener('click', function () { closeModal(false); });
modalConfirm.addEventListener('click', function () { closeModal(true); });
modalRoot.addEventListener('click', function (e) {
if (e.target === modalRoot) closeModal(false);
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !modalRoot.classList.contains('hidden')) closeModal(false);
});
function parseFilename(cd) {
if (!cd) return null;
var star = /filename*=UTF-8''([^;]+)/i.exec(cd);
if (star) { try { return decodeURIComponent(star[1]); } catch (e) {} }
var plain = /filename="?([^";]+)"?/i.exec(cd);
return plain ? plain[1] : null;
}
function fileIcon(name) {
var ext = (name.split('.').pop() || '').toLowerCase();
if (/(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/.test(ext)) return '🖼️';
if (/(mp4|mkv|mov|avi|wmv|flv|webm)$/.test(ext)) return '🎬';
if (/(mp3|wav|flac|aac|ogg|m4a)$/.test(ext)) return '🎵';
if (/(zip|rar|7z|tar|gz|bz2|xz)$/.test(ext)) return '🗜️';
if (ext === 'pdf') return '📕';
if (/(docx?|xlsx?|pptx?|odt|ods)$/.test(ext)) return '📄';
if (/(js|ts|py|java|c|cpp|go|rs|html|css|json|xml|sh|sql)$/.test(ext)) return '🧩';
return '📄';
}
/* ---------- 会话 / 登录 ---------- */
function showLogin() {
currentUser = null;
hide(appView); show(loginView);
passwordInput.value = '';
hide(loginError);
loginBtn.disabled = false; loginBtn.textContent = '登 录';
}
function showApp(user) {
currentUser = user;
hide(loginView); show(appView);
userName.textContent = user;
loadFiles(true);
}
function handle401() {
toast('登录已过期,请重新登录', 'error');
showLogin();
}
async function checkSession() {
try {
var resp = await fetch('api/session', { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (resp.status === 401) { showLogin(); return; }
var data = await resp.json();
if (data.logged_in) showApp(data.user); else showLogin();
} catch (e) {
showLogin();
toast('无法连接服务器', 'error');
}
}
loginForm.addEventListener('submit', async function (e) {
e.preventDefault();
hide(loginError);
loginBtn.disabled = true; loginBtn.textContent = '登录中…';
try {
var resp = await fetch('api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify({ username: usernameInput.value.trim(), password: passwordInput.value })
});
var data = await resp.json().catch(function () { return { ok: false, error: '服务器错误' }; });
if (resp.ok && data.ok) {
toast('欢迎回来,' + data.user, 'success');
showApp(data.user);
} else {
loginError.textContent = data.error || '用户名或密码错误';
show(loginError);
}
} catch (e) {
loginError.textContent = '网络错误,请重试';
show(loginError);
}
loginBtn.disabled = false; loginBtn.textContent = '登 录';
});
logoutBtn.addEventListener('click', async function () {
logoutBtn.disabled = true;
try {
await fetch('api/logout', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' } });
} catch (e) {}
logoutBtn.disabled = false;
showLogin();
});
/* ---------- 文件列表 ---------- */
async function loadFiles(showErrors) {
try {
var resp = await fetch('api/files', { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (resp.status === 401) { handle401(); return; }
var data = await resp.json();
if (!resp.ok) {
if (showErrors) toast(data.error || '加载文件列表失败', 'error');
return;
}
files = data.files || [];
renderFiles();
} catch (e) {
if (showErrors) toast('加载文件列表失败', 'error');
}
}
function renderFiles() {
fileListEl.innerHTML = '';
if (!files.length) { show(emptyState); fileCountEl.textContent = ''; return; }
hide(emptyState);
fileCountEl.textContent = '' + files.length + ' 个)';
files.forEach(function (f) {
var li = document.createElement('li');
li.className = 'file-row';
li.dataset.id = f.id;
var icon = document.createElement('div');
icon.className = 'file-icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = fileIcon(f.name);
var main = document.createElement('div');
main.className = 'file-main';
var name = document.createElement('div');
name.className = 'file-name';
name.title = f.name;
name.textContent = f.name;
main.appendChild(name);
var meta = document.createElement('div');
meta.className = 'file-meta';
var s1 = document.createElement('span'); s1.className = 'mono'; s1.textContent = fmtSize(f.size);
var s2 = document.createElement('span'); s2.textContent = '上传于 ' + fmtTime(f.created_at);
var s3 = document.createElement('span'); s3.className = 'expire';
if (f.remaining_seconds <= 0) { s3.classList.add('danger'); }
else if (f.remaining_seconds < 24 * 3600) { s3.classList.add('warn'); }
else { s3.classList.add('ok'); }
s3.textContent = fmtRemaining(f.remaining_seconds);
var s4 = document.createElement('span'); s4.textContent = '下载 ' + f.downloads + ' 次';
meta.appendChild(s1); meta.appendChild(s2); meta.appendChild(s3); meta.appendChild(s4);
main.appendChild(meta);
var dl = document.createElement('div');
dl.className = 'dl-progress hidden';
dl.setAttribute('role', 'progressbar');
dl.setAttribute('aria-valuemin', '0'); dl.setAttribute('aria-valuemax', '100'); dl.setAttribute('aria-valuenow', '0');
var dlTrack = document.createElement('div'); dlTrack.className = 'progress';
var dlBar = document.createElement('div'); dlBar.className = 'progress-bar';
dlTrack.appendChild(dlBar);
var dlPct = document.createElement('span'); dlPct.className = 'mono'; dlPct.textContent = '0%';
dl.appendChild(dlTrack); dl.appendChild(dlPct);
main.appendChild(dl);
var actions = document.createElement('div');
actions.className = 'file-actions';
var btnDl = document.createElement('button');
btnDl.type = 'button'; btnDl.className = 'btn btn-primary btn-sm'; btnDl.dataset.id = f.id;
btnDl.textContent = '下载';
var linkNative = document.createElement('a');
linkNative.className = 'btn btn-ghost btn-sm'; linkNative.href = 'api/download/' + f.id + '?dl=1';
linkNative.dataset.id = f.id; linkNative.textContent = '系统下载';
var btnDel = document.createElement('button');
btnDel.type = 'button'; btnDel.className = 'btn btn-danger-ghost btn-sm'; btnDel.dataset.id = f.id;
btnDel.textContent = '删除';
actions.appendChild(btnDl); actions.appendChild(linkNative); actions.appendChild(btnDel);
li.appendChild(icon); li.appendChild(main); li.appendChild(actions);
fileListEl.appendChild(li);
});
}
fileListEl.addEventListener('click', async function (e) {
var btn = e.target.closest('button[data-id], a[data-id]');
if (!btn) return;
var id = btn.dataset.id;
var f = files.find(function (x) { return x.id === id; });
if (!f) return;
if (btn.classList.contains('btn-danger-ghost')) {
var ok = await openModal('删除文件', '确定删除「' + f.name + '」吗?此操作不可恢复。', true);
if (!ok) return;
btn.disabled = true; btn.textContent = '删除中…';
try {
var resp = await fetch('api/files/' + id, { method: 'DELETE', headers: { 'X-Requested-With': 'XMLHttpRequest' } });
var data = await resp.json().catch(function () { return { ok: false }; });
if (resp.ok && data.ok) {
toast('已删除:' + f.name, 'success');
loadFiles(true);
} else if (resp.status === 401) { handle401(); }
else { toast(data.error || '删除失败', 'error'); btn.disabled = false; btn.textContent = '删除'; }
} catch (err) {
toast('网络错误', 'error'); btn.disabled = false; btn.textContent = '删除';
}
return;
}
if (btn.classList.contains('btn-primary')) {
startDownload(f, btn);
}
});
function startDownload(f, btn) {
var li = fileListEl.querySelector('[data-id="' + f.id + '"]');
if (!li) return;
var dl = li.querySelector('.dl-progress');
var bar = li.querySelector('.dl-progress .progress-bar');
var pct = li.querySelector('.dl-progress .mono');
show(dl);
dl.setAttribute('aria-valuenow', '0');
bar.style.width = '0%'; pct.textContent = '0%';
btn.disabled = true; btn.textContent = '下载中…';
var xhr = new XMLHttpRequest();
xhr.open('GET', 'api/download/' + f.id + '?dl=1', true);
xhr.responseType = 'blob';
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.onprogress = function (e) {
if (e.lengthComputable) {
var p = Math.round(e.loaded / e.total * 100);
bar.style.width = p + '%';
pct.textContent = p + '%';
dl.setAttribute('aria-valuenow', String(p));
}
};
xhr.onload = async function () {
if (xhr.status === 200) {
var name = parseFilename(xhr.getResponseHeader('Content-Disposition')) || f.name;
var url = URL.createObjectURL(xhr.response);
var a = document.createElement('a');
a.href = url; a.download = name;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 1500);
bar.style.width = '100%'; pct.textContent = '100%';
dl.setAttribute('aria-valuenow', '100');
toast('下载完成:' + name, 'success');
loadFiles(true);
} else if (xhr.status === 401) {
handle401();
} else {
var msg = '下载失败';
try {
if (xhr.response && xhr.response instanceof Blob) {
var text = await xhr.response.text();
var parsed = JSON.parse(text);
if (parsed && parsed.error) msg = parsed.error;
}
} catch (e) {}
toast(msg, 'error');
hide(dl);
btn.disabled = false; btn.textContent = '下载';
}
};
xhr.onerror = function () {
toast('网络错误,下载中断', 'error');
hide(dl);
btn.disabled = false; btn.textContent = '下载';
};
xhr.send();
}
/* ---------- 清理 / 刷新 ---------- */
cleanupBtn.addEventListener('click', async function () {
cleanupBtn.disabled = true;
try {
var resp = await fetch('api/cleanup', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' } });
var data = await resp.json().catch(function () { return { ok: false }; });
if (resp.ok && data.ok) {
toast('清理完成:移除 ' + data.removed + ' 个过期文件', 'success');
} else if (resp.status === 401) { handle401(); }
else { toast(data.error || '清理失败', 'error'); }
loadFiles(true);
} catch (e) {
toast('网络错误', 'error');
}
cleanupBtn.disabled = false;
});
refreshBtn.addEventListener('click', function () { loadFiles(true); });
/* ---------- 上传队列 ---------- */
dropZone.addEventListener('click', function () { fileInput.click(); });
dropZone.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); }
});
['dragenter', 'dragover'].forEach(function (ev) {
dropZone.addEventListener(ev, function (e) { e.preventDefault(); dropZone.classList.add('dragover'); });
});
['dragleave', 'drop'].forEach(function (ev) {
dropZone.addEventListener(ev, function (e) { e.preventDefault(); dropZone.classList.remove('dragover'); });
});
dropZone.addEventListener('drop', function (e) {
var list = e.dataTransfer && e.dataTransfer.files;
if (list && list.length) enqueueFiles(Array.prototype.slice.call(list));
});
fileInput.addEventListener('change', function () {
if (fileInput.files.length) enqueueFiles(Array.prototype.slice.call(fileInput.files));
fileInput.value = '';
});
function enqueueFiles(fileArr) {
var hours = Number(expiresSelect.value) || 168;
fileArr.forEach(function (file) {
queue.push({
id: ++queueSeq,
file: file,
expiresHours: hours,
status: 'pending',
progress: 0,
loaded: 0,
total: file.size || 0,
error: '',
xhr: null
});
});
renderQueue();
pumpQueue();
}
function renderQueue() {
queueEl.innerHTML = '';
queue.forEach(function (item) {
var li = document.createElement('li');
li.className = 'queue-item';
li.dataset.qid = item.id;
var info = document.createElement('div');
info.className = 'q-info';
var name = document.createElement('div');
name.className = 'q-name'; name.title = item.file.name; name.textContent = item.file.name;
var meta = document.createElement('div');
meta.className = 'q-meta mono';
info.appendChild(name); info.appendChild(meta);
var progress = document.createElement('div');
progress.className = 'q-progress';
var track = document.createElement('div'); track.className = 'progress';
var bar = document.createElement('div'); bar.className = 'progress-bar';
track.appendChild(bar);
var pct = document.createElement('span'); pct.className = 'mono';
progress.appendChild(track); progress.appendChild(pct);
var side = document.createElement('div');
side.className = 'q-side';
var status = document.createElement('span'); status.className = 'q-status';
var cancel = document.createElement('button');
cancel.type = 'button'; cancel.className = 'btn btn-ghost btn-sm'; cancel.textContent = '取消';
cancel.addEventListener('click', function () { cancelItem(item); });
side.appendChild(status); side.appendChild(cancel);
li.appendChild(info); li.appendChild(progress); li.appendChild(side);
item.root = li; item.refs = { meta: meta, bar: bar, pct: pct, status: status, cancel: cancel };
queueEl.appendChild(li);
updateQueueItem(item);
});
}
function statusText(item) {
if (item.status === 'pending') return '等待上传';
if (item.status === 'uploading') return '上传中';
if (item.status === 'done') return '完成';
if (item.status === 'error') return item.error || '上传失败';
if (item.status === 'canceled') return '已取消';
return '';
}
function updateQueueItem(item) {
if (!item.root || !item.root.isConnected) { renderQueue(); return; }
var refs = item.refs;
refs.meta.textContent = fmtSize(item.loaded) + ' / ' + fmtSize(item.total) + ' · 有效期 ' + item.expiresHours + ' 小时';
refs.bar.style.width = item.progress + '%';
refs.pct.textContent = item.progress + '%';
refs.status.textContent = statusText(item);
refs.status.className = 'q-status ' +
(item.status === 'done' ? 'ok' : item.status === 'error' ? 'err' : item.status === 'canceled' ? 'cancel' : '');
if (item.status === 'done') {
refs.cancel.textContent = '移除';
refs.cancel.onclick = function () { removeQueueItem(item); };
} else if (item.status === 'error') {
refs.cancel.textContent = '重试';
refs.cancel.onclick = function () { retryItem(item); };
} else {
refs.cancel.textContent = '取消';
refs.cancel.onclick = function () { cancelItem(item); };
}
}
function removeQueueItem(item) {
queue = queue.filter(function (x) { return x.id !== item.id; });
renderQueue();
}
function retryItem(item) {
item.status = 'pending'; item.progress = 0; item.loaded = 0; item.error = ''; item.xhr = null;
updateQueueItem(item);
pumpQueue();
}
function cancelItem(item) {
if (item.status === 'uploading' && item.xhr) { item.xhr.abort(); }
else { item.status = 'canceled'; updateQueueItem(item); }
}
function pumpQueue() {
if (uploadBusy) return;
uploadBusy = true;
(async function loop() {
while (true) {
var item = queue.find(function (x) { return x.status === 'pending'; });
if (!item) break;
await uploadOne(item);
}
uploadBusy = false;
})();
}
function uploadOne(item) {
return new Promise(function (resolve) {
item.status = 'uploading';
item.progress = 0; item.loaded = 0; item.error = '';
updateQueueItem(item);
var fd = new FormData();
fd.append('file', item.file, item.file.name);
fd.append('expires_hours', String(item.expiresHours));
var xhr = new XMLHttpRequest();
item.xhr = xhr;
xhr.open('POST', 'api/upload', true);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.upload.onprogress = function (e) {
if (e.lengthComputable) {
item.total = e.total;
item.loaded = e.loaded;
item.progress = e.total ? Math.round(e.loaded / e.total * 100) : 0;
updateQueueItem(item);
}
};
xhr.onload = function () {
if (xhr.status === 200) {
item.progress = 100;
item.status = 'done';
updateQueueItem(item);
toast('上传完成:' + item.file.name, 'success');
loadFiles(true);
} else if (xhr.status === 401) {
item.status = 'error'; item.error = '登录已过期';
updateQueueItem(item);
handle401();
} else {
var msg = '上传失败';
try {
var parsed = JSON.parse(xhr.responseText);
if (parsed && parsed.error) msg = parsed.error;
} catch (e) {}
item.status = 'error'; item.error = msg;
updateQueueItem(item);
toast(msg, 'error');
}
resolve();
};
xhr.onerror = function () {
if (item.status !== 'canceled') {
item.status = 'error'; item.error = '网络错误';
updateQueueItem(item);
toast('上传失败:网络错误', 'error');
}
resolve();
};
xhr.onabort = function () {
item.status = 'canceled';
updateQueueItem(item);
resolve();
};
xhr.send(fd);
});
}
/* ---------- 启动 ---------- */
setInterval(function () {
if (currentUser) loadFiles(false);
}, 30000);
checkSession();
})();
</script>
</body>
</html>