- 后端:新增 /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 同步更新
1309 lines
51 KiB
HTML
1309 lines
51 KiB
HTML
<!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>
|