🔄 换机助手

局域网电脑数据一键迁移工具。两台电脑各运行同一程序,一端选「发送」、一端选「接收」,即可互传文件和文件夹。

像手机换机一样简单。


✨ 功能特性

功能 说明
一键互传 输入对方 IP 即可连接,无需注册、无需云服务
文件 + 文件夹 支持选择单个/多个文件,或整个文件夹(保持目录结构)
拖放传输 直接拖放文件/文件夹到页面
连续批量 传完一批可继续添加文件再传,无需重新连接
自定义接收目录 默认保存到 D 盘,可随时更改(支持任意路径)
实时进度 每个文件的传输进度、速度、剩余时间一目了然
传输记录 所有已传文件可追溯,支持历史查看
局域网直连 数据不经过第三方服务器,速度快、隐私安全
跨平台 Windows / macOS / Linux 均可使用

🚀 快速开始

1. 环境要求

  • Python 3.8+

  • 两台电脑在同一局域网(连同一个 Wi-Fi 或路由器)

2. 安装依赖

pip install fastapi uvicorn python-multipart

3. 启动程序

python main.py

启动后终端会显示:

====================================================
   🔄  换 机 助 手 已 启 动
====================================================
   本机地址 : http://localhost:8000
   局域网   : http://192.168.1.100:8000
   接收目录 : D:\换机助手_接收文件
====================================================

4. 使用步骤

  电脑 A(发送端)              电脑 B(接收端)
  ─────────────              ─────────────
  ① python main.py           ① python main.py
  ② 打开 localhost:8000      ② 打开 localhost:8000
  ③ 点击「发送」              ③ 点击「接收」
  ④ 输入电脑 B 显示的 IP      ④ 记下页面显示的 IP
  ⑤ 拖入文件,点「开始传输」  ⑤ 自动接收并显示进度
  ⑥ 传输完成 ✓               ⑥ 文件保存到接收目录 ✓

📖 功能详解

发送文件

  1. 首页点击 「发送」

  2. 输入接收端显示的 IP 地址,点击 「连接」

  3. 通过以下方式添加文件:

    • 拖放文件/文件夹 到虚线区域

    • 点击 「📄 选择文件」 按钮(支持多选)

    • 点击 「📁 选择文件夹」 按钮(选择整个目录)

  4. 检查文件列表,可逐个删除不需要的文件

  5. 点击 「开始传输」

  6. 传输过程中可实时查看:

    • 当前文件进度、速度、剩余时间

    • 总体进度

    • 已完成文件列表

  7. 传输完成后可选择 「继续发送」「回到首页」

接收文件

  1. 首页点击 「接收」

  2. 页面显示本机 IP 地址和二维码

  3. 将 IP 告诉发送方(或让对方扫码)

  4. 等待发送方连接后自动开始接收

  5. 接收过程中实时显示进度

  6. 文件默认保存到 D:\换机助手_接收文件

设置接收目录

有三个入口可以修改接收目录:

  • 首页 → 点击「⚙ 接收目录」按钮

  • 右上角 → 点击 ⚙ 齿轮图标

  • 接收等待页 → 目录卡片右侧「更改」按钮

    支持的路径格式:

D:\工作文件\迁移          ← Windows 绝对路径
E:\Backup               ← Windows 其他盘符
~/Desktop/换机文件       ← 支持 ~ 展开为用户目录
./my_files              ← 相对路径

程序会自动创建不存在的目录,并验证写入权限。

传输记录

  • 首页 → 点击「📋 传输记录」查看所有历史

  • 传输页面 → 底部实时显示已完成的文件

  • 完成页 → 展示本次传输的所有文件详情


📁 项目结构

main.py                  # 单文件,包含前后端全部代码

无需额外文件。 前端 HTML/CSS/JS 内嵌在 Python 文件中,启动即用。

生成的接收文件目录结构:

D:\换机助手_接收文件\
├── photo.jpg                        # 单个文件
├── document.pdf                     # 单个文件
├── project\                         # 文件夹(保持原始结构)
│   ├── src\
│   │   ├── main.py
│   │   └── utils.py
│   ├── README.md
│   └── package.json
└── backup\
    └── data.csv

🔧 配置说明

默认接收目录

系统 默认路径
Windows(有 D 盘) D:\换机助手_接收文件
Windows(无 D 盘) 依次尝试 E:、F:,最后回退到用户目录
macOS / Linux ~/换机助手_接收文件

端口

默认使用 8000 端口。如需修改,在 main.py 最底部更改:

uvicorn.run(app, host="0.0.0.0", port=8080)  # 改为 8080

重名文件处理

接收端遇到同名文件时,自动添加后缀:

photo.jpg        → photo.jpg        (首次)
photo.jpg        → photo_1.jpg      (第二次)
photo.jpg        → photo_2.jpg      (第三次)

🛡️ 安全说明

  • 局域网传输:数据仅在局域网内流转,不经过任何外部服务器

  • 无认证机制:设计为可信网络环境使用,建议在安全的局域网下运行

  • 接收目录验证:设置目录时自动检查写入权限,防止误操作


❓ 常见问题

连接失败?

  1. 确认两台电脑在同一局域网(同一 Wi-Fi / 路由器)

  2. 检查防火墙是否放行了 8000 端口

  3. 尝试在接收端 ping 发送端IP 测试连通性

    Windows 防火墙放行方法:

设置 → 隐私和安全性 → Windows 安全中心 → 防火墙
→ 入站规则 → 新建规则 → 端口 → TCP 8000 → 允许连接

传输速度慢?

  • 速度取决于局域网带宽(通常 Wi-Fi 5 约 50-80 MB/s,千兆有线约 100+ MB/s)

  • 大量小文件会比单个大文件慢(每个文件有 HTTP 开销)

  • 建议打包成 zip 再传输大量小文件

接收的文件在哪里?

  • 默认:D:\换机助手_接收文件(Windows)或 ~/换机助手_接收文件(macOS/Linux)

  • 可在首页或接收页面查看/修改具体路径

  • 完成页也会显示保存位置

上传了但看不到记录?

  • F12 打开浏览器控制台,查看是否有 [换机] 开头的日志

  • 服务端终端也会打印每个文件的接收日志

  • 尝试刷新页面后在「📋 传输记录」中查看

支持多大的文件?

  • 理论上无限制,采用流式写入,不会一次性加载整个文件到内存

  • 实际取决于目标磁盘剩余空间

  • 传输过程中可看到实时进度


📄 技术栈

层级 技术
后端 Python + FastAPI + Uvicorn
前端 原生 HTML/CSS/JavaScript(无框架依赖)
通信 HTTP(文件上传)+ WebSocket(实时进度推送)+ 轮询(兜底)
字体 Noto Sans SC + JetBrains Mono(Google Fonts)

代码:

"""
PC 换机助手 v7 — 传输界面实时显示记录
"""

from fastapi import FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, Form
from fastapi.responses import HTMLResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from pathlib import Path
from collections import deque
import time, socket, platform, shutil, threading

app = FastAPI(title="换机助手")
app.add_middleware(
    CORSMiddleware, allow_origins=["*"],
    allow_methods=["*"], allow_headers=["*"], allow_credentials=True,
)

ws_clients: set[WebSocket] = set()
history: deque = deque(maxlen=500)
history_lock = threading.Lock()
seq_counter = 0
seq_lock = threading.Lock()


def get_default_dir():
    if platform.system() == "Windows":
        for letter in ["D", "E", "F"]:
            drive = Path(f"{letter}:/")
            if drive.exists():
                d = drive / "换机助手_接收文件"
                try:
                    d.mkdir(parents=True, exist_ok=True)
                    t = d / ".w_test"
                    t.write_text("ok")
                    t.unlink()
                    return d
                except Exception:
                    continue
    d = Path.home() / "换机助手_接收文件"
    d.mkdir(parents=True, exist_ok=True)
    return d


SAVE_DIR = get_default_dir()


def get_local_ip():
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
        s.close()
        return ip
    except Exception:
        return "127.0.0.1"


def next_seq():
    global seq_counter
    with seq_lock:
        seq_counter += 1
        return seq_counter


async def ws_broadcast(msg: dict):
    dead = set()
    for ws in ws_clients:
        try:
            await ws.send_json(msg)
        except Exception:
            dead.add(ws)
    ws_clients.difference_update(dead)


def disk_info(p: Path):
    try:
        du = shutil.disk_usage(str(p))
        return du.free, du.total
    except Exception:
        return 0, 0


# ── Routes ──

@app.get("/", response_class=HTMLResponse)
async def index():
    return HTML_PAGE


@app.get("/api/info")
async def info():
    free, total = disk_info(SAVE_DIR)
    return {
        "ip": get_local_ip(), "port": 8000,
        "hostname": socket.gethostname(),
        "os": f"{platform.system()} {platform.release()}",
        "save_dir": str(SAVE_DIR),
        "default_dir": str(get_default_dir()),
        "disk_free": free, "disk_total": total,
    }


@app.get("/api/history")
async def get_history(after_seq: int = 0):
    with history_lock:
        if after_seq > 0:
            items = [h for h in history if h["seq"] > after_seq]
        else:
            items = list(history)
    return {"items": items, "count": len(items)}


class DirReq(BaseModel):
    path: str


@app.post("/api/set-dir")
async def set_dir(req: DirReq):
    global SAVE_DIR
    raw = req.path.strip().strip('"').strip("'")
    p = Path(raw).expanduser().resolve()
    print(f"  [SET-DIR] {raw} -> {p}")
    try:
        p.mkdir(parents=True, exist_ok=True)
        t = p / ".write_test_换机"
        t.write_text("ok", encoding="utf-8")
        t.unlink()
    except Exception as e:
        print(f"  [SET-DIR] FAIL: {e}")
        return {"ok": False, "error": str(e)}
    SAVE_DIR = p
    free, total = disk_info(SAVE_DIR)
    print(f"  [SET-DIR] OK: {SAVE_DIR}")
    return {"ok": True, "save_dir": str(SAVE_DIR), "disk_free": free}


@app.post("/api/reset-dir")
async def reset_dir():
    global SAVE_DIR
    d = get_default_dir()
    SAVE_DIR = d
    free, total = disk_info(SAVE_DIR)
    print(f"  [RESET-DIR] -> {SAVE_DIR}")
    return {"ok": True, "save_dir": str(SAVE_DIR), "disk_free": free}


@app.websocket("/ws")
async def ws_endpoint(websocket: WebSocket):
    await websocket.accept()
    ws_clients.add(websocket)
    print(f"  [WS] +1, total={len(ws_clients)}")
    try:
        while True:
            data = await websocket.receive_json()
            if data.get("type") == "ping":
                await websocket.send_json({"type": "pong"})
    except (WebSocketDisconnect, Exception):
        pass
    finally:
        ws_clients.discard(websocket)
        print(f"  [WS] -1, total={len(ws_clients)}")


@app.post("/api/upload")
async def upload(
    file: UploadFile = File(...),
    total_size: int = Form(0),
    relative_path: str = Form(""),
):
    if not relative_path:
        relative_path = file.filename
    rel = Path(relative_path)
    if len(rel.parts) > 1:
        target_dir = SAVE_DIR / rel.parent
        target_dir.mkdir(parents=True, exist_ok=True)
    else:
        target_dir = SAVE_DIR
    save_path = target_dir / rel.name
    counter = 1
    orig = save_path
    while save_path.exists():
        save_path = orig.parent / f"{orig.stem}_{counter}{orig.suffix}"
        counter += 1

    display = relative_path or file.filename
    seq = next_seq()
    print(f"  [UPLOAD #{seq}] 开始: {display} ({total_size} bytes)")

    await ws_broadcast({
        "type": "file_start", "filename": display,
        "total_size": total_size, "seq": seq,
    })

    start_time = time.time()
    written = 0
    last_bcast = 0

    with open(save_path, "wb") as f:
        while True:
            chunk = await file.read(1024 * 1024)
            if not chunk:
                break
            f.write(chunk)
            written += len(chunk)
            now = time.time()
            if now - last_bcast > 0.15:
                await ws_broadcast({
                    "type": "file_progress",
                    "filename": display, "received": written,
                    "total": total_size, "seq": seq,
                })
                last_bcast = now

    elapsed = max(time.time() - start_time, 0.001)

    record = {
        "seq": seq,
        "filename": display,
        "saved_as": str(save_path.relative_to(SAVE_DIR)),
        "size": written,
        "elapsed": round(elapsed, 2),
        "ts": time.time(),
        "dir": str(SAVE_DIR),
    }
    with history_lock:
        history.append(record)

    await ws_broadcast({
        "type": "file_done",
        "seq": seq, "filename": display,
        "saved_as": record["saved_as"],
        "size": written, "elapsed": round(elapsed, 2),
        "ts": record["ts"],
    })

    print(f"  [UPLOAD #{seq}] 完成: {display} -> {save_path} ({written} bytes, {elapsed:.1f}s)")
    return record


# ═══════════════════════════════════════════════════════════════
HTML_PAGE = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>换机助手</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🔄</text></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700;900&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
  --bg:#080810;--sf:rgba(255,255,255,.04);--bd:rgba(255,255,255,.08);
  --p:#00d4ff;--pb:rgba(0,212,255,.1);--s:#7c3aed;
  --ok:#10b981;--okb:rgba(16,185,129,.12);--w:#f59e0b;--e:#ef4444;
  --t:#eaeaf2;--m:#6a6a80;--d:#3a3a4a;
  --f:'Noto Sans SC',system-ui,sans-serif;--mo:'JetBrains Mono',monospace;
  --r:20px;--rs:12px;
}
html,body{height:100%;font-family:var(--f);background:var(--bg);color:var(--t);overflow-x:hidden}
body{background:radial-gradient(ellipse at 15% 85%,rgba(0,60,140,.12) 0%,transparent 50%),
  radial-gradient(ellipse at 85% 15%,rgba(100,0,160,.1) 0%,transparent 50%),var(--bg)}
body::before{content:'';position:fixed;inset:0;pointer-events:none;z-index:0;
  background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.035'/%3E%3C/svg%3E");opacity:.5}

.app{position:relative;z-index:1;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}
.step{display:none;flex-direction:column;align-items:center;width:100%;max-width:560px}
.step.on{display:flex;animation:fi .35s ease}
@keyframes fi{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}

.logo{width:76px;height:76px;border-radius:22px;background:linear-gradient(135deg,var(--p),var(--s));
  display:flex;align-items:center;justify-content:center;font-size:34px;margin-bottom:20px;
  box-shadow:0 8px 30px rgba(0,212,255,.2);animation:fl 3s ease-in-out infinite}
@keyframes fl{0%,100%{transform:translateY(0)}50%{transform:translateY(-6px)}}
.logo.sm{width:48px;height:48px;font-size:20px;border-radius:14px;margin-bottom:12px}
h1{font-size:26px;font-weight:900;letter-spacing:-.3px;margin-bottom:6px;
  background:linear-gradient(135deg,var(--t),var(--p));
  -webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
h1.sm{font-size:19px}
.sub{color:var(--m);font-size:13px;margin-bottom:24px;font-weight:300;line-height:1.6;text-align:center}

.modes{display:flex;gap:14px;width:100%;max-width:420px}
.mode{flex:1;padding:28px 16px;border-radius:var(--r);border:1px solid var(--bd);background:var(--sf);
  cursor:pointer;text-align:center;transition:.25s;user-select:none}
.mode:hover{border-color:var(--p);transform:translateY(-3px);box-shadow:0 8px 28px rgba(0,212,255,.12)}
.mode:active{transform:translateY(-1px)}
.mode .ic{font-size:38px;margin-bottom:10px}.mode .lb{font-size:17px;font-weight:700}
.mode .ds{font-size:12px;color:var(--m);margin-top:4px}

.ipbox{font-family:var(--mo);font-size:28px;font-weight:600;color:var(--p);text-align:center;
  padding:18px;background:var(--pb);border-radius:var(--rs);margin:14px 0;letter-spacing:2px;position:relative}
.cpb{position:absolute;right:10px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,.08);
  border:1px solid transparent;color:var(--t);padding:6px 12px;border-radius:7px;cursor:pointer;
  font-size:11px;font-family:var(--f);transition:.2s}
.cpb:hover{background:rgba(255,255,255,.15);border-color:var(--bd)}

.inpr{display:flex;gap:10px;width:100%}
input[type=text]{flex:1;padding:13px 15px;border-radius:var(--rs);border:1px solid var(--bd);
  background:rgba(0,0,0,.35);color:var(--t);font-family:var(--mo);font-size:14px;outline:0;transition:.2s}
input[type=text]:focus{border-color:var(--p);box-shadow:0 0 0 3px rgba(0,212,255,.08)}
input[type=text]::placeholder{color:var(--d)}

.btn{padding:13px 22px;border-radius:var(--rs);border:none;font-family:var(--f);font-size:13px;
  font-weight:600;cursor:pointer;transition:.2s;user-select:none;white-space:nowrap}
.btn:active{transform:scale(.97)}
.btn.pr{background:linear-gradient(135deg,var(--p),#0099cc);color:#000}
.btn.pr:hover{box-shadow:0 4px 16px rgba(0,212,255,.25)}
.btn.pr:disabled{opacity:.3;cursor:not-allowed;box-shadow:none;transform:none}
.btn.ok{background:var(--ok);color:#000}
.btn.sc{background:rgba(255,255,255,.06);color:var(--t);border:1px solid var(--bd)}
.btn.sc:hover{border-color:var(--p)}
.btn.wn{background:rgba(245,158,11,.12);color:var(--w);border:1px solid rgba(245,158,11,.2)}
.btn.rst{background:rgba(255,255,255,.04);color:var(--m);border:1px solid var(--bd);font-size:12px;padding:8px 14px}
.btn.big{padding:15px 36px;font-size:15px;border-radius:14px}
.btn.sm{padding:10px 15px;font-size:12px;border-radius:8px}

.drop{border:2px dashed var(--bd);border-radius:var(--r);padding:32px 16px;text-align:center;
  cursor:pointer;transition:.3s;margin:12px 0}
.drop:hover,.drop.ov{border-color:var(--p);background:var(--pb)}
.drop .ic{font-size:40px;margin-bottom:4px;opacity:.4}
.drop .tx{color:var(--m);font-size:13px}.drop .tx b{color:var(--p)}
.selbtns{display:flex;gap:10px;justify-content:center;margin-bottom:12px;width:100%}

.flist{max-height:200px;overflow-y:auto;margin:10px 0;width:100%}
.flist::-webkit-scrollbar{width:3px}.flist::-webkit-scrollbar-thumb{background:var(--d);border-radius:2px}
.fi{display:flex;align-items:center;padding:7px 12px;border-radius:8px;background:rgba(0,0,0,.2);
  margin-bottom:3px;animation:fi .2s ease}
.fi .i{font-size:15px;margin-right:7px;flex-shrink:0}
.fi .n{flex:1;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--mo)}
.fi .s{font-family:var(--mo);font-size:10px;color:var(--m);margin:0 7px;flex-shrink:0}
.fi .x{background:none;border:none;color:var(--d);cursor:pointer;font-size:13px;padding:2px 5px;border-radius:4px;transition:.2s}
.fi .x:hover{color:var(--e);background:rgba(239,68,68,.1)}

.sum{display:flex;justify-content:space-between;align-items:center;padding:9px 13px;
  background:rgba(0,0,0,.25);border-radius:var(--rs);margin:10px 0;font-size:11px}
.sum .c{color:var(--m)}.sum .tv{font-family:var(--mo);color:var(--p);font-weight:600}

.pg{margin:14px 0;width:100%}
.pgh{display:flex;justify-content:space-between;margin-bottom:5px;font-size:11px}
.pgf{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:65%;font-family:var(--mo)}
.pgs{font-family:var(--mo);color:var(--m);font-size:10px}
.bar{height:6px;background:rgba(255,255,255,.06);border-radius:3px;overflow:hidden}
.barf{height:100%;width:0;background:linear-gradient(90deg,var(--p),var(--s));border-radius:3px;
  transition:width .15s;position:relative}
.barf::after{content:'';position:absolute;inset:0;
  background:linear-gradient(90deg,transparent,rgba(255,255,255,.3),transparent);animation:shm 1.2s infinite}
@keyframes shm{from{transform:translateX(-100%)}to{transform:translateX(100%)}}
.pct{text-align:center;font-family:var(--mo);font-size:24px;font-weight:600;color:var(--p);margin:8px 0}
.mt{display:flex;justify-content:center;gap:18px;font-size:10px;color:var(--m);font-family:var(--mo)}

.ov{margin-top:14px;padding-top:12px;border-top:1px solid var(--bd);width:100%}
.ovl{font-size:11px;color:var(--m);margin-bottom:5px}
.ovb{height:3px;background:rgba(255,255,255,.06);border-radius:2px;overflow:hidden}
.ovf{height:100%;background:var(--ok);border-radius:2px;transition:width .2s}

.ci{display:flex;align-items:center;gap:10px;padding:11px 13px;background:rgba(0,0,0,.25);
  border-radius:var(--rs);margin-bottom:10px;width:100%}
.ci .cii{font-size:20px}.ci .cih{font-weight:600;font-size:12px}
.ci .cid{font-family:var(--mo);font-size:10px;color:var(--m)}

.back{position:fixed;top:14px;left:14px;background:var(--sf);border:1px solid var(--bd);color:var(--t);
  width:36px;height:36px;border-radius:9px;display:none;align-items:center;justify-content:center;
  cursor:pointer;font-size:15px;transition:.2s;z-index:10}
.back.on{display:flex}.back:hover{border-color:var(--p);background:rgba(255,255,255,.08)}

.gear{position:fixed;top:14px;right:14px;background:var(--sf);border:1px solid var(--bd);color:var(--m);
  width:36px;height:36px;border-radius:9px;display:flex;align-items:center;justify-content:center;
  cursor:pointer;font-size:15px;transition:.2s;z-index:10}
.gear:hover{border-color:var(--p);color:var(--t)}

.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.6);backdrop-filter:blur(4px);
  z-index:100;display:none;align-items:center;justify-content:center}
.modal-bg.on{display:flex;animation:fi .2s ease}
.modal{background:#12121a;border:1px solid var(--bd);border-radius:var(--r);padding:24px;
  width:90%;max-width:520px;max-height:80vh;overflow-y:auto}
.modal h2{font-size:17px;font-weight:700;margin-bottom:18px;
  background:linear-gradient(135deg,var(--t),var(--p));
  -webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}

.dir-current{padding:12px;background:rgba(0,0,0,.3);border:1px solid var(--bd);border-radius:var(--rs);margin-bottom:14px}
.dir-current .label{font-size:10px;color:var(--d);margin-bottom:3px}
.dir-current .val{font-family:var(--mo);font-size:13px;color:var(--p);word-break:break-all}
.dir-current .meta{font-family:var(--mo);font-size:10px;color:var(--m);margin-top:3px}
.dir-input-wrap{margin:10px 0}
.dir-input-wrap .label{font-size:12px;color:var(--m);margin-bottom:5px;font-weight:500}
.dir-input-wrap input{width:100%;padding:11px 13px;border-radius:var(--rs);border:2px solid var(--bd);
  background:rgba(0,0,0,.4);color:var(--t);font-family:var(--mo);font-size:13px;outline:0;transition:.2s}
.dir-input-wrap input:focus{border-color:var(--p)}
.dir-hint{font-size:10px;color:var(--d);margin:6px 0 14px;line-height:1.6}
.dir-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.dir-actions .spacer{flex:1}
.dir-actions .status{font-size:12px;min-height:18px}

.dirbox{width:100%;margin:8px 0}
.dirview{display:flex;align-items:center;gap:8px;padding:11px 13px;
  background:rgba(0,0,0,.3);border:1px solid var(--bd);border-radius:var(--rs)}
.dirview .icon{font-size:18px;flex-shrink:0}
.dirview .info{flex:1;min-width:0}
.dirview .path{font-family:var(--mo);font-size:11px;color:var(--p);
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dirview .meta{font-family:var(--mo);font-size:10px;color:var(--d);margin-top:2px}
.dirview .act{display:flex;gap:5px;flex-shrink:0}
.diredit{margin-top:8px;display:none;animation:fi .2s ease}.diredit.on{display:block}
.diredit .inpr{margin:0 0 7px 0}.diredit input[type=text]{font-size:12px;padding:11px 13px}
.dirbtns{display:flex;gap:7px;align-items:center}
.dirbtns .status{font-size:11px;flex:1;text-align:right}

.dots{display:flex;gap:6px;justify-content:center;margin:14px 0}
.dot{width:8px;height:8px;border-radius:50%;background:var(--p);animation:bn 1.4s infinite}
.dot:nth-child(2){animation-delay:.2s}.dot:nth-child(3){animation-delay:.4s}
@keyframes bn{0%,80%,100%{transform:scale(.5);opacity:.3}40%{transform:scale(1);opacity:1}}
.status{min-height:20px;margin-top:6px;font-size:12px;text-align:center}
.err{color:var(--e)}.okt{color:var(--ok)}.mut{color:var(--m)}
.qr{margin:10px 0;text-align:center}.qr img{border-radius:8px;background:#fff;padding:5px}
.sys{margin-top:20px;font-size:10px;color:var(--d);text-align:center}
.act{display:flex;gap:8px;margin-top:12px;justify-content:center;flex-wrap:wrap}

/* ── 文件记录 ── */
.log-sec{width:100%;margin-top:12px}
.log-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
.log-head .title{font-size:12px;color:var(--m);font-weight:500}
.log-head .cnt{font-family:var(--mo);font-size:12px;color:var(--p);font-weight:600}

.logbox{max-height:240px;overflow-y:auto;width:100%;scroll-behavior:smooth}
.logbox::-webkit-scrollbar{width:3px}.logbox::-webkit-scrollbar-thumb{background:var(--d);border-radius:2px}

.log-item{display:flex;align-items:flex-start;gap:7px;padding:9px 11px;
  background:rgba(0,0,0,.25);border-radius:8px;margin-bottom:3px;
  border-left:3px solid var(--ok);animation:slideIn .3s ease}
@keyframes slideIn{from{opacity:0;transform:translateX(-10px)}to{opacity:1;transform:translateX(0)}}
.log-item.new{animation:flashGreen 1s ease}
@keyframes flashGreen{0%{background:rgba(16,185,129,.15)}100%{background:rgba(0,0,0,.25)}}
.log-item .ck{color:var(--ok);flex-shrink:0;font-size:14px;margin-top:1px}
.log-item .body{flex:1;min-width:0}
.log-item .name{font-family:var(--mo);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.log-item .path{font-size:9px;color:var(--d);font-family:var(--mo);margin-top:1px}
.log-item .right{text-align:right;flex-shrink:0}
.log-item .size{font-family:var(--mo);font-size:10px;color:var(--m)}
.log-item .time{font-family:var(--mo);font-size:9px;color:var(--d)}

/* 空状态 */
.log-empty{text-align:center;padding:20px;color:var(--d);font-size:12px}

.donew{width:80px;height:80px;border-radius:50%;background:var(--okb);display:flex;align-items:center;
  justify-content:center;font-size:40px;margin-bottom:16px;animation:sc .4s ease}
@keyframes sc{from{transform:scale(0)}to{transform:scale(1)}}
.stats{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%;margin:12px 0}
.st{padding:12px;background:rgba(0,0,0,.2);border-radius:var(--rs);text-align:center}
.stv{font-family:var(--mo);font-size:18px;font-weight:600;color:var(--p)}
.stl{font-size:10px;color:var(--m);margin-top:2px}
</style>
</head>
<body>

<button class="back" id="backBtn" onclick="goBack()">←</button>
<button class="gear" onclick="openSettings()">⚙</button>

<!-- ═══ Settings Modal ═══ -->
<div class="modal-bg" id="settingsModal">
  <div class="modal">
    <h2>⚙ 接收目录设置</h2>
    <div class="dir-current">
      <div class="label">当前接收目录</div>
      <div class="val" id="setDirCur">-</div>
      <div class="meta" id="setDirDisk">-</div>
    </div>
    <div class="dir-input-wrap">
      <div class="label">修改接收目录</div>
      <input type="text" id="setDirInput" placeholder="例如 D:\我的文件"
        onkeydown="if(event.key==='Enter')applyDir()"/>
    </div>
    <div class="dir-hint">💡 从资源管理器地址栏复制路径粘贴即可</div>
    <div class="dir-actions">
      <button class="btn pr sm" onclick="applyDir()">确认修改</button>
      <button class="btn rst sm" onclick="resetDir()">恢复默认</button>
      <div class="spacer"></div>
      <span class="status" id="setDirSt"></span>
    </div>
    <div style="text-align:right;margin-top:16px;padding-top:12px;border-top:1px solid var(--bd)">
      <button class="btn sc" onclick="closeSettings()">关闭</button>
    </div>
  </div>
</div>

<!-- ═══ History Modal ═══ -->
<div class="modal-bg" id="histModal">
  <div class="modal" style="max-width:580px">
    <h2>📋 传输记录</h2>
    <div class="logbox" id="histList" style="max-height:50vh"></div>
    <div style="text-align:right;margin-top:14px;padding-top:10px;border-top:1px solid var(--bd)">
      <button class="btn sc" onclick="closeHistory()">关闭</button>
    </div>
  </div>
</div>

<div class="app">

  <!-- ═══ S0 ═══ -->
  <div class="step on" id="s0">
    <div class="logo">🔄</div>
    <h1>换机助手</h1>
    <p class="sub">局域网一键迁移,支持文件和文件夹<br>连续批量传输</p>
    <div class="modes">
      <div class="mode" onclick="pickMode('send')">
        <div class="ic">📤</div><div class="lb">发送</div><div class="ds">从这台电脑发文件</div>
      </div>
      <div class="mode" onclick="pickMode('recv')">
        <div class="ic">📥</div><div class="lb">接收</div><div class="ds">接收另一台电脑的文件</div>
      </div>
    </div>
    <div style="margin-top:12px;display:flex;gap:8px">
      <button class="btn sc sm" onclick="openSettings()">⚙ 接收目录</button>
      <button class="btn sc sm" onclick="openHistory()">📋 传输记录</button>
    </div>
    <div class="sys" id="sysInfo"></div>
  </div>

  <!-- ═══ S1S: 连接 ═══ -->
  <div class="step" id="s1s">
    <div class="logo sm">🔗</div>
    <h1 class="sm">连接接收端</h1>
    <p class="sub">输入接收端电脑上显示的 IP 地址</p>
    <div class="inpr" style="margin:12px 0">
      <input type="text" id="ipInput" placeholder="例如 192.168.1.100"
        onkeydown="if(event.key==='Enter')doConnect()"/>
      <button class="btn pr" id="connBtn" onclick="doConnect()">连接</button>
    </div>
    <div class="status" id="connSt"></div>
  </div>

  <!-- ═══ S1R: 等待 ═══ -->
  <div class="step" id="s1r">
    <div class="logo sm">📡</div>
    <h1 class="sm">等待连接</h1>
    <p class="sub">在发送端输入以下 IP 地址</p>
    <div class="ipbox">
      <span id="myIp">获取中…</span>
      <button class="cpb" onclick="copyIp()">复制</button>
    </div>
    <div class="qr" id="qrBox"></div>
    <div class="dirbox">
      <div class="dirview">
        <span class="icon">📁</span>
        <div class="info">
          <div class="path" id="recvDirPath">-</div>
          <div class="meta" id="recvDirDisk">-</div>
        </div>
        <div class="act"><button class="btn sc sm" onclick="toggleRecvEdit()">更改</button></div>
      </div>
      <div class="diredit" id="recvDirEdit">
        <div class="inpr">
          <input type="text" id="recvDirInput" placeholder="输入新目录…"
            onkeydown="if(event.key==='Enter')applyRecvDir()"/>
        </div>
        <div class="dirbtns">
          <button class="btn pr sm" onclick="applyRecvDir()">确认</button>
          <button class="btn sc sm" onclick="toggleRecvEdit()">取消</button>
          <button class="btn rst sm" onclick="resetRecvDir()">默认</button>
          <span class="status" id="recvDirSt"></span>
        </div>
      </div>
    </div>
    <div class="dots"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>
    <p class="mut" style="text-align:center">等待发送端连接…</p>
  </div>

  <!-- ═══ S2S: 选择文件 ═══ -->
  <div class="step" id="s2s">
    <h1 class="sm">选择要发送的文件</h1>
    <div class="ci" id="connInfo"></div>
    <div class="drop" id="dropZone">
      <div class="ic">📂</div>
      <div class="tx">拖放 <b>文件/文件夹</b> 到这里</div>
    </div>
    <div class="selbtns">
      <button class="btn sc" onclick="$('fileInput').click()">📄 选择文件</button>
      <button class="btn sc" onclick="$('folderInput').click()">📁 选择文件夹</button>
    </div>
    <input type="file" id="fileInput" multiple style="display:none"/>
    <input type="file" id="folderInput" webkitdirectory style="display:none"/>
    <div class="flist" id="fileList"></div>
    <div class="sum" id="sumBar" style="display:none">
      <span class="c"><span id="fileCnt">0</span> 个文件 · <span id="folderCnt">0</span> 个文件夹</span>
      <span class="tv" id="fileTotal">0 B</span>
    </div>
    <div class="act">
      <button class="btn sc" onclick="clearFiles()">清空</button>
      <button class="btn pr big" id="sendBtn" onclick="startTransfer()" disabled>开始传输</button>
    </div>
  </div>

  <!-- ═══ S2R: 接收中 ═══ -->
  <div class="step" id="s2r" style="max-width:600px">
    <div class="logo sm">📥</div>
    <h1 class="sm">正在接收文件</h1>

    <div class="pg" id="rPg" style="display:none">
      <div class="pgh"><span class="pgf" id="rFn">-</span><span class="pgs" id="rSt">-</span></div>
      <div class="bar"><div class="barf" id="rFill"></div></div>
      <div class="pct" id="rPct">0%</div>
    </div>

    <div class="log-sec">
      <div class="log-head">
        <span class="title">已接收文件</span>
        <span class="cnt" id="rCnt">0 个</span>
      </div>
      <div class="logbox" id="rList">
        <div class="log-empty">等待文件传入…</div>
      </div>
    </div>

    <div style="margin-top:12px;display:flex;gap:8px">
      <button class="btn sc sm" onclick="openHistory()">📋 查看历史</button>
    </div>
  </div>

  <!-- ═══ S2T: 传输中 ═══ -->
  <div class="step" id="s2t" style="max-width:600px">
    <div class="logo sm">⚡</div>
    <h1 class="sm">正在传输</h1>

    <div class="pg">
      <div class="pgh"><span class="pgf" id="tFn">-</span><span class="pgs" id="tSt">-</span></div>
      <div class="bar"><div class="barf" id="tFill"></div></div>
      <div class="pct" id="tPct">0%</div>
      <div class="mt"><span id="tSpd">-</span><span id="tEta">-</span></div>
    </div>

    <div class="ov">
      <div class="ovl">总进度: <span id="ovLab">0/0</span></div>
      <div class="ovb"><div class="ovf" id="ovFill"></div></div>
    </div>

    <div class="log-sec">
      <div class="log-head">
        <span class="title">已发送文件</span>
        <span class="cnt" id="tCnt">0 / 0</span>
      </div>
      <div class="logbox" id="tLog">
        <div class="log-empty">开始传输后,每完成一个文件会在这里显示</div>
      </div>
    </div>
  </div>

  <!-- ═══ S3: 完成 ═══ -->
  <div class="step" id="s3" style="max-width:600px">
    <div class="donew" id="doneIcon">✓</div>
    <h1 class="sm" id="dTit">传输完成</h1>
    <p class="sub" id="dSub">-</p>
    <div class="stats" id="dSt"></div>

    <div class="log-sec">
      <div class="log-head">
        <span class="title">传输详情</span>
        <span class="cnt" id="dCnt">-</span>
      </div>
      <div class="logbox" id="dLog" style="max-height:180px"></div>
    </div>

    <div class="act">
      <button class="btn wn" id="btnMore" onclick="keepSending()">继续发送</button>
      <button class="btn sc" onclick="openHistory()">📋 历史</button>
      <button class="btn ok big" onclick="goHome()">完成</button>
    </div>
  </div>

</div>

<script>
/* ═══════════════════════════════════════════════
   换机助手 v7 — 传输界面实时记录
   ═══════════════════════════════════════════════ */

function $(id){return document.getElementById(id)}
function log(m){console.log('[换机]',m)}
function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}

function fmtSize(b){
  if(!b)return'0 B';var k=1024,u=['B','KB','MB','GB','TB'];
  var i=Math.floor(Math.log(b)/Math.log(k));
  return(b/Math.pow(k,i)).toFixed(i>1?1:0)+' '+u[i];
}
function fmtSpd(b){return fmtSize(b)+'/s'}
function fmtTime(s){
  if(!s||s<0)return'--';if(s<60)return Math.ceil(s)+'秒';
  if(s<3600)return Math.floor(s/60)+'分'+Math.ceil(s%60)+'秒';
  return Math.floor(s/3600)+'时'+Math.floor((s%3600)/60)+'分';
}
function fmtTs(ts){
  if(!ts)return'';var d=new Date(ts*1000);
  return String(d.getHours()).padStart(2,'0')+':'+
    String(d.getMinutes()).padStart(2,'0')+':'+
    String(d.getSeconds()).padStart(2,'0');
}
function fIcon(n){
  var e=n.split('.').pop().toLowerCase();
  var m={pdf:'📕',doc:'📘','docx':'📘',txt:'📝',md:'📝',
    xls:'📊',xlsx:'📊',csv:'📊',ppt:'📙','pptx':'📙',
    jpg:'🖼️',jpeg:'🖼️',png:'🖼️',gif:'🖼️',svg:'🖼️',webp:'🖼️',
    mp4:'🎬',avi:'🎬',mkv:'🎬',mov:'🎬',wmv:'🎬',webm:'🎬',
    mp3:'🎵',wav:'🎵',flac:'🎵',aac:'🎵',ogg:'🎵',
    zip:'📦',rar:'📦','7z':'📦',tar:'📦',gz:'📦',
    exe:'⚙️',msi:'⚙️',dmg:'⚙️',app:'⚙️',
    js:'💻',ts:'💻',py:'💻',html:'💻',css:'💻',java:'💻',cpp:'💻',c:'💻',
    go:'💻',rs:'💻',rb:'💻',php:'💻',
    json:'📋',xml:'📋',yaml:'📋',yml:'📋',toml:'📋',
    psd:'🎨',ai:'🎨',sketch:'🎨',fig:'🎨',
    ttf:'🔤',otf:'🔤',woff:'🔤',woff2:'🔤',iso:'💿'};
  return m[e]||'📄';
}

/* ── 创建记录元素 ── */
function makeLogItem(fname, displayPath, savedAs, size, elapsed){
  var el=document.createElement('div');
  el.className='log-item new';
  var icon=fIcon(fname);
  el.innerHTML='<span class="ck">✓</span><div class="body">'
    +'<div class="name">'+icon+' '+escH(displayPath)+'</div>'
    +(savedAs?'<div class="path">→ '+escH(savedAs)+'</div>':'')
    +'</div><div class="right">'
    +'<div class="size">'+fmtSize(size)+'</div>'
    +(elapsed?'<div class="time">'+elapsed+'s</div>':'')
    +'</div>';
  setTimeout(function(){el.classList.remove('new')},1200);
  return el;
}

function getFnameFromPath(p){
  if(!p)return p;
  var sep=p.indexOf('/')>=0?'/':'\\';
  if(p.indexOf(sep)<0)return p;
  return p.split(sep).pop();
}

/* ── State ── */
var S={mode:null,targetUrl:'',ws:null,myInfo:null,targetInfo:null,
  items:[],recvList:[],sendLog:[],totalBytes:0,sentBytes:0,startTime:0,
  pollTimer:null,recvSeq:0,transferDone:false};

/* ── Navigation ── */
function showStep(id){
  var all=document.querySelectorAll('.step');
  for(var i=0;i<all.length;i++)all[i].classList.remove('on');
  var t=$(id);if(t)t.classList.add('on');
}
function pickMode(mode){
  S.mode=mode;$('backBtn').classList.add('on');
  if(mode==='send'){showStep('s1s');setTimeout(function(){$('ipInput').focus()},100)}
  else{showStep('s1r');initRecv()}
}
function goBack(){
  if(S.ws){try{S.ws.close()}catch(e){}}S.ws=null;
  if(S.pollTimer){clearInterval(S.pollTimer);S.pollTimer=null}
  S.items=[];S.mode=null;S.recvList=[];S.sendLog=[];S.recvSeq=0;S.transferDone=false;
  $('fileList').innerHTML='';$('sumBar').style.display='none';
  $('sendBtn').disabled=true;$('connSt').innerHTML='';
  $('tLog').innerHTML='<div class="log-empty">开始传输后,每完成一个文件会在这里显示</div>';
  $('rList').innerHTML='<div class="log-empty">等待文件传入…</div>';
  showStep('s0');$('backBtn').classList.remove('on');
}
function goHome(){goBack()}

/* ── Init ── */
async function refreshInfo(){
  try{var r=await fetch('/api/info');if(!r.ok)throw 0;S.myInfo=await r.json();return S.myInfo}
  catch(e){return null}
}
async function init(){
  var info=await refreshInfo();
  if(info)$('sysInfo').textContent=info.hostname+' · '+info.os+' · '+info.ip;
  else $('sysInfo').textContent='获取信息失败';
}

/* ═══════════════════════════
   HISTORY
   ═══════════════════════════ */
async function openHistory(){
  $('histList').innerHTML='<div class="log-empty">加载中…</div>';
  $('histModal').classList.add('on');
  try{
    var r=await fetch('/api/history');var d=await r.json();
    var items=d.items||[];
    if(!items.length){$('histList').innerHTML='<div class="log-empty">暂无记录</div>';return}
    $('histList').innerHTML='';
    for(var i=items.length-1;i>=0;i--){
      var it=items[i];var fname=getFnameFromPath(it.filename);
      $('histList').appendChild(makeLogItem(fname,it.filename,it.saved_as,it.size,it.elapsed));
    }
  }catch(e){$('histList').innerHTML='<div class="log-empty">加载失败</div>'}
}
function closeHistory(){$('histModal').classList.remove('on')}

/* ═══════════════════════════
   SETTINGS
   ═══════════════════════════ */
async function openSettings(){
  var info=await refreshInfo();
  if(info){$('setDirCur').textContent=info.save_dir;$('setDirDisk').textContent='可用: '+fmtSize(info.disk_free);$('setDirInput').value=info.save_dir}
  $('setDirSt').innerHTML='';$('settingsModal').classList.add('on');
  setTimeout(function(){$('setDirInput').focus()},200);
}
function closeSettings(){$('settingsModal').classList.remove('on')}
async function applyDir(){
  var path=$('setDirInput').value.trim();if(!path)return;
  var st=$('setDirSt');st.innerHTML='<span class="mut">设置中…</span>';
  try{
    var r=await fetch('/api/set-dir',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:path})});
    var d=await r.json();
    if(d.ok){st.innerHTML='<span class="okt">✓ 成功</span>';await refreshInfo();if(S.myInfo){$('setDirCur').textContent=S.myInfo.save_dir;$('setDirDisk').textContent='可用: '+fmtSize(S.myInfo.disk_free)};updateRecvDirDisplay()}
    else st.innerHTML='<span class="err">✗ '+(d.error||'失败')+'</span>';
  }catch(e){st.innerHTML='<span class="err">✗ 请求失败</span>'}
}
async function resetDir(){
  var st=$('setDirSt');st.innerHTML='<span class="mut">恢复中…</span>';
  try{var r=await fetch('/api/reset-dir',{method:'POST'});var d=await r.json();
    if(d.ok){st.innerHTML='<span class="okt">✓ 已恢复</span>';$('setDirInput').value=d.save_dir;await refreshInfo();updateRecvDirDisplay()}
  }catch(e){st.innerHTML='<span class="err">✗ 失败</span>'}
}

/* ═══════════════════════════
   RECEIVER
   ═══════════════════════════ */
function updateRecvDirDisplay(){
  if(!S.myInfo)return;
  $('recvDirPath').textContent=S.myInfo.save_dir;
  $('recvDirDisk').textContent='可用: '+fmtSize(S.myInfo.disk_free);
  $('recvDirInput').value=S.myInfo.save_dir;
}
function toggleRecvEdit(){
  var ed=$('recvDirEdit');ed.classList.toggle('on');
  if(ed.classList.contains('on')){$('recvDirInput').value=S.myInfo?S.myInfo.save_dir:'';$('recvDirSt').innerHTML='';setTimeout(function(){$('recvDirInput').focus()},100)}
}
async function applyRecvDir(){
  var path=$('recvDirInput').value.trim();if(!path)return;
  var st=$('recvDirSt');st.innerHTML='<span class="mut">设置中…</span>';
  try{var r=await fetch('/api/set-dir',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:path})});var d=await r.json();
    if(d.ok){st.innerHTML='<span class="okt">✓</span>';await refreshInfo();updateRecvDirDisplay();setTimeout(toggleRecvEdit,600)}
    else st.innerHTML='<span class="err">✗ '+(d.error||'失败')+'</span>';
  }catch(e){st.innerHTML='<span class="err">✗ 失败</span>'}
}
async function resetRecvDir(){
  try{var r=await fetch('/api/reset-dir',{method:'POST'});var d=await r.json();
    if(d.ok){await refreshInfo();updateRecvDirDisplay();setTimeout(toggleRecvEdit,600)}
  }catch(e){}
}

async function initRecv(){
  if(!S.myInfo)await refreshInfo();
  if(!S.myInfo){$('myIp').textContent='获取失败';return}
  $('myIp').textContent=S.myInfo.ip;
  var url='http://'+S.myInfo.ip+':S.myInfo.port';
  url='http://'+S.myInfo.ip+':'+S.myInfo.port;
  $('qrBox').innerHTML='<img src="https://api.qrserver.com/v1/create-qr-code/?size=140x140&data='+encodeURIComponent(url)+'" width="140" height="140" onerror="this.style.display=\'none\'"/>';
  updateRecvDirDisplay();
  /* 加载已有记录 */
  try{var r=await fetch('/api/history');var d=await r.json();var items=d.items||[];
    for(var i=0;i<items.length;i++)if(items[i].seq>S.recvSeq)S.recvSeq=items[i].seq;
    log('recv: 历史记录 '+items.length+' 条, 最新seq='+S.recvSeq);
  }catch(e){}
  recvWS();
  startRecvPoll();
}

/* WS */
function recvWS(){
  if(S.ws){try{S.ws.close()}catch(e){}}
  try{S.ws=new WebSocket('ws://'+location.host+'/ws')}catch(e){setTimeout(recvWS,3000);return}
  S.ws.onopen=function(){log('R-WS open')};
  S.ws.onmessage=function(ev){var m;try{m=JSON.parse(ev.data)}catch(e){return}handleRecvMsg(m)};
  S.ws.onclose=function(){S.ws=null;log('R-WS closed');setTimeout(recvWS,3000)};
  S.ws.onerror=function(){log('R-WS err')};
}

/* 轮询兜底 */
function startRecvPoll(){
  if(S.pollTimer)clearInterval(S.pollTimer);
  S.pollTimer=setInterval(async function(){
    if(S.transferDone)return;
    try{
      var r=await fetch('/api/history?after_seq='+S.recvSeq);
      var d=await r.json();var items=d.items||[];
      if(items.length){
        log('R-poll: '+items.length+' new items');
        /* 切到接收页面 */
        if(!document.querySelector('#s2r.on')&&!document.querySelector('#s3.on')){
          showStep('s2r');
        }
        for(var i=0;i<items.length;i++){
          var it=items[i];
          if(it.seq>S.recvSeq){
            S.recvSeq=it.seq;
            addRecvItem(it);
          }
        }
      }
    }catch(e){}
  },1500);
}

function handleRecvMsg(m){
  if(m.type==='file_start'){
    S.transferDone=false;
    if(document.querySelector('#s3.on')){S.recvList=[];$('rList').innerHTML='<div class="log-empty">等待文件传入…</div>'}
    showStep('s2r');
    $('rPg').style.display='block';
    $('rFn').textContent=fIcon(m.filename)+' '+getFnameFromPath(m.filename);
    $('rSt').textContent=fmtSize(m.total_size);
    $('rPct').textContent='0%';$('rFill').style.width='0%';
  }else if(m.type==='file_progress'){
    var p=m.total>0?Math.round(m.received/m.total*100):0;
    $('rPct').textContent=p+'%';$('rFill').style.width=p+'%';
    $('rSt').textContent=fmtSize(m.received)+' / '+fmtSize(m.total);
  }else if(m.type==='file_done'){
    $('rPg').style.display='none';
    if(m.seq>S.recvSeq)S.recvSeq=m.seq;
    addRecvItem(m);
  }else if(m.type==='transfer_done'){
    S.transferDone=true;
    recvDone();
  }
}

function addRecvItem(m){
  /* 防重 */
  for(var j=0;j<S.recvList.length;j++){if(S.recvList[j].seq===m.seq)return}
  S.recvList.push(m);

  /* 清空空提示 */
  var empty=$('rList').querySelector('.log-empty');if(empty)empty.remove();

  var fname=getFnameFromPath(m.filename);
  $('rList').insertBefore(makeLogItem(fname,m.filename,m.saved_as,m.size,m.elapsed),$('rList').firstChild);
  $('rCnt').textContent=S.recvList.length+' 个';
}

function recvDone(){
  if(!S.recvList.length)return;
  var tot=0;
  for(var i=0;i<S.recvList.length;i++)tot+=S.recvList[i].size;
  showStep('s3');
  $('doneIcon').textContent='✓';$('doneIcon').style.background='var(--okb)';
  $('dTit').textContent='接收完成';
  $('dSub').textContent=S.recvList.length+' 个文件已保存到 '+(S.myInfo?S.myInfo.save_dir:'');
  $('dSt').innerHTML='<div class="st"><div class="stv">'+S.recvList.length+'</div><div class="stl">文件数</div></div>'
    +'<div class="st"><div class="stv">'+fmtSize(tot)+'</div><div class="stl">总大小</div></div>';
  $('dCnt').textContent=S.recvList.length+' 个';
  $('dLog').innerHTML='';
  for(var i=0;i<S.recvList.length;i++){
    var m=S.recvList[i];var fname=getFnameFromPath(m.filename);
    $('dLog').appendChild(makeLogItem(fname,m.filename,m.saved_as,m.size,m.elapsed));
  }
  $('btnMore').style.display='none';$('backBtn').classList.add('on');
}

function copyIp(){
  if(!S.myInfo)return;if(navigator.clipboard)navigator.clipboard.writeText(S.myInfo.ip);
  var b=document.querySelector('.cpb');b.textContent='✓';setTimeout(function(){b.textContent='复制'},1500);
}

/* ═══════════════════════════
   SENDER
   ═══════════════════════════ */
async function doConnect(){
  var ip=$('ipInput').value.trim();if(!ip)return;
  var btn=$('connBtn'),st=$('connSt');
  btn.disabled=true;btn.textContent='连接中…';
  st.innerHTML='<span class="mut">正在连接 '+escH(ip)+' …</span>';
  try{
    var ctrl=new AbortController();var tm=setTimeout(function(){ctrl.abort()},6000);
    var r=await fetch('http://'+ip+':8000/api/info',{signal:ctrl.signal});clearTimeout(tm);
    if(!r.ok)throw 0;var info=await r.json();
    S.targetUrl='http://'+ip+':8000';S.targetInfo=info;
    senderWS(ip);
    st.innerHTML='<span class="okt">✓ 已连接 '+escH(info.hostname)+'</span>';
    setTimeout(function(){showFileSel(info,ip)},400);
  }catch(e){
    st.innerHTML='<span class="err">✗ 连接失败</span>';btn.disabled=false;btn.textContent='连接';
  }
}
function senderWS(ip){
  if(S.ws){try{S.ws.close()}catch(e){}}
  try{S.ws=new WebSocket('ws://'+ip+':8000/ws');
    S.ws.onopen=function(){log('S-WS open')};
    S.ws.onclose=function(){log('S-WS closed');if(S.mode==='send'&&S.targetUrl)setTimeout(function(){senderWS(ip)},3000)};
    S.ws.onerror=function(){};
  }catch(e){}
}
function showFileSel(info,ip){
  showStep('s2s');
  $('connInfo').innerHTML='<span class="cii">💻</span><div>'
    +'<div class="cih">'+escH(info.hostname)+'</div>'
    +'<div class="cid">'+ip+' · '+escH(info.os)+' · 可用 '+fmtSize(info.disk_free)+'</div></div>';
}

/* ── Files ── */
function addFileItems(fileList){
  for(var i=0;i<fileList.length;i++){
    var f=fileList[i];var rel=f.webkitRelativePath||f.name;var dup=false;
    for(var j=0;j<S.items.length;j++){if(S.items[j].relPath===rel&&S.items[j].file.size===f.size){dup=true;break}}
    if(!dup)S.items.push({file:f,relPath:rel});
  }
  renderFiles();
}
function collectEntries(entry,basePath,results){
  return new Promise(function(resolve){
    if(entry.isFile){entry.file(function(file){results.push({file:file,relPath:basePath+file.name});resolve()})}
    else if(entry.isDirectory){var reader=entry.createReader();var all=[];
      function readBatch(){reader.readEntries(function(batch){
        if(!batch.length){var proms=[];for(var k=0;k<all.length;k++)proms.push(collectEntries(all[k],basePath+entry.name+'/',results));Promise.all(proms).then(resolve)}
        else{for(var k=0;k<batch.length;k++)all.push(batch[k]);readBatch()}
      })}readBatch();
    }else resolve()
  });
}
function removeItem(idx){S.items.splice(idx,1);renderFiles()}
function clearFiles(){S.items=[];renderFiles()}
function renderFiles(){
  var list=$('fileList'),sum=$('sumBar'),btn=$('sendBtn');
  if(!S.items.length){list.innerHTML='';sum.style.display='none';btn.disabled=true;return}
  var html='';for(var i=0;i<S.items.length;i++){var it=S.items[i];var dp=it.relPath||it.file.name;
    html+='<div class="fi"><span class="i">'+fIcon(it.file.name)+'</span><span class="n" title="'+escH(dp)+'">'+escH(dp)+'</span><span class="s">'+fmtSize(it.file.size)+'</span><button class="x" onclick="removeItem('+i+')">×</button></div>'}
  list.innerHTML=html;S.totalBytes=0;var folders={};
  for(var i=0;i<S.items.length;i++){S.totalBytes+=S.items[i].file.size;var rp=S.items[i].relPath;if(rp&&rp.indexOf('/')>=0)folders[rp.split('/')[0]]=true}
  $('fileCnt').textContent=S.items.length;$('folderCnt').textContent=Object.keys(folders).length;$('fileTotal').textContent=fmtSize(S.totalBytes);sum.style.display='flex';btn.disabled=false;
}

/* ── Drag & Drop ── */
(function(){
  var dz=$('dropZone');if(!dz)return;
  dz.addEventListener('click',function(){$('fileInput').click()});
  function on(e,fn){dz.addEventListener(e,function(ev){ev.preventDefault();ev.stopPropagation();fn(ev)})}
  on('dragenter',function(){dz.classList.add('ov')});on('dragover',function(){dz.classList.add('ov')});
  on('dragleave',function(){dz.classList.remove('ov')});
  on('drop',function(ev){dz.classList.remove('ov');
    var items=ev.dataTransfer&&ev.dataTransfer.items;
    if(items&&items.length&&items[0].webkitGetAsEntry){
      var entries=[];for(var i=0;i<items.length;i++){var en=items[i].webkitGetAsEntry();if(en)entries.push(en)}
      var collected=[];var proms=[];for(var i=0;i<entries.length;i++)proms.push(collectEntries(entries[i],'',collected));
      Promise.all(proms).then(function(){if(collected.length)addFileItems(collected.map(function(c){return c.file}))});
    }else if(ev.dataTransfer&&ev.dataTransfer.files.length){addFileItems(ev.dataTransfer.files)}
  });
  document.addEventListener('dragover',function(e){e.preventDefault()});
  document.addEventListener('drop',function(e){e.preventDefault()});
  $('fileInput').addEventListener('change',function(e){if(e.target.files.length)addFileItems(e.target.files);e.target.value=''});
  $('folderInput').addEventListener('change',function(e){if(e.target.files.length)addFileItems(e.target.files);e.target.value=''});
})();

/* ═══════════════════════════
   TRANSFER
   ═══════════════════════════ */
async function startTransfer(){
  if(!S.items.length||!S.targetUrl)return;
  showStep('s2t');$('backBtn').classList.remove('on');
  S.sentBytes=0;S.startTime=Date.now();S.sendLog=[];
  $('ovLab').textContent='0/'+S.items.length;
  $('ovFill').style.width='0%';$('tFill').style.width='0%';$('tPct').textContent='0%';
  $('tLog').innerHTML='';$('tCnt').textContent='0 / '+S.items.length;

  var ok=0,fail=0;
  for(var i=0;i<S.items.length;i++){
    try{
      var result=await uploadOne(S.items[i],i);
      if(result&&result.ok!==false){
        ok++;S.sendLog.push({item:S.items[i],result:result});
        addSendLog(S.items[i],result,ok);
      }else{fail++;log('upload failed: '+S.items[i].relPath)}
    }catch(e){fail++;log('upload error: '+e.message)}
  }
  if(S.ws&&S.ws.readyState===WebSocket.OPEN)S.ws.send(JSON.stringify({type:'transfer_done'}));
  txDone(ok,fail);
}

function addSendLog(item,result,count){
  var empty=$('tLog').querySelector('.log-empty');if(empty)empty.remove();
  var dn=item.relPath||item.file.name;var fname=getFnameFromPath(dn);
  $('tLog').insertBefore(makeLogItem(fname,dn,result.saved_as,result.size,result.elapsed),$('tLog').firstChild);
  $('tCnt').textContent=count+' / '+S.items.length;
}

function uploadOne(item,idx){
  return new Promise(function(resolve,reject){
    var xhr=new XMLHttpRequest();var fd=new FormData();
    fd.append('file',item.file);fd.append('total_size',String(item.file.size));
    fd.append('relative_path',item.relPath||item.file.name);
    var dn=item.relPath||item.file.name;
    $('tFn').textContent=fIcon(item.file.name)+' '+getFnameFromPath(dn);
    $('ovLab').textContent=(idx+1)+'/'+S.items.length;
    xhr.upload.onprogress=function(e){
      if(!e.lengthComputable)return;
      var elapsed=(Date.now()-S.startTime)/1000;
      var spd=elapsed>.3?(S.sentBytes+e.loaded)/elapsed:0;
      var remain=S.totalBytes-S.sentBytes-e.loaded;var eta=spd>0?remain/spd:-1;
      var fp=Math.round(e.loaded/e.total*100);var tp=Math.round((S.sentBytes+e.loaded)/S.totalBytes*100);
      $('tFill').style.width=fp+'%';$('tPct').textContent=fp+'%';
      $('tSt').textContent=fmtSize(e.loaded)+' / '+fmtSize(e.total);
      $('tSpd').textContent='⚡ '+fmtSpd(spd);
      $('tEta').textContent=eta>0?'⏱ '+fmtTime(eta):'';$('ovFill').style.width=tp+'%';
    };
    xhr.onload=function(){
      S.sentBytes+=item.file.size;
      $('ovFill').style.width=Math.round(S.sentBytes/S.totalBytes*100)+'%';
      if(xhr.status>=200&&xhr.status<300){
        try{resolve(JSON.parse(xhr.responseText))}catch(e){resolve({ok:true,size:item.file.size})}
      }else{resolve(null)}
    };
    xhr.onerror=function(){S.sentBytes+=item.file.size;resolve(null)};
    xhr.open('POST',S.targetUrl+'/api/upload');xhr.send(fd);
  });
}

function txDone(ok,fail){
  var el=(Date.now()-S.startTime)/1000;var avg=el>0?S.totalBytes/el:0;
  showStep('s3');
  if(fail>0){$('doneIcon').textContent='⚠';$('doneIcon').style.background='rgba(245,158,11,.12)';$('dTit').textContent='传输完成(有失败)';$('dSub').textContent=ok+' 成功,'+fail+' 失败'}
  else{$('doneIcon').textContent='✓';$('doneIcon').style.background='var(--okb)';$('dTit').textContent='传输完成';$('dSub').textContent='全部 '+ok+' 个文件已发送'}
  var st='<div class="st"><div class="stv">'+ok+'</div><div class="stl">成功</div></div>';
  if(fail>0)st+='<div class="st"><div class="stv" style="color:var(--e)">'+fail+'</div><div class="stl">失败</div></div>';
  st+='<div class="st"><div class="stv">'+fmtSize(S.totalBytes)+'</div><div class="stl">总大小</div></div>';
  st+='<div class="st"><div class="stv">'+fmtTime(el)+'</div><div class="stl">耗时</div></div>';
  $('dSt').innerHTML=st;
  $('dCnt').textContent=ok+' 个';$('dLog').innerHTML='';
  for(var i=0;i<S.sendLog.length;i++){
    var sl=S.sendLog[i];var dn=sl.item.relPath||sl.item.file.name;var fname=getFnameFromPath(dn);
    $('dLog').appendChild(makeLogItem(fname,dn,sl.result.saved_as,sl.result.size,sl.result.elapsed));
  }
  $('btnMore').style.display='';$('backBtn').classList.add('on');
}

function keepSending(){S.items=[];S.totalBytes=0;renderFiles();showStep('s2s');$('backBtn').classList.add('on')}

/* ── Boot ── */
init();
</script>
</body>
</html>"""

if __name__ == "__main__":
    import uvicorn
    print()
    print("=" * 52)
    print("   🔄  换 机 助 手 v7 已 启 动")
    print("=" * 52)
    print(f"   本机地址 : http://localhost:8000")
    print(f"   局域网   : http://{get_local_ip()}:8000")
    print(f"   接收目录 : {SAVE_DIR}")
    print("=" * 52)
    print()
    uvicorn.run(app, host="0.0.0.0", port=8000)

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐