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 同步更新
This commit is contained in:
+104
-5
@@ -24,6 +24,10 @@ USERS = cfg["users"]
|
||||
MIN_EXPIRES_SECONDS = 3600 # 最短 1 小时
|
||||
MAX_EXPIRES_SECONDS = 30 * 24 * 3600 # 最长 30 天
|
||||
DEFAULT_EXPIRES_HOURS = 168 # 默认 7 天
|
||||
MAX_CLIP_LENGTH = cfg["max_clip_length"] # 单条剪贴板文本上限(字符)
|
||||
MAX_CLIPS = cfg["max_clips"] # 剪贴板保留条数上限
|
||||
DEFAULT_CLIP_EXPIRES_HOURS = cfg["clip_default_expires_hours"] # 剪贴板默认有效期
|
||||
MAX_CLIP_PREVIEW_CHARS = 240 # 列表接口返回的正文预览长度
|
||||
|
||||
os.makedirs(FILES_DIR, exist_ok=True)
|
||||
storage.init_db(DB_PATH)
|
||||
@@ -98,16 +102,19 @@ def not_found(err):
|
||||
return err
|
||||
|
||||
|
||||
def _parse_expires_hours(value) -> int:
|
||||
def _parse_expires_hours(value, default_hours: int = DEFAULT_EXPIRES_HOURS,
|
||||
label: str = "有效期") -> int:
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
value = default_hours
|
||||
try:
|
||||
hours = float(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("有效期格式不正确")
|
||||
raise ValueError("%s格式不正确" % label)
|
||||
if not (1 <= hours <= 720):
|
||||
raise ValueError("有效期最短 1 小时,最长 30 天(720 小时)")
|
||||
raise ValueError("%s最短 1 小时,最长 30 天(720 小时)" % label)
|
||||
seconds = int(round(hours * 3600))
|
||||
if not (MIN_EXPIRES_SECONDS <= seconds <= MAX_EXPIRES_SECONDS):
|
||||
raise ValueError("有效期超出允许范围")
|
||||
raise ValueError("%s超出允许范围" % label)
|
||||
return seconds
|
||||
|
||||
|
||||
@@ -118,6 +125,17 @@ def _clean_name(raw_name) -> str:
|
||||
return (name[:200] or "未命名文件")
|
||||
|
||||
|
||||
def _clean_clip_text(raw_text) -> str:
|
||||
"""规范化剪贴板正文:统一换行、去掉 NUL,保留换行与制表符。"""
|
||||
text = raw_text.replace("\r\n", "\n").replace("\r", "\n").replace("\x00", "")
|
||||
return text
|
||||
|
||||
|
||||
def _clean_client(raw_client) -> str:
|
||||
name = re.sub(r"[\x00-\x1f\x7f]", "", str(raw_client or "").strip())
|
||||
return name[:40]
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
@@ -261,7 +279,88 @@ def api_cleanup():
|
||||
now = int(time.time())
|
||||
removed = storage.cleanup_expired(DB_PATH, FILES_DIR, now=now)
|
||||
orphans = storage.cleanup_orphans(DB_PATH, FILES_DIR, now=now)
|
||||
return jsonify(ok=True, removed=removed, orphans=orphans)
|
||||
clips = storage.cleanup_expired_clips(DB_PATH, now=now)
|
||||
return jsonify(ok=True, removed=removed, orphans=orphans, clips=clips)
|
||||
|
||||
|
||||
# ---------- 剪贴板 ----------
|
||||
# 登录用户共享同一块剪贴板:任一客户端粘贴文本上传,其他客户端可查看并一键复制。
|
||||
|
||||
@app.get("/api/clips")
|
||||
@login_required
|
||||
def api_clips():
|
||||
now = int(time.time())
|
||||
storage.cleanup_expired_clips(DB_PATH, now=now)
|
||||
return jsonify(clips=storage.list_clips(DB_PATH, now))
|
||||
|
||||
|
||||
@app.post("/api/clips")
|
||||
@login_required
|
||||
def api_create_clip():
|
||||
bad = require_xhr()
|
||||
if bad:
|
||||
return bad
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify(ok=False, error="请求格式不正确"), 400
|
||||
raw_text = data.get("text")
|
||||
if not isinstance(raw_text, str):
|
||||
return jsonify(ok=False, error="缺少剪贴板文本"), 400
|
||||
text = _clean_clip_text(raw_text)
|
||||
if not text.strip():
|
||||
return jsonify(ok=False, error="剪贴板内容不能为空"), 400
|
||||
if len(text) > MAX_CLIP_LENGTH:
|
||||
return jsonify(ok=False, error="文本过长:最多 %d 个字符" % MAX_CLIP_LENGTH), 400
|
||||
try:
|
||||
expires_seconds = _parse_expires_hours(
|
||||
data.get("expires_hours"), DEFAULT_CLIP_EXPIRES_HOURS, "剪贴板有效期")
|
||||
except ValueError as exc:
|
||||
return jsonify(ok=False, error=str(exc)), 400
|
||||
|
||||
now = int(time.time())
|
||||
clip_id = secrets.token_hex(8)
|
||||
storage.insert_clip(DB_PATH, clip_id, text, _clean_client(data.get("client")),
|
||||
now + expires_seconds, now)
|
||||
storage.prune_clips(DB_PATH, MAX_CLIPS)
|
||||
row = storage.get_clip(DB_PATH, clip_id)
|
||||
if row is None: # 理论上不会发生,被 prune 挤掉时也返回成功但不回传内容
|
||||
return jsonify(ok=True, clip=None)
|
||||
return jsonify(ok=True, clip=storage.clip_content_to_dict(row, now))
|
||||
|
||||
|
||||
@app.get("/api/clips/<clip_id>")
|
||||
@login_required
|
||||
def api_clip_detail(clip_id):
|
||||
now = int(time.time())
|
||||
row = storage.get_clip(DB_PATH, clip_id)
|
||||
if row is None:
|
||||
return jsonify(ok=False, error="剪贴板内容不存在或已删除"), 404
|
||||
if row["expires_at"] <= now:
|
||||
storage.cleanup_expired_clips(DB_PATH, now=now)
|
||||
return jsonify(ok=False, error="剪贴板内容已过期"), 404
|
||||
return jsonify(ok=True, clip=storage.clip_content_to_dict(row, now))
|
||||
|
||||
|
||||
@app.post("/api/clips/<clip_id>/copy")
|
||||
@login_required
|
||||
def api_clip_copied(clip_id):
|
||||
bad = require_xhr()
|
||||
if bad:
|
||||
return bad
|
||||
if not storage.increment_clip_copies(DB_PATH, clip_id):
|
||||
return jsonify(ok=False, error="剪贴板内容不存在"), 404
|
||||
return jsonify(ok=True)
|
||||
|
||||
|
||||
@app.delete("/api/clips/<clip_id>")
|
||||
@login_required
|
||||
def api_delete_clip(clip_id):
|
||||
bad = require_xhr()
|
||||
if bad:
|
||||
return bad
|
||||
if not storage.delete_clip(DB_PATH, clip_id):
|
||||
return jsonify(ok=False, error="剪贴板内容不存在"), 404
|
||||
return jsonify(ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -67,6 +67,10 @@ def load_config(path: str | None = None) -> dict:
|
||||
cfg["max_content_length"] = int(cfg.get("max_content_length", 5 * 1024 ** 3))
|
||||
cfg["cookie_secure"] = bool(cfg.get("cookie_secure", True))
|
||||
cfg["session_days"] = int(cfg.get("session_days", 7))
|
||||
# 剪贴板:单条文本上限(字符)、保留条数上限、默认有效期(小时)
|
||||
cfg["max_clip_length"] = int(cfg.get("max_clip_length", 256 * 1024))
|
||||
cfg["max_clips"] = int(cfg.get("max_clips", 200))
|
||||
cfg["clip_default_expires_hours"] = int(cfg.get("clip_default_expires_hours", 24))
|
||||
return cfg
|
||||
|
||||
|
||||
|
||||
+4
-2
@@ -1,4 +1,4 @@
|
||||
"""手动/定时清理过期文件与孤儿缓存文件。"""
|
||||
"""手动/定时清理过期文件、过期剪贴板与孤儿缓存文件。"""
|
||||
import logging
|
||||
import time
|
||||
|
||||
@@ -17,7 +17,9 @@ def main() -> int:
|
||||
now = int(time.time())
|
||||
removed = storage.cleanup_expired(db_path, files_dir, now=now)
|
||||
orphans = storage.cleanup_orphans(db_path, files_dir, now=now)
|
||||
log.info("清理完成:过期文件 %d 个,孤儿缓存 %d 个", removed, orphans)
|
||||
clips = storage.cleanup_expired_clips(db_path, now=now)
|
||||
log.info("清理完成:过期文件 %d 个,孤儿缓存 %d 个,过期剪贴板 %d 条",
|
||||
removed, orphans, clips)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -16,8 +16,21 @@ CREATE TABLE IF NOT EXISTS files (
|
||||
expires_at INTEGER NOT NULL,
|
||||
downloads INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clips (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
client TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
copies INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_clips_expires_at ON clips (expires_at);
|
||||
"""
|
||||
|
||||
CLIP_PREVIEW_CHARS = 240
|
||||
|
||||
|
||||
def connect(db_path: str) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(db_path, timeout=30)
|
||||
@@ -172,3 +185,136 @@ def cleanup_orphans(db_path: str, files_dir: str, now: int | None = None,
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
|
||||
|
||||
# ---------- 剪贴板(纯数据库存储,无磁盘文件) ----------
|
||||
|
||||
def clip_to_dict(row: sqlite3.Row, now: int) -> dict:
|
||||
"""把带 preview/size 字段的查询行转换为接口返回结构(不含完整正文)。"""
|
||||
size = row["size"] if row["size"] is not None else 0
|
||||
return {
|
||||
"id": row["id"],
|
||||
"preview": row["preview"] or "",
|
||||
"size": size,
|
||||
"truncated": size > len(row["preview"] or ""),
|
||||
"client": row["client"] or "",
|
||||
"created_at": iso(row["created_at"]),
|
||||
"expires_at": iso(row["expires_at"]),
|
||||
"remaining_seconds": max(0, row["expires_at"] - now),
|
||||
"copies": row["copies"],
|
||||
}
|
||||
|
||||
|
||||
def insert_clip(db_path: str, clip_id: str, content: str, client: str,
|
||||
expires_at: int, created_at: int) -> None:
|
||||
with _DB_LOCK:
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO clips (id, content, client, created_at, expires_at, copies) "
|
||||
"VALUES (?, ?, ?, ?, ?, 0)",
|
||||
(clip_id, content, client, created_at, expires_at),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_clip(db_path: str, clip_id: str, preview_chars: int = CLIP_PREVIEW_CHARS):
|
||||
"""返回单条剪贴板记录的完整内容(含 content 字段)。"""
|
||||
with _DB_LOCK:
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
return conn.execute(
|
||||
"SELECT id, content, client, created_at, expires_at, copies, "
|
||||
"length(content) AS size FROM clips WHERE id = ?",
|
||||
(clip_id,),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def clip_content_to_dict(row: sqlite3.Row, now: int) -> dict:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"content": row["content"] or "",
|
||||
"size": row["size"] if row["size"] is not None else 0,
|
||||
"client": row["client"] or "",
|
||||
"created_at": iso(row["created_at"]),
|
||||
"expires_at": iso(row["expires_at"]),
|
||||
"remaining_seconds": max(0, row["expires_at"] - now),
|
||||
"copies": row["copies"],
|
||||
}
|
||||
|
||||
|
||||
def list_clips(db_path: str, now: int,
|
||||
preview_chars: int = CLIP_PREVIEW_CHARS) -> list[dict]:
|
||||
"""列出未过期剪贴板记录;只取正文前若干字符作为预览,避免列表响应过大。"""
|
||||
with _DB_LOCK:
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, client, created_at, expires_at, copies, "
|
||||
"substr(content, 1, ?) AS preview, length(content) AS size "
|
||||
"FROM clips WHERE expires_at > ? "
|
||||
"ORDER BY created_at DESC, rowid DESC",
|
||||
(preview_chars, now),
|
||||
).fetchall()
|
||||
return [clip_to_dict(r, now) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def increment_clip_copies(db_path: str, clip_id: str) -> bool:
|
||||
with _DB_LOCK:
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"UPDATE clips SET copies = copies + 1 WHERE id = ?", (clip_id,)
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_clip(db_path: str, clip_id: str) -> bool:
|
||||
with _DB_LOCK:
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
cur = conn.execute("DELETE FROM clips WHERE id = ?", (clip_id,))
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def cleanup_expired_clips(db_path: str, now: int | None = None) -> int:
|
||||
import time as _time
|
||||
now = now if now is not None else int(_time.time())
|
||||
with _DB_LOCK:
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
cur = conn.execute("DELETE FROM clips WHERE expires_at <= ?", (now,))
|
||||
conn.commit()
|
||||
return cur.rowcount
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def prune_clips(db_path: str, keep: int) -> int:
|
||||
"""只保留最近 keep 条剪贴板记录,返回被删除的条数。"""
|
||||
if keep <= 0:
|
||||
return 0
|
||||
with _DB_LOCK:
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM clips WHERE id NOT IN ("
|
||||
"SELECT id FROM clips ORDER BY created_at DESC, rowid DESC LIMIT ?)",
|
||||
(keep,),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -218,6 +218,51 @@ button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,[t
|
||||
}
|
||||
.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;
|
||||
@@ -257,6 +302,13 @@ button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,[t
|
||||
.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}
|
||||
@@ -318,6 +370,41 @@ button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,[t
|
||||
<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>
|
||||
@@ -363,16 +450,27 @@ button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,[t
|
||||
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) {
|
||||
@@ -463,6 +561,7 @@ button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,[t
|
||||
passwordInput.value = '';
|
||||
hide(loginError);
|
||||
loginBtn.disabled = false; loginBtn.textContent = '登 录';
|
||||
clips = []; clipCache = {}; clipExpanded = {};
|
||||
}
|
||||
|
||||
function showApp(user) {
|
||||
@@ -470,6 +569,8 @@ button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,[t
|
||||
hide(loginView); show(appView);
|
||||
userName.textContent = user;
|
||||
loadFiles(true);
|
||||
loadClips(true);
|
||||
clipClientInput.value = readStoredClipClient();
|
||||
}
|
||||
|
||||
function handle401() {
|
||||
@@ -696,13 +797,15 @@ button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,[t
|
||||
cleanupBtn.addEventListener('click', async function () {
|
||||
cleanupBtn.disabled = true;
|
||||
try {
|
||||
var resp = await fetch('api/cleanup', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
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 + ' 个过期文件', 'success');
|
||||
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');
|
||||
}
|
||||
@@ -711,6 +814,291 @@ button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,[t
|
||||
|
||||
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) {
|
||||
@@ -907,8 +1295,11 @@ button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,[t
|
||||
}
|
||||
|
||||
/* ---------- 启动 ---------- */
|
||||
updateClipChars();
|
||||
setInterval(function () {
|
||||
if (currentUser) loadFiles(false);
|
||||
if (!currentUser) return;
|
||||
loadFiles(false);
|
||||
loadClips(false); // 定期拉取,其他客户端发送的剪贴板内容会自动出现
|
||||
}, 30000);
|
||||
checkSession();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user