Initial commit: lzwlab.cn server project (docs, nginx, gitea, rustdesk, file transfer app)

This commit is contained in:
2026-08-26 22:59:47 +08:00
commit 7da61a0456
18 changed files with 2703 additions and 0 deletions
+268
View File
@@ -0,0 +1,268 @@
"""lzwlab.cn 文件快传 —— Flask 应用入口。
生产环境由 gunicorn 启动:
gunicorn --workers 1 --threads 4 --bind 127.0.0.1:8090 app:app
"""
import functools
import os
import re
import secrets
import time
from datetime import timedelta
from flask import (Flask, jsonify, redirect, render_template, request,
send_file, session)
from werkzeug.exceptions import RequestEntityTooLarge
import auth
import storage
cfg = auth.load_config()
FILES_DIR = cfg["files_dir"]
DB_PATH = cfg["database_path"]
USERS = cfg["users"]
MIN_EXPIRES_SECONDS = 3600 # 最短 1 小时
MAX_EXPIRES_SECONDS = 30 * 24 * 3600 # 最长 30 天
DEFAULT_EXPIRES_HOURS = 168 # 默认 7 天
os.makedirs(FILES_DIR, exist_ok=True)
storage.init_db(DB_PATH)
app = Flask(__name__)
app.config.update(
SECRET_KEY=cfg["secret_key"],
MAX_CONTENT_LENGTH=cfg["max_content_length"],
SESSION_COOKIE_NAME="lzwlab_transfer_session",
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_SECURE=cfg["cookie_secure"],
SESSION_COOKIE_PATH=cfg.get("cookie_path", "/transfer/"),
PERMANENT_SESSION_LIFETIME=timedelta(days=cfg["session_days"]),
)
login_limiter = auth.RateLimiter(max_failures=10, window_seconds=900)
def client_ip() -> str:
fwd = request.headers.get("X-Forwarded-For", "")
if fwd:
return fwd.split(",")[0].strip()
return request.remote_addr or "unknown"
def require_xhr():
if request.headers.get("X-Requested-With", "").lower() != "xmlhttprequest":
return jsonify(ok=False, error="非法请求:缺少 X-Requested-With"), 400
def login_required(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
user = session.get("user")
if not user or user not in USERS:
if request.path.startswith("/api/"):
return jsonify(ok=False, error="未登录或会话已过期"), 401
return redirect("/")
return fn(*args, **kwargs)
return wrapper
@app.after_request
def harden(response):
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "no-referrer")
if request.path.startswith("/api/"):
response.headers.setdefault("Cache-Control", "no-store")
if request.endpoint == "index":
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "
"script-src 'self' 'unsafe-inline'; connect-src 'self'; "
"font-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'",
)
return response
@app.errorhandler(RequestEntityTooLarge)
def too_large(_err):
if request.path.startswith("/api/"):
return jsonify(ok=False, error="文件大小超过允许上限"), 413
return "文件过大", 413
@app.errorhandler(404)
def not_found(err):
if request.path.startswith("/api/"):
return jsonify(ok=False, error="资源不存在"), 404
return err
def _parse_expires_hours(value) -> int:
try:
hours = float(value)
except (TypeError, ValueError):
raise ValueError("有效期格式不正确")
if not (1 <= hours <= 720):
raise ValueError("有效期最短 1 小时,最长 30 天(720 小时)")
seconds = int(round(hours * 3600))
if not (MIN_EXPIRES_SECONDS <= seconds <= MAX_EXPIRES_SECONDS):
raise ValueError("有效期超出允许范围")
return seconds
def _clean_name(raw_name) -> str:
name = str(raw_name or "").strip() or "未命名文件"
name = name.replace("\\", "/").split("/")[-1]
name = re.sub(r"[\x00-\x1f\x7f]", "", name)
return (name[:200] or "未命名文件")
@app.get("/")
def index():
return render_template("index.html")
@app.get("/healthz")
def healthz():
return jsonify(ok=True)
@app.get("/api/session")
def api_session():
user = session.get("user")
if user and user in USERS:
return jsonify(logged_in=True, user=user)
return jsonify(logged_in=False)
@app.post("/api/login")
def api_login():
ip = client_ip()
if login_limiter.is_blocked(ip):
return jsonify(ok=False, error="尝试次数过多,请 15 分钟后再试"), 429
data = request.get_json(silent=True) or {}
username = str(data.get("username", "")).strip()
password = str(data.get("password", ""))
user = USERS.get(username)
ok = bool(user) and auth.verify_password(password, user.get("password_hash", ""))
if not ok:
login_limiter.record_failure(ip)
time.sleep(0.3)
return jsonify(ok=False, error="用户名或密码错误"), 401
login_limiter.reset(ip)
session.clear()
session.permanent = True
session["user"] = username
return jsonify(ok=True, user=username)
@app.post("/api/logout")
def api_logout():
session.clear()
return jsonify(ok=True)
@app.post("/api/upload")
@login_required
def api_upload():
bad = require_xhr()
if bad:
return bad
if "file" not in request.files:
return jsonify(ok=False, error="未选择文件"), 400
upload = request.files["file"]
if upload is None or upload.filename is None:
return jsonify(ok=False, error="未选择文件"), 400
try:
expires_seconds = _parse_expires_hours(request.form.get("expires_hours", DEFAULT_EXPIRES_HOURS))
except ValueError as exc:
return jsonify(ok=False, error=str(exc)), 400
file_id = secrets.token_hex(8)
stored_path = os.path.join(FILES_DIR, file_id)
name = _clean_name(upload.filename)
try:
upload.save(stored_path)
except RequestEntityTooLarge:
raise
except Exception:
app.logger.exception("upload save failed: %s", file_id)
try:
os.remove(stored_path)
except OSError:
pass
return jsonify(ok=False, error="上传失败,请重试"), 500
size = os.path.getsize(stored_path)
now = int(time.time())
storage.insert_file(DB_PATH, file_id, name, stored_path, size, now + expires_seconds, now)
return jsonify(ok=True, file={
"id": file_id,
"name": name,
"size": size,
"created_at": storage.iso(now),
"expires_at": storage.iso(now + expires_seconds),
"remaining_seconds": expires_seconds,
"downloads": 0,
})
@app.get("/api/files")
@login_required
def api_files():
now = int(time.time())
storage.cleanup_expired(DB_PATH, FILES_DIR, now=now)
return jsonify(files=storage.list_files(DB_PATH, now))
@app.get("/api/download/<file_id>")
@login_required
def api_download(file_id):
now = int(time.time())
row = storage.get_file(DB_PATH, file_id)
if row is None:
return jsonify(ok=False, error="文件不存在或已过期"), 404
if row["expires_at"] <= now:
storage.cleanup_expired(DB_PATH, FILES_DIR, now=now)
return jsonify(ok=False, error="文件已过期"), 404
if not os.path.isfile(row["stored_path"]):
storage.delete_file(DB_PATH, FILES_DIR, file_id)
return jsonify(ok=False, error="缓存文件已丢失"), 404
storage.increment_downloads(DB_PATH, file_id)
return send_file(
row["stored_path"],
as_attachment=True,
download_name=row["name"],
conditional=True,
etag=True,
max_age=0,
)
@app.delete("/api/files/<file_id>")
@login_required
def api_delete_file(file_id):
bad = require_xhr()
if bad:
return bad
ok = storage.delete_file(DB_PATH, FILES_DIR, file_id)
if not ok:
return jsonify(ok=False, error="文件不存在"), 404
return jsonify(ok=True)
@app.post("/api/cleanup")
@login_required
def api_cleanup():
bad = require_xhr()
if bad:
return bad
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)
if __name__ == "__main__":
app.run(host="127.0.0.1", port=8090, debug=False)
+101
View File
@@ -0,0 +1,101 @@
"""认证、配置加载与登录限流工具(仅依赖标准库)。"""
import base64
import hashlib
import hmac
import json
import os
import secrets
import threading
import time
DEFAULT_CONFIG_PATH = os.environ.get("LZWLAB_TRANSFER_CONFIG", "/etc/lzwlab-transfer/config.json")
_SCRYPT_N = 2 ** 14
_SCRYPT_R = 8
_SCRYPT_P = 1
def _b64encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _b64decode(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def hash_password(password: str) -> str:
"""返回 scrypt 密码哈希,格式:scrypt$N$r$p$salt_b64$hash_b64"""
if not isinstance(password, str) or not password:
raise ValueError("密码不能为空")
salt = secrets.token_bytes(16)
digest = hashlib.scrypt(
password.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=32
)
return "scrypt$%d$%d$%d$%s$%s" % (
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _b64encode(salt), _b64encode(digest)
)
def verify_password(password: str, stored: str) -> bool:
try:
parts = stored.split("$")
if len(parts) != 6 or parts[0] != "scrypt":
return False
_, n_s, r_s, p_s, salt_s, digest_s = parts
n, r, p = int(n_s), int(r_s), int(p_s)
digest = hashlib.scrypt(
password.encode("utf-8"), salt=_b64decode(salt_s), n=n, r=r, p=p, dklen=32
)
return hmac.compare_digest(digest, _b64decode(digest_s))
except Exception:
return False
def load_config(path: str | None = None) -> dict:
path = path or DEFAULT_CONFIG_PATH
if not os.path.exists(path):
raise RuntimeError("配置文件不存在:%s" % path)
with open(path, "r", encoding="utf-8") as fh:
cfg = json.load(fh)
if not isinstance(cfg.get("users"), dict) or not cfg["users"]:
raise RuntimeError("配置缺少 users")
if not cfg.get("secret_key") or len(str(cfg["secret_key"])) < 32:
raise RuntimeError("配置缺少 secret_key(至少 32 字符)")
data_dir = cfg.get("data_dir", "/var/lib/lzwlab-transfer")
cfg["files_dir"] = os.path.join(data_dir, "files")
cfg["database_path"] = cfg.get("database_path", os.path.join(data_dir, "files.db"))
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))
return cfg
def random_secret() -> str:
return secrets.token_urlsafe(48)
class RateLimiter:
"""基于内存的按 IP 登录失败限流。"""
def __init__(self, max_failures: int = 10, window_seconds: int = 900):
self.max_failures = max_failures
self.window_seconds = window_seconds
self._lock = threading.Lock()
self._failures: dict[str, list[float]] = {}
def _prune(self, ip: str, now: float) -> None:
self._failures[ip] = [t for t in self._failures.get(ip, []) if now - t < self.window_seconds]
def is_blocked(self, ip: str) -> bool:
with self._lock:
self._prune(ip, time.monotonic())
return len(self._failures.get(ip, [])) >= self.max_failures
def record_failure(self, ip: str) -> None:
with self._lock:
self._failures.setdefault(ip, []).append(time.monotonic())
self._prune(ip, time.monotonic())
def reset(self, ip: str) -> None:
with self._lock:
self._failures.pop(ip, None)
+25
View File
@@ -0,0 +1,25 @@
"""手动/定时清理过期文件与孤儿缓存文件。"""
import logging
import time
import auth
import storage
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("lzwlab-cleanup")
def main() -> int:
cfg = auth.load_config()
files_dir = cfg["files_dir"]
db_path = cfg["database_path"]
storage.init_db(db_path)
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)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+174
View File
@@ -0,0 +1,174 @@
"""文件元数据与磁盘缓存的存储层(SQLite + 文件目录,仅标准库)。"""
import os
import sqlite3
import threading
from datetime import datetime, timezone
_DB_LOCK = threading.Lock()
SCHEMA = """
CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
stored_path TEXT NOT NULL,
size INTEGER NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
downloads INTEGER NOT NULL DEFAULT 0
);
"""
def connect(db_path: str) -> sqlite3.Connection:
conn = sqlite3.connect(db_path, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=30000")
return conn
def init_db(db_path: str) -> None:
os.makedirs(os.path.dirname(db_path), exist_ok=True)
with _DB_LOCK:
conn = connect(db_path)
try:
conn.executescript(SCHEMA)
conn.commit()
finally:
conn.close()
def iso(ts: int) -> str:
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
def file_to_dict(row: sqlite3.Row, now: int) -> dict:
return {
"id": row["id"],
"name": row["name"],
"size": row["size"],
"created_at": iso(row["created_at"]),
"expires_at": iso(row["expires_at"]),
"remaining_seconds": max(0, row["expires_at"] - now),
"downloads": row["downloads"],
}
def insert_file(db_path: str, file_id: str, name: str, stored_path: str,
size: int, expires_at: int, created_at: int) -> None:
with _DB_LOCK:
conn = connect(db_path)
try:
conn.execute(
"INSERT INTO files (id, name, stored_path, size, created_at, expires_at, downloads) "
"VALUES (?, ?, ?, ?, ?, ?, 0)",
(file_id, name, stored_path, size, created_at, expires_at),
)
conn.commit()
finally:
conn.close()
def get_file(db_path: str, file_id: str):
with _DB_LOCK:
conn = connect(db_path)
try:
return conn.execute("SELECT * FROM files WHERE id = ?", (file_id,)).fetchone()
finally:
conn.close()
def list_files(db_path: str, now: int) -> list[dict]:
with _DB_LOCK:
conn = connect(db_path)
try:
rows = conn.execute(
"SELECT * FROM files WHERE expires_at > ? ORDER BY created_at DESC", (now,)
).fetchall()
return [file_to_dict(r, now) for r in rows]
finally:
conn.close()
def increment_downloads(db_path: str, file_id: str) -> None:
with _DB_LOCK:
conn = connect(db_path)
try:
conn.execute("UPDATE files SET downloads = downloads + 1 WHERE id = ?", (file_id,))
conn.commit()
finally:
conn.close()
def delete_file(db_path: str, files_dir: str, file_id: str) -> bool:
"""删除数据库记录并移除磁盘文件,返回是否删除过数据库记录。"""
with _DB_LOCK:
conn = connect(db_path)
try:
row = conn.execute("SELECT id, stored_path FROM files WHERE id = ?", (file_id,)).fetchone()
if row is None:
return False
conn.execute("DELETE FROM files WHERE id = ?", (file_id,))
conn.commit()
finally:
conn.close()
try:
os.remove(row["stored_path"])
except FileNotFoundError:
pass
except OSError:
pass
return True
def cleanup_expired(db_path: str, files_dir: str, now: int | None = None) -> int:
now = now if now is not None else int(__import__("time").time())
removed = 0
with _DB_LOCK:
conn = connect(db_path)
try:
rows = conn.execute(
"SELECT id, stored_path FROM files WHERE expires_at <= ?", (now,)
).fetchall()
for row in rows:
try:
os.remove(row["stored_path"])
except FileNotFoundError:
pass
except OSError:
continue # 磁盘文件暂时删不掉时保留记录,下次重试
conn.execute("DELETE FROM files WHERE id = ?", (row["id"],))
removed += 1
conn.commit()
finally:
conn.close()
return removed
def cleanup_orphans(db_path: str, files_dir: str, now: int | None = None,
min_age_seconds: int = 172800) -> int:
"""清理磁盘上不在数据库且超过 48 小时未修改的孤儿文件。"""
import time as _time
now = now if now is not None else int(_time.time())
removed = 0
with _DB_LOCK:
conn = connect(db_path)
try:
ids = {r["id"] for r in conn.execute("SELECT id FROM files").fetchall()}
finally:
conn.close()
if not os.path.isdir(files_dir):
return 0
for name in os.listdir(files_dir):
if name in ids:
continue
path = os.path.join(files_dir, name)
if not os.path.isfile(path):
continue
try:
if now - os.path.getmtime(path) >= min_age_seconds:
os.remove(path)
removed += 1
except OSError:
pass
return removed
@@ -0,0 +1,10 @@
[Unit]
Description=lzwlab.cn file transfer cleanup (expired files)
After=local-fs.target
[Service]
Type=oneshot
User=lzwlab
Group=lzwlab
Environment=LZWLAB_TRANSFER_CONFIG=/etc/lzwlab-transfer/config.json
ExecStart=/usr/bin/python3 /opt/lzwlab-transfer/cleanup.py
@@ -0,0 +1,10 @@
[Unit]
Description=Cleanup expired files every 10 minutes
[Timer]
OnBootSec=1min
OnUnitActiveSec=10min
Persistent=true
[Install]
WantedBy=timers.target
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=lzwlab.cn file transfer web app
After=network.target
[Service]
Type=simple
User=lzwlab
Group=lzwlab
WorkingDirectory=/opt/lzwlab-transfer
Environment=LZWLAB_TRANSFER_CONFIG=/etc/lzwlab-transfer/config.json
ExecStart=/usr/bin/gunicorn --workers 1 --threads 4 --timeout 3600 --graceful-timeout 30 --bind 127.0.0.1:8090 --access-logfile /var/log/lzwlab-transfer/access.log --error-logfile /var/log/lzwlab-transfer/error.log app:app
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
+917
View File
@@ -0,0 +1,917 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#667eea">
<meta name="color-scheme" content="light dark">
<title>文件快传 · lzwlab.cn</title>
<style>
:root{
--primary:#4f6ef7;
--primary-strong:#3b57e0;
--grad:linear-gradient(135deg,#667eea 0%,#764ba2 100%);
--bg:#f5f6fa;
--card:#ffffff;
--text:#1f2430;
--muted:#6b7280;
--border:#e5e7eb;
--border-strong:#d1d5db;
--success:#16a34a;
--danger:#dc2626;
--warning:#d97706;
--shadow:0 4px 16px rgba(31,36,48,.06);
--shadow-lg:0 12px 32px rgba(31,36,48,.12);
--focus:0 0 0 3px rgba(79,110,247,.28);
}
@media (prefers-color-scheme: dark){
:root{
--bg:#12141a;
--card:#1c1f27;
--text:#eef0f5;
--muted:#9aa1ad;
--border:#2c3039;
--border-strong:#3a3f4b;
--shadow:0 4px 16px rgba(0,0,0,.35);
--shadow-lg:0 12px 32px rgba(0,0,0,.5);
}
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{min-height:100%}
body{
background:var(--bg);
color:var(--text);
font-family:"PingFang SC","Microsoft YaHei","Segoe UI",system-ui,-apple-system,sans-serif;
font-size:16px;
line-height:1.5;
-webkit-font-smoothing:antialiased;
}
.hidden{display:none !important}
button,input,select{font:inherit;color:inherit}
button{-webkit-tap-highlight-color:transparent}
button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:none;box-shadow:var(--focus)}
.mono{font-family:ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",Menlo,monospace;font-variant-numeric:tabular-nums}
/* ---------- 登录页 ---------- */
.auth-wrap{
min-height:100vh;min-height:100dvh;
display:flex;align-items:center;justify-content:center;
padding:16px;
background:var(--grad);
}
.auth-card{
width:100%;max-width:400px;
background:var(--card);
border-radius:16px;
box-shadow:var(--shadow-lg);
padding:32px 28px;
text-align:center;
}
.logo{
width:64px;height:64px;margin:0 auto 14px;
display:flex;align-items:center;justify-content:center;
font-size:32px;
border-radius:18px;
background:var(--grad);
box-shadow:var(--shadow);
}
.auth-card h1{font-size:24px;font-weight:700;letter-spacing:.5px}
.auth-card .sub{color:var(--muted);font-size:14px;margin:6px 0 24px}
.field{display:block;text-align:left;margin-bottom:14px}
.field span{display:block;font-size:14px;color:var(--muted);margin-bottom:6px}
.field input,select{
width:100%;min-height:44px;
padding:10px 12px;
border:1px solid var(--border);
border-radius:10px;
background:var(--card);
transition:border-color .15s, box-shadow .15s;
}
.field input:focus,select:focus{border-color:var(--primary);outline:none;box-shadow:var(--focus)}
.form-error{color:var(--danger);font-size:14px;min-height:20px;margin:2px 0 10px;text-align:left}
.btn{
display:inline-flex;align-items:center;justify-content:center;gap:8px;
min-height:44px;padding:10px 18px;
border:1px solid transparent;border-radius:10px;
background:transparent;cursor:pointer;
font-size:15px;font-weight:600;line-height:1.2;
transition:filter .15s, transform .05s, background .15s, border-color .15s, color .15s;
text-decoration:none;
}
.btn:active{transform:translateY(1px)}
.btn:disabled{opacity:.55;cursor:not-allowed;filter:none}
.btn-primary{background:var(--grad);color:#fff;box-shadow:var(--shadow)}
.btn-primary:not(:disabled):hover{filter:brightness(1.06)}
.btn-ghost{border-color:var(--border);color:var(--text);background:var(--card)}
.btn-ghost:not(:disabled):hover{border-color:var(--border-strong)}
.btn-danger{background:var(--danger);color:#fff}
.btn-danger-ghost{border-color:var(--danger);color:var(--danger);background:transparent}
.btn-danger-ghost:not(:disabled):hover{background:rgba(220,38,38,.08)}
.btn-sm{min-height:36px;padding:7px 12px;font-size:14px;border-radius:8px}
.btn-block{width:100%}
/* ---------- 主界面 ---------- */
.topbar{
position:sticky;top:0;z-index:20;
background:var(--card);
border-bottom:1px solid var(--border);
box-shadow:var(--shadow);
}
.topbar-inner{
max-width:980px;margin:0 auto;padding:12px 16px;
display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;
}
.brand{display:flex;align-items:center;gap:10px;font-size:18px;font-weight:700}
.brand .logo-sm{
width:38px;height:38px;border-radius:10px;background:var(--grad);
display:flex;align-items:center;justify-content:center;font-size:20px;
}
.brand-sub{font-size:12px;font-weight:400;color:var(--muted)}
.user-box{display:flex;align-items:center;gap:10px}
.user-badge{display:inline-flex;align-items:center;gap:6px;font-size:14px;color:var(--muted);padding:6px 12px;border-radius:999px;background:var(--bg);border:1px solid var(--border)}
.user-badge b{color:var(--text);font-weight:600}
.container{max-width:980px;margin:0 auto;padding:20px 16px calc(32px + env(safe-area-inset-bottom));display:grid;gap:16px}
.card{
background:var(--card);border:1px solid var(--border);border-radius:12px;
box-shadow:var(--shadow);padding:20px;
}
.card h2{font-size:18px;font-weight:700;margin-bottom:14px}
/* 上传区 */
.drop-zone{
border:2px dashed var(--border-strong);border-radius:12px;
padding:32px 16px;text-align:center;cursor:pointer;
transition:border-color .15s, background .15s;
}
.drop-zone:hover,.drop-zone.dragover{border-color:var(--primary);background:rgba(79,110,247,.05)}
.dz-icon{font-size:34px;line-height:1;margin-bottom:8px}
.drop-zone p{color:var(--text)}
.drop-zone .hint{color:var(--muted);font-size:13px;margin-top:4px}
.upload-options{
display:flex;align-items:center;gap:16px;flex-wrap:wrap;
margin-top:14px;padding-top:14px;border-top:1px solid var(--border);
}
.field-inline{display:flex;align-items:center;gap:10px;font-size:14px;color:var(--muted);white-space:nowrap}
.field-inline select{width:auto;min-width:120px}
.upload-options .hint{font-size:12px;color:var(--muted)}
.queue{list-style:none;margin:14px 0 0;display:grid;gap:10px}
.queue-item{
display:flex;align-items:center;gap:12px;flex-wrap:wrap;
border:1px solid var(--border);border-radius:10px;padding:12px;
}
.q-info{flex:1 1 240px;min-width:0}
.q-name{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.q-meta{font-size:12px;color:var(--muted);margin:3px 0 6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.q-side{display:flex;align-items:center;gap:10px}
.q-pct{min-width:44px;text-align:right;font-size:13px;color:var(--muted)}
.q-status{font-size:13px;font-weight:600}
.q-status.ok{color:var(--success)}
.q-status.err{color:var(--danger)}
.q-status.cancel{color:var(--muted)}
.progress{
width:100%;height:8px;border-radius:999px;background:var(--border);
overflow:hidden;
}
.progress-bar{
height:100%;width:0;border-radius:999px;
background:linear-gradient(90deg,#667eea,#8b5cf6);
transition:width .15s linear;
}
.q-progress{width:100%;flex-basis:100%;display:flex;align-items:center;gap:10px}
.q-progress .progress{flex:1}
.q-progress .mono{font-size:12px;color:var(--muted);min-width:40px;text-align:right}
/* 文件列表 */
.section-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:12px}
.section-head h2{margin-bottom:0}
.count{font-size:13px;color:var(--muted);font-weight:400;margin-left:6px}
.head-actions{display:flex;gap:8px;flex-wrap:wrap}
.file-list{list-style:none;display:grid;gap:10px}
.file-row{
display:grid;grid-template-columns:auto minmax(0,1fr) auto;
gap:12px;align-items:center;
border:1px solid var(--border);border-radius:12px;padding:14px;
transition:border-color .15s, box-shadow .15s;
}
.file-row:hover{border-color:var(--border-strong);box-shadow:var(--shadow)}
.file-icon{
width:44px;height:44px;border-radius:10px;background:var(--bg);
display:flex;align-items:center;justify-content:center;font-size:22px;
border:1px solid var(--border);
}
.file-main{min-width:0}
.file-name{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.file-meta{display:flex;flex-wrap:wrap;gap:6px 16px;color:var(--muted);font-size:13px;margin-top:4px}
.file-meta .sep{display:none}
.expire{font-weight:600}
.expire.ok{color:var(--success)}
.expire.warn{color:var(--warning)}
.expire.danger{color:var(--danger)}
.file-actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}
.dl-progress{grid-column:1/-1;display:flex;align-items:center;gap:10px;margin-top:2px}
.dl-progress .progress{flex:1}
.dl-progress .mono{font-size:12px;color:var(--muted);min-width:44px;text-align:right}
.empty{
text-align:center;padding:40px 16px;color:var(--muted);
border:1px dashed var(--border-strong);border-radius:12px;
}
.empty .empty-icon{font-size:36px;display:block;margin-bottom:8px}
/* Toast 与确认框 */
.toasts{
position:fixed;top:16px;right:16px;z-index:1000;
display:flex;flex-direction:column;gap:8px;
max-width:min(360px,calc(100vw - 32px));
}
.toast{
background:var(--card);border:1px solid var(--border);border-radius:10px;
box-shadow:var(--shadow-lg);padding:12px 14px;font-size:14px;
display:flex;align-items:center;gap:8px;
animation:toast-in .2s ease-out;
}
.toast.info{border-left:4px solid var(--primary)}
.toast.success{border-left:4px solid var(--success)}
.toast.error{border-left:4px solid var(--danger)}
@keyframes toast-in{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}
.modal-root{
position:fixed;inset:0;z-index:900;background:rgba(15,18,26,.5);
display:flex;align-items:center;justify-content:center;padding:16px;
}
.modal-card{
background:var(--card);border-radius:12px;box-shadow:var(--shadow-lg);
width:100%;max-width:400px;padding:20px;
}
.modal-card h3{font-size:17px;margin-bottom:8px}
.modal-card p{color:var(--muted);font-size:14px;margin-bottom:18px;word-break:break-all}
.modal-actions{display:flex;justify-content:flex-end;gap:10px}
@media (max-width:640px){
.card{padding:16px}
.file-row{grid-template-columns:auto minmax(0,1fr);padding:12px}
.file-actions{grid-column:1/-1;justify-content:stretch}
.file-actions .btn{flex:1}
.file-meta{font-size:12px;gap:4px 10px}
.topbar-inner{padding:10px 12px}
.container{padding:14px 12px calc(28px + env(safe-area-inset-bottom));gap:12px}
.toasts{left:16px;right:16px;top:12px;align-items:stretch}
.q-side{width:100%;justify-content:flex-end}
.q-pct{flex:1;text-align:right}
}
@media (prefers-reduced-motion: reduce){
*{transition:none !important;animation:none !important}
}
</style>
</head>
<body>
<section id="loginView" class="auth-wrap hidden" aria-label="登录">
<form id="loginForm" class="auth-card" autocomplete="on">
<div class="logo" aria-hidden="true">📦</div>
<h1>文件快传</h1>
<p class="sub">lzwlab.cn 私人在线文件暂存</p>
<label class="field">
<span>用户名</span>
<input id="username" name="username" type="text" autocomplete="username" required maxlength="64">
</label>
<label class="field">
<span>密码</span>
<input id="password" name="password" type="password" autocomplete="current-password" required>
</label>
<p id="loginError" class="form-error hidden" role="alert"></p>
<button id="loginBtn" class="btn btn-primary btn-block" type="submit">登 录</button>
</form>
</section>
<div id="appView" class="hidden">
<header class="topbar">
<div class="topbar-inner">
<div class="brand"><span class="logo-sm" aria-hidden="true">📦</span>文件快传<span class="brand-sub">lzwlab.cn</span></div>
<div class="user-box">
<span class="user-badge">👤 <b id="userName"></b></span>
<button id="logoutBtn" class="btn btn-ghost btn-sm" type="button">退出</button>
</div>
</div>
</header>
<main class="container">
<section class="card" aria-label="上传文件">
<h2>上传文件</h2>
<div id="dropZone" class="drop-zone" role="button" tabindex="0" aria-label="选择或拖拽上传文件">
<div class="dz-icon" aria-hidden="true">⬆️</div>
<p>点击选择文件,或将文件拖到此处</p>
<p class="hint">可一次选择多个文件,单个文件最大 5 GB</p>
</div>
<input type="file" id="fileInput" multiple hidden>
<div class="upload-options">
<label class="field-inline">文件有效期
<select id="expiresSelect" aria-label="文件有效期">
<option value="1">1 小时</option>
<option value="24">1 天</option>
<option value="72">3 天</option>
<option value="168" selected>7 天(默认)</option>
<option value="720">30 天</option>
</select>
</label>
<p class="hint">到期后文件自动删除;最短 1 小时,最长 30 天</p>
</div>
<ul id="queue" class="queue" aria-live="polite"></ul>
</section>
<section class="card" aria-label="我的暂存文件">
<div class="section-head">
<h2>我的暂存文件<span id="fileCount" class="count"></span></h2>
<div class="head-actions">
<button id="cleanupBtn" class="btn btn-ghost btn-sm" type="button">立即清理过期</button>
<button id="refreshBtn" class="btn btn-ghost btn-sm" type="button">刷新</button>
</div>
</div>
<ul id="fileList" class="file-list" aria-live="polite"></ul>
<div id="emptyState" class="empty hidden">
<span class="empty-icon" aria-hidden="true">📭</span>
<p>还没有暂存文件,先上传一个吧</p>
</div>
</section>
</main>
</div>
<div id="toasts" class="toasts" aria-live="polite" aria-atomic="false"></div>
<div id="modalRoot" class="modal-root hidden" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="modal-card">
<h3 id="modalTitle">确认</h3>
<p id="modalMessage"></p>
<div class="modal-actions">
<button id="modalCancel" class="btn btn-ghost" type="button">取消</button>
<button id="modalConfirm" class="btn btn-primary" type="button">确定</button>
</div>
</div>
</div>
<script>
(function () {
'use strict';
var $ = function (sel, root) { return (root || document).querySelector(sel); };
var show = function (el) { el.classList.remove('hidden'); };
var hide = function (el) { el.classList.add('hidden'); };
var loginView = $('#loginView'), appView = $('#appView');
var loginForm = $('#loginForm'), loginError = $('#loginError'), loginBtn = $('#loginBtn');
var usernameInput = $('#username'), passwordInput = $('#password');
var userName = $('#userName'), logoutBtn = $('#logoutBtn');
var dropZone = $('#dropZone'), fileInput = $('#fileInput'), expiresSelect = $('#expiresSelect');
var queueEl = $('#queue'), fileListEl = $('#fileList'), emptyState = $('#emptyState');
var fileCountEl = $('#fileCount'), cleanupBtn = $('#cleanupBtn'), refreshBtn = $('#refreshBtn');
var toastsEl = $('#toasts');
var modalRoot = $('#modalRoot'), modalTitle = $('#modalTitle'), modalMessage = $('#modalMessage');
var modalCancel = $('#modalCancel'), modalConfirm = $('#modalConfirm');
var currentUser = null;
var files = [];
var queue = [];
var queueSeq = 0;
var uploadBusy = false;
var modalResolver = null;
/* ---------- 工具函数 ---------- */
function fmtSize(bytes) {
if (!Number.isFinite(bytes) || bytes < 0) return '-';
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
var v = bytes, i = 0;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return (i === 0 ? v.toFixed(0) : v.toFixed(1)) + ' ' + units[i];
}
function fmtTime(iso) {
var d = new Date(iso);
if (Number.isNaN(d.getTime())) return '-';
return d.toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false
});
}
function fmtRemaining(seconds) {
if (seconds <= 0) return '已过期';
var d = Math.floor(seconds / 86400);
var h = Math.floor((seconds % 86400) / 3600);
var m = Math.floor((seconds % 3600) / 60);
if (d > 0) return '剩 ' + d + ' 天 ' + h + ' 小时';
if (h > 0) return '剩 ' + h + ' 小时 ' + m + ' 分';
if (m > 0) return '剩 ' + m + ' 分钟';
return '即将过期';
}
function toast(message, type) {
type = type || 'info';
var el = document.createElement('div');
el.className = 'toast ' + type;
var icon = type === 'success' ? '✅' : type === 'error' ? '⚠️' : '️';
el.innerHTML = '<span aria-hidden="true">' + icon + '</span><span></span>';
el.lastChild.textContent = message;
toastsEl.appendChild(el);
setTimeout(function () { el.remove(); }, 4000);
}
function openModal(title, message, danger, confirmText) {
modalTitle.textContent = title;
modalMessage.textContent = message;
modalConfirm.className = 'btn ' + (danger ? 'btn-danger' : 'btn-primary');
modalConfirm.textContent = confirmText || (danger ? '删除' : '确定');
show(modalRoot);
modalConfirm.focus();
return new Promise(function (resolve) { modalResolver = resolve; });
}
function closeModal(result) {
hide(modalRoot);
if (modalResolver) { modalResolver(result); modalResolver = null; }
}
modalCancel.addEventListener('click', function () { closeModal(false); });
modalConfirm.addEventListener('click', function () { closeModal(true); });
modalRoot.addEventListener('click', function (e) {
if (e.target === modalRoot) closeModal(false);
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !modalRoot.classList.contains('hidden')) closeModal(false);
});
function parseFilename(cd) {
if (!cd) return null;
var star = /filename*=UTF-8''([^;]+)/i.exec(cd);
if (star) { try { return decodeURIComponent(star[1]); } catch (e) {} }
var plain = /filename="?([^";]+)"?/i.exec(cd);
return plain ? plain[1] : null;
}
function fileIcon(name) {
var ext = (name.split('.').pop() || '').toLowerCase();
if (/(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/.test(ext)) return '🖼️';
if (/(mp4|mkv|mov|avi|wmv|flv|webm)$/.test(ext)) return '🎬';
if (/(mp3|wav|flac|aac|ogg|m4a)$/.test(ext)) return '🎵';
if (/(zip|rar|7z|tar|gz|bz2|xz)$/.test(ext)) return '🗜️';
if (ext === 'pdf') return '📕';
if (/(docx?|xlsx?|pptx?|odt|ods)$/.test(ext)) return '📄';
if (/(js|ts|py|java|c|cpp|go|rs|html|css|json|xml|sh|sql)$/.test(ext)) return '🧩';
return '📄';
}
/* ---------- 会话 / 登录 ---------- */
function showLogin() {
currentUser = null;
hide(appView); show(loginView);
passwordInput.value = '';
hide(loginError);
loginBtn.disabled = false; loginBtn.textContent = '登 录';
}
function showApp(user) {
currentUser = user;
hide(loginView); show(appView);
userName.textContent = user;
loadFiles(true);
}
function handle401() {
toast('登录已过期,请重新登录', 'error');
showLogin();
}
async function checkSession() {
try {
var resp = await fetch('api/session', { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (resp.status === 401) { showLogin(); return; }
var data = await resp.json();
if (data.logged_in) showApp(data.user); else showLogin();
} catch (e) {
showLogin();
toast('无法连接服务器', 'error');
}
}
loginForm.addEventListener('submit', async function (e) {
e.preventDefault();
hide(loginError);
loginBtn.disabled = true; loginBtn.textContent = '登录中…';
try {
var resp = await fetch('api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify({ username: usernameInput.value.trim(), password: passwordInput.value })
});
var data = await resp.json().catch(function () { return { ok: false, error: '服务器错误' }; });
if (resp.ok && data.ok) {
toast('欢迎回来,' + data.user, 'success');
showApp(data.user);
} else {
loginError.textContent = data.error || '用户名或密码错误';
show(loginError);
}
} catch (e) {
loginError.textContent = '网络错误,请重试';
show(loginError);
}
loginBtn.disabled = false; loginBtn.textContent = '登 录';
});
logoutBtn.addEventListener('click', async function () {
logoutBtn.disabled = true;
try {
await fetch('api/logout', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' } });
} catch (e) {}
logoutBtn.disabled = false;
showLogin();
});
/* ---------- 文件列表 ---------- */
async function loadFiles(showErrors) {
try {
var resp = await fetch('api/files', { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (resp.status === 401) { handle401(); return; }
var data = await resp.json();
if (!resp.ok) {
if (showErrors) toast(data.error || '加载文件列表失败', 'error');
return;
}
files = data.files || [];
renderFiles();
} catch (e) {
if (showErrors) toast('加载文件列表失败', 'error');
}
}
function renderFiles() {
fileListEl.innerHTML = '';
if (!files.length) { show(emptyState); fileCountEl.textContent = ''; return; }
hide(emptyState);
fileCountEl.textContent = '' + files.length + ' 个)';
files.forEach(function (f) {
var li = document.createElement('li');
li.className = 'file-row';
li.dataset.id = f.id;
var icon = document.createElement('div');
icon.className = 'file-icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = fileIcon(f.name);
var main = document.createElement('div');
main.className = 'file-main';
var name = document.createElement('div');
name.className = 'file-name';
name.title = f.name;
name.textContent = f.name;
main.appendChild(name);
var meta = document.createElement('div');
meta.className = 'file-meta';
var s1 = document.createElement('span'); s1.className = 'mono'; s1.textContent = fmtSize(f.size);
var s2 = document.createElement('span'); s2.textContent = '上传于 ' + fmtTime(f.created_at);
var s3 = document.createElement('span'); s3.className = 'expire';
if (f.remaining_seconds <= 0) { s3.classList.add('danger'); }
else if (f.remaining_seconds < 24 * 3600) { s3.classList.add('warn'); }
else { s3.classList.add('ok'); }
s3.textContent = fmtRemaining(f.remaining_seconds);
var s4 = document.createElement('span'); s4.textContent = '下载 ' + f.downloads + ' 次';
meta.appendChild(s1); meta.appendChild(s2); meta.appendChild(s3); meta.appendChild(s4);
main.appendChild(meta);
var dl = document.createElement('div');
dl.className = 'dl-progress hidden';
dl.setAttribute('role', 'progressbar');
dl.setAttribute('aria-valuemin', '0'); dl.setAttribute('aria-valuemax', '100'); dl.setAttribute('aria-valuenow', '0');
var dlTrack = document.createElement('div'); dlTrack.className = 'progress';
var dlBar = document.createElement('div'); dlBar.className = 'progress-bar';
dlTrack.appendChild(dlBar);
var dlPct = document.createElement('span'); dlPct.className = 'mono'; dlPct.textContent = '0%';
dl.appendChild(dlTrack); dl.appendChild(dlPct);
main.appendChild(dl);
var actions = document.createElement('div');
actions.className = 'file-actions';
var btnDl = document.createElement('button');
btnDl.type = 'button'; btnDl.className = 'btn btn-primary btn-sm'; btnDl.dataset.id = f.id;
btnDl.textContent = '下载';
var linkNative = document.createElement('a');
linkNative.className = 'btn btn-ghost btn-sm'; linkNative.href = 'api/download/' + f.id + '?dl=1';
linkNative.dataset.id = f.id; linkNative.textContent = '系统下载';
var btnDel = document.createElement('button');
btnDel.type = 'button'; btnDel.className = 'btn btn-danger-ghost btn-sm'; btnDel.dataset.id = f.id;
btnDel.textContent = '删除';
actions.appendChild(btnDl); actions.appendChild(linkNative); actions.appendChild(btnDel);
li.appendChild(icon); li.appendChild(main); li.appendChild(actions);
fileListEl.appendChild(li);
});
}
fileListEl.addEventListener('click', async function (e) {
var btn = e.target.closest('button[data-id], a[data-id]');
if (!btn) return;
var id = btn.dataset.id;
var f = files.find(function (x) { return x.id === id; });
if (!f) return;
if (btn.classList.contains('btn-danger-ghost')) {
var ok = await openModal('删除文件', '确定删除「' + f.name + '」吗?此操作不可恢复。', true);
if (!ok) return;
btn.disabled = true; btn.textContent = '删除中…';
try {
var resp = await fetch('api/files/' + id, { method: 'DELETE', headers: { 'X-Requested-With': 'XMLHttpRequest' } });
var data = await resp.json().catch(function () { return { ok: false }; });
if (resp.ok && data.ok) {
toast('已删除:' + f.name, 'success');
loadFiles(true);
} else if (resp.status === 401) { handle401(); }
else { toast(data.error || '删除失败', 'error'); btn.disabled = false; btn.textContent = '删除'; }
} catch (err) {
toast('网络错误', 'error'); btn.disabled = false; btn.textContent = '删除';
}
return;
}
if (btn.classList.contains('btn-primary')) {
startDownload(f, btn);
}
});
function startDownload(f, btn) {
var li = fileListEl.querySelector('[data-id="' + f.id + '"]');
if (!li) return;
var dl = li.querySelector('.dl-progress');
var bar = li.querySelector('.dl-progress .progress-bar');
var pct = li.querySelector('.dl-progress .mono');
show(dl);
dl.setAttribute('aria-valuenow', '0');
bar.style.width = '0%'; pct.textContent = '0%';
btn.disabled = true; btn.textContent = '下载中…';
var xhr = new XMLHttpRequest();
xhr.open('GET', 'api/download/' + f.id + '?dl=1', true);
xhr.responseType = 'blob';
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.onprogress = function (e) {
if (e.lengthComputable) {
var p = Math.round(e.loaded / e.total * 100);
bar.style.width = p + '%';
pct.textContent = p + '%';
dl.setAttribute('aria-valuenow', String(p));
}
};
xhr.onload = async function () {
if (xhr.status === 200) {
var name = parseFilename(xhr.getResponseHeader('Content-Disposition')) || f.name;
var url = URL.createObjectURL(xhr.response);
var a = document.createElement('a');
a.href = url; a.download = name;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 1500);
bar.style.width = '100%'; pct.textContent = '100%';
dl.setAttribute('aria-valuenow', '100');
toast('下载完成:' + name, 'success');
loadFiles(true);
} else if (xhr.status === 401) {
handle401();
} else {
var msg = '下载失败';
try {
if (xhr.response && xhr.response instanceof Blob) {
var text = await xhr.response.text();
var parsed = JSON.parse(text);
if (parsed && parsed.error) msg = parsed.error;
}
} catch (e) {}
toast(msg, 'error');
hide(dl);
btn.disabled = false; btn.textContent = '下载';
}
};
xhr.onerror = function () {
toast('网络错误,下载中断', 'error');
hide(dl);
btn.disabled = false; btn.textContent = '下载';
};
xhr.send();
}
/* ---------- 清理 / 刷新 ---------- */
cleanupBtn.addEventListener('click', async function () {
cleanupBtn.disabled = true;
try {
var resp = await fetch('api/cleanup', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' } });
var data = await resp.json().catch(function () { return { ok: false }; });
if (resp.ok && data.ok) {
toast('清理完成:移除 ' + data.removed + ' 个过期文件', 'success');
} else if (resp.status === 401) { handle401(); }
else { toast(data.error || '清理失败', 'error'); }
loadFiles(true);
} catch (e) {
toast('网络错误', 'error');
}
cleanupBtn.disabled = false;
});
refreshBtn.addEventListener('click', function () { loadFiles(true); });
/* ---------- 上传队列 ---------- */
dropZone.addEventListener('click', function () { fileInput.click(); });
dropZone.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); }
});
['dragenter', 'dragover'].forEach(function (ev) {
dropZone.addEventListener(ev, function (e) { e.preventDefault(); dropZone.classList.add('dragover'); });
});
['dragleave', 'drop'].forEach(function (ev) {
dropZone.addEventListener(ev, function (e) { e.preventDefault(); dropZone.classList.remove('dragover'); });
});
dropZone.addEventListener('drop', function (e) {
var list = e.dataTransfer && e.dataTransfer.files;
if (list && list.length) enqueueFiles(Array.prototype.slice.call(list));
});
fileInput.addEventListener('change', function () {
if (fileInput.files.length) enqueueFiles(Array.prototype.slice.call(fileInput.files));
fileInput.value = '';
});
function enqueueFiles(fileArr) {
var hours = Number(expiresSelect.value) || 168;
fileArr.forEach(function (file) {
queue.push({
id: ++queueSeq,
file: file,
expiresHours: hours,
status: 'pending',
progress: 0,
loaded: 0,
total: file.size || 0,
error: '',
xhr: null
});
});
renderQueue();
pumpQueue();
}
function renderQueue() {
queueEl.innerHTML = '';
queue.forEach(function (item) {
var li = document.createElement('li');
li.className = 'queue-item';
li.dataset.qid = item.id;
var info = document.createElement('div');
info.className = 'q-info';
var name = document.createElement('div');
name.className = 'q-name'; name.title = item.file.name; name.textContent = item.file.name;
var meta = document.createElement('div');
meta.className = 'q-meta mono';
info.appendChild(name); info.appendChild(meta);
var progress = document.createElement('div');
progress.className = 'q-progress';
var track = document.createElement('div'); track.className = 'progress';
var bar = document.createElement('div'); bar.className = 'progress-bar';
track.appendChild(bar);
var pct = document.createElement('span'); pct.className = 'mono';
progress.appendChild(track); progress.appendChild(pct);
var side = document.createElement('div');
side.className = 'q-side';
var status = document.createElement('span'); status.className = 'q-status';
var cancel = document.createElement('button');
cancel.type = 'button'; cancel.className = 'btn btn-ghost btn-sm'; cancel.textContent = '取消';
cancel.addEventListener('click', function () { cancelItem(item); });
side.appendChild(status); side.appendChild(cancel);
li.appendChild(info); li.appendChild(progress); li.appendChild(side);
item.root = li; item.refs = { meta: meta, bar: bar, pct: pct, status: status, cancel: cancel };
queueEl.appendChild(li);
updateQueueItem(item);
});
}
function statusText(item) {
if (item.status === 'pending') return '等待上传';
if (item.status === 'uploading') return '上传中';
if (item.status === 'done') return '完成';
if (item.status === 'error') return item.error || '上传失败';
if (item.status === 'canceled') return '已取消';
return '';
}
function updateQueueItem(item) {
if (!item.root || !item.root.isConnected) { renderQueue(); return; }
var refs = item.refs;
refs.meta.textContent = fmtSize(item.loaded) + ' / ' + fmtSize(item.total) + ' · 有效期 ' + item.expiresHours + ' 小时';
refs.bar.style.width = item.progress + '%';
refs.pct.textContent = item.progress + '%';
refs.status.textContent = statusText(item);
refs.status.className = 'q-status ' +
(item.status === 'done' ? 'ok' : item.status === 'error' ? 'err' : item.status === 'canceled' ? 'cancel' : '');
if (item.status === 'done') {
refs.cancel.textContent = '移除';
refs.cancel.onclick = function () { removeQueueItem(item); };
} else if (item.status === 'error') {
refs.cancel.textContent = '重试';
refs.cancel.onclick = function () { retryItem(item); };
} else {
refs.cancel.textContent = '取消';
refs.cancel.onclick = function () { cancelItem(item); };
}
}
function removeQueueItem(item) {
queue = queue.filter(function (x) { return x.id !== item.id; });
renderQueue();
}
function retryItem(item) {
item.status = 'pending'; item.progress = 0; item.loaded = 0; item.error = ''; item.xhr = null;
updateQueueItem(item);
pumpQueue();
}
function cancelItem(item) {
if (item.status === 'uploading' && item.xhr) { item.xhr.abort(); }
else { item.status = 'canceled'; updateQueueItem(item); }
}
function pumpQueue() {
if (uploadBusy) return;
uploadBusy = true;
(async function loop() {
while (true) {
var item = queue.find(function (x) { return x.status === 'pending'; });
if (!item) break;
await uploadOne(item);
}
uploadBusy = false;
})();
}
function uploadOne(item) {
return new Promise(function (resolve) {
item.status = 'uploading';
item.progress = 0; item.loaded = 0; item.error = '';
updateQueueItem(item);
var fd = new FormData();
fd.append('file', item.file, item.file.name);
fd.append('expires_hours', String(item.expiresHours));
var xhr = new XMLHttpRequest();
item.xhr = xhr;
xhr.open('POST', 'api/upload', true);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.upload.onprogress = function (e) {
if (e.lengthComputable) {
item.total = e.total;
item.loaded = e.loaded;
item.progress = e.total ? Math.round(e.loaded / e.total * 100) : 0;
updateQueueItem(item);
}
};
xhr.onload = function () {
if (xhr.status === 200) {
item.progress = 100;
item.status = 'done';
updateQueueItem(item);
toast('上传完成:' + item.file.name, 'success');
loadFiles(true);
} else if (xhr.status === 401) {
item.status = 'error'; item.error = '登录已过期';
updateQueueItem(item);
handle401();
} else {
var msg = '上传失败';
try {
var parsed = JSON.parse(xhr.responseText);
if (parsed && parsed.error) msg = parsed.error;
} catch (e) {}
item.status = 'error'; item.error = msg;
updateQueueItem(item);
toast(msg, 'error');
}
resolve();
};
xhr.onerror = function () {
if (item.status !== 'canceled') {
item.status = 'error'; item.error = '网络错误';
updateQueueItem(item);
toast('上传失败:网络错误', 'error');
}
resolve();
};
xhr.onabort = function () {
item.status = 'canceled';
updateQueueItem(item);
resolve();
};
xhr.send(fd);
});
}
/* ---------- 启动 ---------- */
setInterval(function () {
if (currentUser) loadFiles(false);
}, 30000);
checkSession();
})();
</script>
</body>
</html>