Files
lzwlab_cn/transfer/templates/index.html
T
waiwaylee 5af69d694f feat: 文件快传新增共享剪贴板(跨客户端粘贴、查看与一键复制)
- 后端:新增 /api/clips(列表/新建)、/api/clips/<id>(取全文/删除)、/api/clips/<id>/copy(复制计数),列表仅返回 240 字符预览
- 存储:SQLite 新增 clips 表(含 expires_at 索引),启动自动建表,线上老库无需手工迁移
- 前端:剪贴板卡片支持读取本机剪贴板、来源备注、查看全文、一键复制到本机剪贴板、单条删除与复制次数
- 清理:systemd 定时任务与 /api/cleanup 同时清理过期剪贴板
- 配置:新增 max_clip_length / max_clips / clip_default_expires_hours
- 部署:deploy_transfer.py 新增 --keep-credentials(不重置登录密码与会话密钥)与 SSH 连接重试
- 文档:README / setup.md / CHANGELOG 同步更新
2026-10-11 22:20:54 +08:00

1309 lines
51 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}
/* ---------- 剪贴板 ---------- */
.clip-hint{font-size:13px;color:var(--muted);margin:-4px 0 12px}
.clip-input{
width:100%;min-height:120px;resize:vertical;display:block;
padding:12px;
border:1px solid var(--border);border-radius:10px;
background:var(--bg);color:var(--text);
font-family:ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",Menlo,monospace;
font-size:14px;line-height:1.55;
transition:border-color .15s, box-shadow .15s;
}
.clip-input:focus{border-color:var(--primary);outline:none;box-shadow:var(--focus)}
.clip-toolbar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-top:12px}
.clip-toolbar .field-inline input[type="text"]{
min-width:150px;min-height:36px;padding:7px 10px;border-radius:8px;
}
.clip-chars{font-size:12px;color:var(--muted);white-space:nowrap}
.clip-chars.over{color:var(--danger);font-weight:600}
.clip-spacer{flex:1 1 auto}
.clip-list{list-style:none;display:grid;gap:10px;margin-top:16px}
.clip-row{
display:grid;grid-template-columns:minmax(0,1fr) auto;
gap:12px;align-items:start;
border:1px solid var(--border);border-radius:12px;padding:14px;
transition:border-color .15s, box-shadow .15s;
}
.clip-row:hover{border-color:var(--border-strong);box-shadow:var(--shadow)}
.clip-main{min-width:0}
.clip-meta{display:flex;align-items:center;flex-wrap:wrap;gap:6px 14px;color:var(--muted);font-size:12px;margin-bottom:8px}
.clip-client{
display:inline-block;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
padding:2px 9px;border-radius:999px;
background:var(--bg);border:1px solid var(--border);color:var(--text);font-weight:600;
}
.clip-text{
margin:0;padding:10px 12px;
background:var(--bg);border:1px solid var(--border);border-radius:8px;
font-family:ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",Menlo,monospace;
font-size:13px;line-height:1.55;
white-space:pre-wrap;overflow-wrap:anywhere;
max-height:5.4em;overflow:hidden;
}
.clip-row.expanded .clip-text{max-height:340px;overflow:auto}
.clip-actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}
/* 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}
.clip-row{grid-template-columns:minmax(0,1fr);padding:12px}
.clip-actions{justify-content:stretch}
.clip-actions .btn{flex:1}
.clip-toolbar{gap:10px}
.clip-toolbar .field-inline{flex:1 1 150px}
.clip-toolbar .field-inline input[type="text"]{width:100%;min-width:0}
.clip-spacer{display:none}
}
@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="clipCount" class="count"></span></h2>
<div class="head-actions">
<button id="clipPasteBtn" class="btn btn-ghost btn-sm" type="button">📋 读取本机剪贴板</button>
<button id="clipRefreshBtn" class="btn btn-ghost btn-sm" type="button">刷新</button>
</div>
</div>
<p class="clip-hint">在一台客户端粘贴文本并发送,其他客户端登录后即可查看,点“复制”即可一键写入本机剪贴板。</p>
<textarea id="clipInput" class="clip-input" rows="5" spellcheck="false"
placeholder="在此粘贴或输入任意文本(Ctrl / ⌘ + Enter 直接发送)" aria-label="要发送到共享剪贴板的文本"></textarea>
<div class="clip-toolbar">
<label class="field-inline">有效期
<select id="clipExpiresSelect" aria-label="剪贴板文本有效期">
<option value="1">1 小时</option>
<option value="6">6 小时</option>
<option value="24" selected>1 天(默认)</option>
<option value="168">7 天</option>
<option value="720">30 天</option>
</select>
</label>
<label class="field-inline">来源备注
<input id="clipClient" type="text" maxlength="40" placeholder="例如:公司 PC" aria-label="来源备注">
</label>
<span id="clipChars" class="clip-chars mono">0 字符</span>
<span class="clip-spacer"></span>
<button id="clipSendBtn" class="btn btn-primary" type="button">发送到共享剪贴板</button>
</div>
<ul id="clipList" class="clip-list" aria-live="polite"></ul>
<div id="clipEmpty" class="empty hidden">
<span class="empty-icon" aria-hidden="true">📋</span>
<p>共享剪贴板还是空的,先在上方粘贴一段文本发送吧</p>
</div>
</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 clipInput = $('#clipInput'), clipClientInput = $('#clipClient'), clipCharsEl = $('#clipChars');
var clipExpiresSelect = $('#clipExpiresSelect'), clipSendBtn = $('#clipSendBtn');
var clipPasteBtn = $('#clipPasteBtn'), clipRefreshBtn = $('#clipRefreshBtn');
var clipListEl = $('#clipList'), clipEmpty = $('#clipEmpty'), clipCountEl = $('#clipCount');
var toastsEl = $('#toasts');
var modalRoot = $('#modalRoot'), modalTitle = $('#modalTitle'), modalMessage = $('#modalMessage');
var modalCancel = $('#modalCancel'), modalConfirm = $('#modalConfirm');
var XHR_HEADER = { 'X-Requested-With': 'XMLHttpRequest' };
var CLIP_MAX_LENGTH = 262144; // 单条剪贴板文本上限(字符),与后端 max_clip_length 一致
var CLIP_CLIENT_KEY = 'lzwlab.clip.client';
var currentUser = null;
var files = [];
var queue = [];
var queueSeq = 0;
var uploadBusy = false;
var modalResolver = null;
var clips = [];
var clipCache = {}; // id -> 完整正文(列表接口只返回预览)
var clipExpanded = {}; // id -> true 表示已展开全文
/* ---------- 工具函数 ---------- */
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 = '登 录';
clips = []; clipCache = {}; clipExpanded = {};
}
function showApp(user) {
currentUser = user;
hide(loginView); show(appView);
userName.textContent = user;
loadFiles(true);
loadClips(true);
clipClientInput.value = readStoredClipClient();
}
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: XHR_HEADER });
var data = await resp.json().catch(function () { return { ok: false }; });
if (resp.ok && data.ok) {
toast('清理完成:移除 ' + data.removed + ' 个过期文件、' +
(data.clips || 0) + ' 条过期剪贴板', 'success');
} else if (resp.status === 401) { handle401(); }
else { toast(data.error || '清理失败', 'error'); }
loadFiles(true);
loadClips(false);
} catch (e) {
toast('网络错误', 'error');
}
cleanupBtn.disabled = false;
});
refreshBtn.addEventListener('click', function () { loadFiles(true); });
/* ---------- 共享剪贴板 ---------- */
function readStoredClipClient() {
try { return localStorage.getItem(CLIP_CLIENT_KEY) || ''; } catch (e) { return ''; }
}
function storeClipClient(name) {
try {
if (name) localStorage.setItem(CLIP_CLIENT_KEY, name);
else localStorage.removeItem(CLIP_CLIENT_KEY);
} catch (e) {}
}
function updateClipChars() {
var n = clipInput.value.length;
clipCharsEl.textContent = n + ' 字符';
clipCharsEl.classList.toggle('over', n > CLIP_MAX_LENGTH);
}
async function loadClips(showErrors) {
try {
var resp = await fetch('api/clips', { headers: XHR_HEADER });
if (resp.status === 401) { handle401(); return; }
var data = await resp.json().catch(function () { return { ok: false }; });
if (!resp.ok) {
if (showErrors) toast(data.error || '加载剪贴板失败', 'error');
return;
}
clips = data.clips || [];
// 丢弃已被删除/过期条目的缓存,避免内存里留下旧正文
var alive = {};
clips.forEach(function (c) { alive[c.id] = true; });
Object.keys(clipCache).forEach(function (id) { if (!alive[id]) delete clipCache[id]; });
Object.keys(clipExpanded).forEach(function (id) { if (!alive[id]) delete clipExpanded[id]; });
renderClips();
} catch (e) {
if (showErrors) toast('加载剪贴板失败', 'error');
}
}
function clipSubtitle(c) {
var parts = [c.size + ' 字符', fmtTime(c.created_at)];
if (c.copies > 0) parts.push('已复制 ' + c.copies + ' 次');
return parts.join(' · ');
}
function renderClips() {
clipListEl.innerHTML = '';
if (!clips.length) {
show(clipEmpty);
clipCountEl.textContent = '';
return;
}
hide(clipEmpty);
clipCountEl.textContent = '(' + clips.length + ' 条)';
clips.forEach(function (c) {
var li = document.createElement('li');
li.className = 'clip-row';
li.dataset.id = c.id;
var main = document.createElement('div');
main.className = 'clip-main';
var meta = document.createElement('div');
meta.className = 'clip-meta';
if (c.client) {
var who = document.createElement('span');
who.className = 'clip-client';
who.title = '来源备注:' + c.client;
who.textContent = '🖥 ' + c.client;
meta.appendChild(who);
}
var when = document.createElement('span');
when.textContent = clipSubtitle(c);
meta.appendChild(when);
var expire = document.createElement('span');
expire.className = 'expire ' + (c.remaining_seconds <= 0 ? 'danger'
: c.remaining_seconds < 24 * 3600 ? 'warn' : 'ok');
expire.textContent = fmtRemaining(c.remaining_seconds);
meta.appendChild(expire);
main.appendChild(meta);
var body = document.createElement('pre');
body.className = 'clip-text';
var expanded = !!clipExpanded[c.id];
var full = clipCache[c.id];
body.textContent = expanded && full !== undefined ? full : c.preview;
main.appendChild(body);
var actions = document.createElement('div');
actions.className = 'clip-actions';
var btnView = document.createElement('button');
btnView.type = 'button'; btnView.className = 'btn btn-ghost btn-sm';
btnView.dataset.act = 'view'; btnView.dataset.id = c.id;
btnView.textContent = expanded ? '收起' : '查看';
var btnCopy = document.createElement('button');
btnCopy.type = 'button'; btnCopy.className = 'btn btn-primary btn-sm';
btnCopy.dataset.act = 'copy'; btnCopy.dataset.id = c.id;
btnCopy.textContent = '一键复制';
var btnDel = document.createElement('button');
btnDel.type = 'button'; btnDel.className = 'btn btn-danger-ghost btn-sm';
btnDel.dataset.act = 'del'; btnDel.dataset.id = c.id;
btnDel.textContent = '删除';
actions.appendChild(btnView); actions.appendChild(btnCopy); actions.appendChild(btnDel);
li.appendChild(main); li.appendChild(actions);
if (expanded) li.classList.add('expanded');
clipListEl.appendChild(li);
});
}
async function fetchClipContent(id) {
if (Object.prototype.hasOwnProperty.call(clipCache, id)) return clipCache[id];
var resp = await fetch('api/clips/' + id, { headers: XHR_HEADER });
if (resp.status === 401) { handle401(); throw new Error('登录已过期'); }
var data = await resp.json().catch(function () { return { ok: false }; });
if (!resp.ok || !data.ok) throw new Error((data && data.error) || '获取剪贴板内容失败');
clipCache[id] = data.clip.content;
return clipCache[id];
}
async function copyToLocalClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
try { await navigator.clipboard.writeText(text); return true; } catch (e) {}
}
// 降级方案:极简隐藏文本框 + execCommand
var ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', 'readonly');
ta.style.position = 'fixed';
ta.style.top = '0';
ta.style.left = '-9999px';
document.body.appendChild(ta);
var ok = false;
try {
ta.select();
ta.setSelectionRange(0, ta.value.length);
ok = document.execCommand('copy');
} catch (e) { ok = false; }
ta.remove();
return ok;
}
function reportClipCopied(id) {
var c = clips.find(function (x) { return x.id === id; });
if (c) c.copies = (c.copies || 0) + 1;
fetch('api/clips/' + id + '/copy', { method: 'POST', headers: XHR_HEADER }).catch(function () {});
}
async function viewClip(id, btn) {
if (clipExpanded[id] && clipCache[id] !== undefined) {
delete clipExpanded[id];
renderClips();
return;
}
btn.disabled = true; btn.textContent = '加载中…';
try {
await fetchClipContent(id);
clipExpanded[id] = true;
renderClips();
} catch (e) {
toast(e.message || '获取内容失败', 'error');
btn.disabled = false; btn.textContent = '查看';
}
}
async function copyClip(id, btn) {
btn.disabled = true; btn.textContent = '复制中…';
try {
var text = await fetchClipContent(id);
var ok = await copyToLocalClipboard(text);
if (ok) {
toast('已复制到本机剪贴板(' + text.length + ' 字符)', 'success');
reportClipCopied(id);
} else {
clipExpanded[id] = true;
renderClips();
toast('浏览器拒绝写入剪贴板,已展开全文,请手动选中复制', 'error');
return;
}
} catch (e) {
toast(e.message || '复制失败', 'error');
}
btn.disabled = false; btn.textContent = '一键复制';
}
clipListEl.addEventListener('click', async function (e) {
var btn = e.target.closest('button[data-act]');
if (!btn) return;
var id = btn.dataset.id;
var act = btn.dataset.act;
if (act === 'view') { viewClip(id, btn); return; }
if (act === 'copy') { copyClip(id, btn); return; }
if (act === 'del') {
var c = clips.find(function (x) { return x.id === id; });
var ok = await openModal('删除剪贴板内容',
'确定删除这条剪贴板内容吗?此操作不可恢复。', true);
if (!ok) return;
btn.disabled = true; btn.textContent = '删除中…';
try {
var resp = await fetch('api/clips/' + id, { method: 'DELETE', headers: XHR_HEADER });
var data = await resp.json().catch(function () { return { ok: false }; });
if (resp.ok && data.ok) {
toast('已删除剪贴板内容', 'success');
delete clipCache[id]; delete clipExpanded[id];
loadClips(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 = '删除';
}
}
});
async function sendClip() {
var text = clipInput.value;
if (!text.trim()) {
toast('剪贴板内容不能为空', 'error');
clipInput.focus();
return;
}
if (text.length > CLIP_MAX_LENGTH) {
toast('文本过长:最多 ' + CLIP_MAX_LENGTH + ' 个字符', 'error');
return;
}
var client = clipClientInput.value.trim();
clipSendBtn.disabled = true; clipSendBtn.textContent = '发送中…';
try {
var resp = await fetch('api/clips', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify({
text: text,
client: client,
expires_hours: Number(clipExpiresSelect.value) || 24
})
});
if (resp.status === 401) { handle401(); return; }
var data = await resp.json().catch(function () { return { ok: false }; });
if (resp.ok && data.ok) {
storeClipClient(client);
clipInput.value = '';
updateClipChars();
toast('已发送到共享剪贴板,其他客户端刷新即可查看', 'success');
loadClips(false);
} else {
toast((data && data.error) || '发送失败', 'error');
}
} catch (e) {
toast('网络错误,发送失败', 'error');
}
clipSendBtn.disabled = false; clipSendBtn.textContent = '发送到共享剪贴板';
}
clipSendBtn.addEventListener('click', sendClip);
clipRefreshBtn.addEventListener('click', function () { loadClips(true); });
clipInput.addEventListener('input', updateClipChars);
clipInput.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); sendClip(); }
});
clipClientInput.addEventListener('change', function () { storeClipClient(clipClientInput.value.trim()); });
clipPasteBtn.addEventListener('click', async function () {
if (!navigator.clipboard || !navigator.clipboard.readText) {
clipInput.focus();
toast('当前浏览器不支持自动读取剪贴板,请在输入框中按 Ctrl / ⌘ + V 粘贴', 'info');
return;
}
try {
var text = await navigator.clipboard.readText();
if (!text) { toast('本机剪贴板为空', 'info'); return; }
var start = clipInput.selectionStart, end = clipInput.selectionEnd;
if (typeof clipInput.setRangeText === 'function') {
clipInput.setRangeText(text, start, end, 'end');
} else {
clipInput.value = clipInput.value.slice(0, start) + text + clipInput.value.slice(end);
}
updateClipChars();
clipInput.focus();
toast('已读取本机剪贴板(' + text.length + ' 字符),点“发送”即可共享', 'success');
} catch (e) {
clipInput.focus();
toast('浏览器未授权读取剪贴板,请在输入框中按 Ctrl / ⌘ + V 粘贴', 'info');
}
});
/* ---------- 上传队列 ---------- */
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);
});
}
/* ---------- 启动 ---------- */
updateClipChars();
setInterval(function () {
if (!currentUser) return;
loadFiles(false);
loadClips(false); // 定期拉取,其他客户端发送的剪贴板内容会自动出现
}, 30000);
checkSession();
})();
</script>
</body>
</html>