commit 7b09d4b79f38999c95eeb5d9c8a261b55733387a Author: eeuse Date: Mon Sep 28 22:46:55 2026 +0800 AI 生成: 一个基于浏览器网址的跨设备文件快传工具,支持拖拽上传、二维码扫描、实时传输进度显示。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..abf2cee --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# QuickShare - 浏览器跨设备文件快传 + +## 简介 + +QuickShare 是一个简单易用的跨设备文件传输工具。只需在电脑上运行服务,手机或其他设备通过浏览器访问网址即可传输文件。 + +## 特性 + +- 🚀 零配置,一键启动 +- 📱 跨设备传输(电脑 ↔ 手机 ↔ 平板) +- 📁 支持拖拽上传 +- 📊 实时传输进度 +- 🎨 简洁美观的界面 +- 🔒 局域网内安全传输 + +## 快速开始 + +### 安装依赖 + +```bash +pip install flask +``` + +### 启动服务 + +```bash +python app.py +``` + +服务启动后,终端会显示访问地址(如 `http://192.168.1.100:5000`)。 + +### 传输文件 + +1. 在电脑上打开浏览器访问 `http://localhost:5000` +2. 手机连接同一 WiFi,扫描页面上的二维码或输入显示的网址 +3. 拖拽文件到页面即可上传,或点击下载已上传的文件 + +## 目录结构 + +``` +quick-share/ +├── app.py # Flask 后端服务 +├── README.md # 说明文档 +├── requirements.txt # Python 依赖 +├── static/ +│ ├── style.css # 样式文件 +│ └── script.js # 前端逻辑 +├── templates/ +│ └── index.html # 主页面 +└── uploads/ # 上传文件存储目录(自动创建) +``` + +## 技术栈 + +- 后端:Python Flask +- 前端:原生 HTML/CSS/JS +- 二维码:qrcode.js (CDN) + +## 注意事项 + +- 文件保存在 `uploads/` 目录,重启服务不会丢失 +- 建议在局域网内使用,公网使用请注意安全 +- 默认端口 5000,可通过环境变量 `PORT` 修改 diff --git a/app.py b/app.py new file mode 100644 index 0000000..0f07ac8 --- /dev/null +++ b/app.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +""" +QuickShare - 浏览器跨设备文件快传服务 +""" +import os +import socket +import time +from flask import Flask, render_template, request, jsonify, send_from_directory, abort +from werkzeug.utils import secure_filename + +app = Flask(__name__) + +# 上传目录 +UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'uploads') +os.makedirs(UPLOAD_FOLDER, exist_ok=True) + +# 允许所有文件类型,最大 2GB +app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER +app.config['MAX_CONTENT_LENGTH'] = 2 * 1024 * 1024 * 1024 + + +def get_local_ip(): + """获取本机局域网 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 format_size(size): + """格式化文件大小""" + for unit in ['B', 'KB', 'MB', 'GB']: + if size < 1024: + return f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} TB" + + +@app.route('/') +def index(): + """主页面""" + ip = get_local_ip() + port = int(os.environ.get('PORT', 5000)) + url = f"http://{ip}:{port}" + return render_template('index.html', url=url) + + +@app.route('/upload', methods=['POST']) +def upload_file(): + """接收上传的文件""" + if 'file' not in request.files: + return jsonify({'success': False, 'error': '没有文件'}), 400 + + file = request.files['file'] + if file.filename == '': + return jsonify({'success': False, 'error': '文件名为空'}), 400 + + # 安全文件名,避免路径穿越 + filename = secure_filename(file.filename) + if not filename: + filename = f"file_{int(time.time())}" + + # 避免覆盖同名文件 + save_path = os.path.join(app.config['UPLOAD_FOLDER'], filename) + base, ext = os.path.splitext(filename) + counter = 1 + while os.path.exists(save_path): + filename = f"{base}_{counter}{ext}" + save_path = os.path.join(app.config['UPLOAD_FOLDER'], filename) + counter += 1 + + file.save(save_path) + size = os.path.getsize(save_path) + + return jsonify({ + 'success': True, + 'filename': filename, + 'size': format_size(size), + 'size_bytes': size + }) + + +@app.route('/files') +def list_files(): + """列出已上传的文件""" + files = [] + if os.path.exists(UPLOAD_FOLDER): + for name in os.listdir(UPLOAD_FOLDER): + path = os.path.join(UPLOAD_FOLDER, name) + if os.path.isfile(path): + stat = os.stat(path) + files.append({ + 'name': name, + 'size': format_size(stat.st_size), + 'size_bytes': stat.st_size, + 'mtime': stat.st_mtime + }) + # 按修改时间倒序 + files.sort(key=lambda x: x['mtime'], reverse=True) + return jsonify(files) + + +@app.route('/download/') +def download_file(filename): + """下载文件""" + # 安全检查 + if '..' in filename or filename.startswith('/'): + abort(400) + return send_from_directory(app.config['UPLOAD_FOLDER'], filename, as_attachment=True) + + +@app.route('/delete/', methods=['DELETE']) +def delete_file(filename): + """删除文件""" + if '..' in filename or filename.startswith('/'): + abort(400) + path = os.path.join(app.config['UPLOAD_FOLDER'], filename) + if os.path.isfile(path): + os.remove(path) + return jsonify({'success': True}) + return jsonify({'success': False, 'error': '文件不存在'}), 404 + + +if __name__ == '__main__': + port = int(os.environ.get('PORT', 5000)) + ip = get_local_ip() + print("\n" + "=" * 50) + print(" QuickShare 已启动") + print("=" * 50) + print(f" 本机访问: http://localhost:{port}") + print(f" 局域网访问: http://{ip}:{port}") + print("=" * 50 + "\n") + app.run(host='0.0.0.0', port=port, debug=False, threaded=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..53ea104 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Flask==3.0.0 +Werkzeug==3.0.1 diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..1e2cd37 --- /dev/null +++ b/static/script.js @@ -0,0 +1,189 @@ +// ========== 初始化二维码 ========== +document.addEventListener('DOMContentLoaded', function () { + new QRCode(document.getElementById('qrcode'), { + text: SERVER_URL, + width: 140, + height: 140, + colorDark: '#333333', + colorLight: '#ffffff', + correctLevel: QRCode.CorrectLevel.M + }); + loadFiles(); +}); + +// ========== 复制链接 ========== +function copyUrl() { + const url = document.getElementById('urlValue').textContent; + navigator.clipboard.writeText(url).then(() => { + const btn = document.querySelector('.btn-copy'); + const original = btn.textContent; + btn.textContent = '已复制 ✓'; + setTimeout(() => btn.textContent = original, 1500); + }).catch(() => { + // 兼容旧浏览器 + const input = document.createElement('input'); + input.value = url; + document.body.appendChild(input); + input.select(); + document.execCommand('copy'); + document.body.removeChild(input); + }); +} + +// ========== 上传相关 ========== +const dropZone = document.getElementById('dropZone'); +const fileInput = document.getElementById('fileInput'); +const progressContainer = document.getElementById('progressContainer'); +const progressFill = document.getElementById('progressFill'); +const progressName = document.getElementById('progressName'); +const progressPercent = document.getElementById('progressPercent'); + +// 点击触发文件选择 +dropZone.addEventListener('click', () => fileInput.click()); + +// 文件选择 +fileInput.addEventListener('change', (e) => { + if (e.target.files.length) { + uploadFiles(Array.from(e.target.files)); + fileInput.value = ''; + } +}); + +// 拖拽事件 +['dragenter', 'dragover'].forEach(evt => { + dropZone.addEventListener(evt, (e) => { + e.preventDefault(); + e.stopPropagation(); + dropZone.classList.add('dragover'); + }); +}); + +['dragleave', 'drop'].forEach(evt => { + dropZone.addEventListener(evt, (e) => { + e.preventDefault(); + e.stopPropagation(); + dropZone.classList.remove('dragover'); + }); +}); + +dropZone.addEventListener('drop', (e) => { + const files = Array.from(e.dataTransfer.files); + if (files.length) uploadFiles(files); +}); + +// 批量上传 +function uploadFiles(files) { + let index = 0; + const total = files.length; + + function next() { + if (index >= total) { + progressContainer.style.display = 'none'; + loadFiles(); + return; + } + const file = files[index]; + index++; + uploadSingle(file, () => next()); + } + next(); +} + +// 单个文件上传 +function uploadSingle(file, callback) { + const formData = new FormData(); + formData.append('file', file); + + const xhr = new XMLHttpRequest(); + + progressContainer.style.display = 'block'; + progressName.textContent = file.name; + progressFill.style.width = '0%'; + progressPercent.textContent = '0%'; + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const percent = Math.round((e.loaded / e.total) * 100); + progressFill.style.width = percent + '%'; + progressPercent.textContent = percent + '%'; + } + }); + + xhr.addEventListener('load', () => { + progressFill.style.width = '100%'; + progressPercent.textContent = '100%'; + setTimeout(callback, 300); + }); + + xhr.addEventListener('error', () => { + alert('上传失败:' + file.name); + callback(); + }); + + xhr.open('POST', '/upload'); + xhr.send(formData); +} + +// ========== 文件列表 ========== +function loadFiles() { + fetch('/files') + .then(res => res.json()) + .then(files => renderFiles(files)) + .catch(err => console.error('加载文件失败', err)); +} + +function renderFiles(files) { + const list = document.getElementById('fileList'); + if (!files.length) { + list.innerHTML = '
  • 暂无文件
  • '; + return; + } + list.innerHTML = files.map(f => ` +
  • +
    + ${getIcon(f.name)} +
    +
    ${escapeHtml(f.name)}
    +
    ${f.size}
    +
    +
    +
    + 下载 + +
    +
  • + `).join(''); +} + +function deleteFile(name) { + if (!confirm('确定删除 ' + name + ' ?')) return; + fetch('/delete/' + encodeURIComponent(name), { method: 'DELETE' }) + .then(res => res.json()) + .then(() => loadFiles()) + .catch(err => alert('删除失败')); +} + +// ========== 工具函数 ========== +function getIcon(name) { + const ext = name.split('.').pop().toLowerCase(); + const map = { + jpg: '🖼️', jpeg: '🖼️', png: '🖼️', gif: '🖼️', webp: '🖼️', svg: '🖼️', + mp4: '🎬', mov: '🎬', avi: '🎬', mkv: '🎬', + mp3: '🎵', wav: '🎵', flac: '🎵', + pdf: '📕', doc: '📘', docx: '📘', xls: '📗', xlsx: '📗', ppt: '📙', pptx: '📙', + zip: '🗜️', rar: '🗜️', '7z': '🗜️', tar: '🗜️', gz: '🗜️', + txt: '📄', md: '📝', json: '📋', xml: '📋', + exe: '⚙️', apk: '📱', dmg: '💿', iso: '💿' + }; + return map[ext] || '📄'; +} + +function escapeHtml(str) { + return String(str).replace(/[&<>"']/g, s => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + }[s])); +} + +function escapeJs(str) { + return String(str).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"'); +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..6da0eb8 --- /dev/null +++ b/static/style.css @@ -0,0 +1,291 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + padding: 20px; + color: #333; +} + +.container { + max-width: 720px; + margin: 0 auto; +} + +header { + text-align: center; + color: #fff; + margin-bottom: 30px; +} + +header h1 { + font-size: 2.2rem; + margin-bottom: 8px; +} + +.subtitle { + opacity: 0.9; + font-size: 1rem; +} + +section { + background: #fff; + border-radius: 16px; + padding: 24px; + margin-bottom: 20px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); +} + +/* 二维码区域 */ +.qr-section { + display: flex; + align-items: center; + gap: 24px; + flex-wrap: wrap; + justify-content: center; +} + +.qr-box { + text-align: center; +} + +#qrcode { + display: inline-block; + padding: 8px; + background: #fff; + border-radius: 8px; +} + +#qrcode img, #qrcode canvas { + display: block; +} + +.qr-tip { + margin-top: 8px; + color: #666; + font-size: 0.85rem; +} + +.url-box { + flex: 1; + min-width: 240px; +} + +.url-label { + color: #666; + font-size: 0.85rem; + margin-bottom: 6px; +} + +.url-value { + background: #f5f5f7; + padding: 12px 14px; + border-radius: 8px; + font-family: monospace; + font-size: 0.95rem; + word-break: break-all; + margin-bottom: 10px; + color: #667eea; + font-weight: 600; +} + +.btn-copy, .btn-refresh { + background: #667eea; + color: #fff; + border: none; + padding: 8px 18px; + border-radius: 8px; + cursor: pointer; + font-size: 0.9rem; + transition: background 0.2s; +} + +.btn-copy:hover, .btn-refresh:hover { + background: #5568d3; +} + +/* 上传区域 */ +.drop-zone { + border: 2px dashed #c0c4cc; + border-radius: 12px; + padding: 40px 20px; + text-align: center; + cursor: pointer; + transition: all 0.2s; + color: #666; +} + +.drop-zone:hover, .drop-zone.dragover { + border-color: #667eea; + background: #f8f9ff; +} + +.drop-icon { + font-size: 3rem; + margin-bottom: 12px; +} + +.link { + color: #667eea; + font-weight: 600; + text-decoration: underline; +} + +.hint { + margin-top: 8px; + font-size: 0.85rem; + color: #999; +} + +/* 进度条 */ +.progress-container { + margin-top: 16px; +} + +.progress-info { + display: flex; + justify-content: space-between; + font-size: 0.9rem; + color: #555; + margin-bottom: 6px; +} + +.progress-bar { + height: 8px; + background: #eef0f5; + border-radius: 4px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + width: 0; + background: linear-gradient(90deg, #667eea, #764ba2); + transition: width 0.15s; +} + +/* 文件列表 */ +.files-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.files-header h2 { + font-size: 1.15rem; + color: #333; +} + +.file-list { + list-style: none; +} + +.file-list li { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-radius: 10px; + background: #f8f9fb; + margin-bottom: 8px; + transition: background 0.2s; +} + +.file-list li:hover { + background: #eef0f5; +} + +.file-list li.empty { + justify-content: center; + color: #999; + background: transparent; +} + +.file-info { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; +} + +.file-icon { + font-size: 1.4rem; + flex-shrink: 0; +} + +.file-meta { + min-width: 0; + flex: 1; +} + +.file-name { + font-size: 0.95rem; + color: #333; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; +} + +.file-size { + font-size: 0.8rem; + color: #999; + margin-top: 2px; +} + +.file-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.btn-download, .btn-delete { + border: none; + padding: 6px 12px; + border-radius: 6px; + font-size: 0.85rem; + cursor: pointer; + text-decoration: none; + transition: all 0.2s; +} + +.btn-download { + background: #667eea; + color: #fff; +} + +.btn-download:hover { + background: #5568d3; +} + +.btn-delete { + background: #fee; + color: #e74c3c; +} + +.btn-delete:hover { + background: #fdd; +} + +/* 移动端 */ +@media (max-width: 600px) { + header h1 { + font-size: 1.6rem; + } + section { + padding: 18px; + } + .qr-section { + flex-direction: column; + } + .file-actions { + flex-direction: column; + } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..9841c24 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,63 @@ + + + + + + QuickShare - 文件快传 + + + + +
    +
    +

    ⚡ QuickShare

    +

    跨设备文件快传 · 简单快捷

    +
    + +
    +
    +
    +

    扫码访问

    +
    +
    +

    访问地址

    +
    {{ url }}
    + +
    +
    + +
    +
    +
    📁
    +

    拖拽文件到这里,或 点击选择

    +

    支持任意文件,最大 2GB

    + +
    + +
    + +
    +
    +

    已上传文件

    + +
    +
      +
    • 暂无文件
    • +
    +
    +
    + + + + +