Compare commits
19 Commits
37989db6ae
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e78c07b42 | ||
|
|
bbca5a404c | ||
|
|
084a0fe6cd | ||
|
|
ea896cc88f | ||
|
|
2f23dce4ac | ||
|
|
ac267e2277 | ||
|
|
6d7141c5b8 | ||
|
|
1b1dc933fc | ||
|
|
38a273eb3d | ||
|
|
213716fa44 | ||
|
|
e3b3cbc0fe | ||
|
|
09ce6bc7bf | ||
|
|
fd4bab03fb | ||
|
|
d2c60b56c9 | ||
|
|
2255ce9c65 | ||
|
|
a50824e831 | ||
|
|
4561aec7e0 | ||
|
|
9c7f8f0f62 | ||
|
|
a9c2b69112 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,3 +9,6 @@ venv/
|
||||
uploads/
|
||||
.flaskenv
|
||||
.env
|
||||
target/
|
||||
Cargo.lock
|
||||
*.exe
|
||||
|
||||
303
README.md
303
README.md
@@ -1,120 +1,153 @@
|
||||
# Temp File Transfer Service
|
||||
# 临时文件传输服务
|
||||
|
||||
A Flask-based personal temporary file sharing service with a Web UI, API access, and SQLite-backed metadata.
|
||||
基于 Flask 的个人临时文件分享服务,支持 Web 界面、API 接口和 SQLite 数据库。
|
||||
|
||||
What it does
|
||||
- Upload files via a Web UI or an API endpoint
|
||||
- Choose an expiry: 1 hour, 24 hours, or 7 days
|
||||
- Generate a share URL (UUID) for downloaded access
|
||||
- File data stored on disk; metadata stored in SQLite
|
||||
- Web-based download page and a simple API for programmatic uploads
|
||||
## 功能介绍
|
||||
|
||||
- 通过 Web 界面或 API 接口上传文件
|
||||
- 选择过期时间:1 小时、24 小时或 7 天
|
||||
- 生成分享链接(UUID)供下载使用
|
||||
- 文件数据存储在磁盘,元数据存储在 SQLite
|
||||
- Web 下载页面和简单的 API 接口支持程序化上传
|
||||
|
||||
## 技术栈
|
||||
|
||||
Tech stack
|
||||
- Flask (Python)
|
||||
- SQLite (metadata)
|
||||
- Filesystem storage for actual file data
|
||||
- SQLite (元数据存储)
|
||||
- 文件系统存储实际文件数据
|
||||
|
||||
Local development setup
|
||||
1. Prerequisites
|
||||
- Python 3.8+ (the project currently uses Python 3.x in this environment)
|
||||
## 本地开发环境搭建
|
||||
|
||||
### 1. 环境要求
|
||||
|
||||
- Python 3.8+ (当前环境使用 Python 3.x)
|
||||
- pip
|
||||
|
||||
2. Install dependencies
|
||||
### 2. 安装依赖
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. Run the server
|
||||
### 3. 运行服务
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
4. Access
|
||||
- Web UI: http://localhost:5000
|
||||
- API: see /api/upload and /api/file endpoints
|
||||
### 4. 访问地址
|
||||
|
||||
Deployment
|
||||
- Web 界面: http://localhost:5000
|
||||
- API 接口: 详见 /api/upload 和 /api/file 接口
|
||||
|
||||
The production instance runs at:
|
||||
- **Domain**: `xiaji-temp.duckdns.org`
|
||||
- **HTTPS**: Let's Encrypt SSL with auto-renewal (certbot timer)
|
||||
- **Nginx**: reverse proxy with 500MB upload limit, 80→443 redirect
|
||||
- **Gunicorn**: 4 workers, systemd-managed
|
||||
## 部署说明
|
||||
|
||||
Project layout
|
||||
- app.py # Flask application
|
||||
- config.py # Configuration constants
|
||||
- database.py # SQLite helpers and data access
|
||||
- requirements.txt # Python dependencies
|
||||
- templates/ # Jinja templates (index.html, download.html)
|
||||
- upload_client.py # Simple API client example for testing
|
||||
- uploads/ # Storage for uploaded files (created at runtime)
|
||||
生产环境部署信息:
|
||||
|
||||
Data model (SQLite)
|
||||
- Table: files
|
||||
- id TEXT PRIMARY KEY
|
||||
- filename TEXT
|
||||
- filepath TEXT
|
||||
- filesize INTEGER
|
||||
- expiry_hours INTEGER
|
||||
- created_at TIMESTAMP
|
||||
- expires_at TIMESTAMP
|
||||
- **域名**: `xiaji-temp.duckdns.org`
|
||||
- **HTTPS**: Let's Encrypt SSL 证书,自动续期 (certbot timer)
|
||||
- **Nginx**: 反向代理,500MB 上传限制,80→443 自动跳转
|
||||
- **Gunicorn**: 4 个工作者进程,systemd 管理
|
||||
|
||||
Expiry and cleanup
|
||||
- Expiry options are defined as 1h, 24h, 7d in config
|
||||
- A cleanup operation removes expired files from disk and deletes DB rows
|
||||
- Cleanup is invoked on access endpoints (and can be wired to a cron/daemon later)
|
||||
## 项目结构
|
||||
|
||||
Traffic limits
|
||||
- Maximum file size: 500 MB
|
||||
- Per-IP daily traffic limit: 20 GB (upload + download combined)
|
||||
- IP traffic tracked in SQLite `ip_traffic` table, reset daily
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `app.py` | Flask 应用程序主文件 |
|
||||
| `config.py` | 配置常量 |
|
||||
| `database.py` | SQLite 辅助函数和数据访问 |
|
||||
| `requirements.txt` | Python 依赖包 |
|
||||
| `templates/` | Jinja 模板 (index.html, download.html) |
|
||||
| `upload_client.py` | 简单的 API 客户端示例,用于测试 |
|
||||
| `uploads/` | 上传文件存储目录 (运行时创建) |
|
||||
| `temp_file_trans_client/` | PySide6 桌面客户端 |
|
||||
|
||||
SSL certificate
|
||||
- Let's Encrypt certificate deployed via certbot
|
||||
- Auto-renewal via systemd timer: `certbot.timer`
|
||||
- Manual renewal test: `certbot renew --dry-run`
|
||||
## 桌面客户端
|
||||
|
||||
Security notes
|
||||
- Do not commit secrets. Secrets should be provided via environment variables in production.
|
||||
- This repository currently avoids embedding credentials.
|
||||
使用 Python + PySide6 + PyInstaller 构建的桌面客户端,支持拖拽上传文件。
|
||||
|
||||
Next steps (optional)
|
||||
- Add authentication for admin/API usage
|
||||
- Add rate limiting and upload size limits per user
|
||||
- Add automated tests and CI integration
|
||||
### 客户端功能
|
||||
|
||||
License
|
||||
- MIT or your preferred license (update as needed)
|
||||
- 拖拽文件到指定区域上传
|
||||
- 点击选择文件上传
|
||||
- 显示上传进度
|
||||
- 上传成功显示分享链接
|
||||
- 支持复制链接到剪贴板
|
||||
- 可配置服务器地址
|
||||
|
||||
API Usage (Python)
|
||||
### 运行客户端
|
||||
|
||||
Upload a file via the API endpoint:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
BASE_URL = "https://xiaji-temp.duckdns.org"
|
||||
|
||||
expiry = "24h" # 1h, 24h, 7d
|
||||
|
||||
with open("/path/to/your/file.zip", "rb") as f:
|
||||
resp = requests.post(
|
||||
f"{BASE_URL}/api/upload",
|
||||
files={"file": ("file.zip", f)},
|
||||
data={"expiry": expiry},
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
print(f"Share URL: {data['share_url']}")
|
||||
print(f"File ID: {data['id']}")
|
||||
print(f"Size: {data['filesize']} bytes")
|
||||
else:
|
||||
print(f"Upload failed: {resp.json()['error']}")
|
||||
```bash
|
||||
cd temp_file_trans_client
|
||||
pip install -r requirements.txt
|
||||
python main.py
|
||||
```
|
||||
|
||||
Response format:
|
||||
### 打包为 exe
|
||||
|
||||
```bash
|
||||
cd temp_file_trans_client
|
||||
pip install pyinstaller
|
||||
pyinstaller build.spec
|
||||
```
|
||||
|
||||
打包后的可执行文件位于 `dist/temp_file_trans_client.exe`
|
||||
|
||||
## 数据模型 (SQLite)
|
||||
|
||||
**files 表结构:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | TEXT PRIMARY KEY | 文件唯一标识 (UUID) |
|
||||
| filename | TEXT | 原始文件名 |
|
||||
| filepath | TEXT | 文件存储路径 |
|
||||
| filesize | INTEGER | 文件大小 (字节) |
|
||||
| expiry_hours | INTEGER | 过期时间 (小时) |
|
||||
| created_at | TIMESTAMP | 创建时间 |
|
||||
| expires_at | TIMESTAMP | 过期时间 |
|
||||
|
||||
## 过期清理
|
||||
|
||||
- 过期选项在 config 中定义为 1h、24h、7d
|
||||
- 清理操作会删除过期文件并清理数据库记录
|
||||
- 访问接口时自动触发清理 (后续可配置 cron/定时任务)
|
||||
|
||||
## 流量限制
|
||||
|
||||
- 最大文件大小:500 MB
|
||||
- 单 IP 每日流量限制:20 GB (上传 + 下载合计)
|
||||
- IP 流量记录在 SQLite `ip_traffic` 表中,每日重置
|
||||
|
||||
## SSL 证书
|
||||
|
||||
- 使用 Let's Encrypt 证书,通过 certbot 部署
|
||||
- 自动续期通过 systemd timer: `certbot.timer`
|
||||
- 手动测试续期: `certbot renew --dry-run`
|
||||
|
||||
## 安全说明
|
||||
|
||||
- 请勿提交密钥等敏感信息。生产环境应通过环境变量提供
|
||||
- 当前仓库未嵌入任何凭证
|
||||
|
||||
## API 接口说明
|
||||
|
||||
### 上传文件
|
||||
|
||||
```
|
||||
POST /api/upload
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
**请求参数:**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| file | File | 是 | 要上传的文件 |
|
||||
| expiry | String | 否 | 过期时间: 1h / 24h / 7d (默认 24h) |
|
||||
|
||||
**返回示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
@@ -125,23 +158,91 @@ Response format:
|
||||
}
|
||||
```
|
||||
|
||||
Get file info via API:
|
||||
```python
|
||||
resp = requests.get(f"{BASE_URL}/api/file/{file_id}")
|
||||
if resp.status_code == 200:
|
||||
info = resp.json()
|
||||
print(f"Daily upload: {info['daily_upload']} bytes")
|
||||
print(f"Daily download: {info['daily_download']} bytes")
|
||||
print(f"Traffic limit: {info['traffic_limit']} bytes (20GB)")
|
||||
### 获取文件信息
|
||||
|
||||
```
|
||||
GET /api/file/{file_id}
|
||||
```
|
||||
|
||||
Or use the bundled client script:
|
||||
**返回示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"filename": "file.zip",
|
||||
"filesize": 1048576,
|
||||
"created_at": "2024-01-15 10:30:00",
|
||||
"expires_at": "2024-01-16 10:30:00",
|
||||
"daily_upload": 1048576,
|
||||
"daily_download": 0,
|
||||
"traffic_limit": 21474836480
|
||||
}
|
||||
```
|
||||
|
||||
### 下载文件
|
||||
|
||||
```
|
||||
GET /file/{file_id}
|
||||
GET /download/{file_id}
|
||||
```
|
||||
|
||||
## Python API 调用示例
|
||||
|
||||
### 上传文件
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
BASE_URL = "https://xiaji-temp.duckdns.org"
|
||||
|
||||
expiry = "24h" # 可选: 1h, 24h, 7d
|
||||
|
||||
with open("/path/to/your/file.zip", "rb") as f:
|
||||
resp = requests.post(
|
||||
f"{BASE_URL}/api/upload",
|
||||
files={"file": ("file.zip", f)},
|
||||
data={"expiry": expiry},
|
||||
verify=False # 对于自签名证书
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
print(f"分享链接: {data['share_url']}")
|
||||
print(f"文件 ID: {data['id']}")
|
||||
print(f"文件大小: {data['filesize']} bytes")
|
||||
else:
|
||||
print(f"上传失败: {resp.json()['error']}")
|
||||
```
|
||||
|
||||
### 获取文件信息
|
||||
|
||||
```python
|
||||
resp = requests.get(f"{BASE_URL}/api/file/{file_id}", verify=False)
|
||||
if resp.status_code == 200:
|
||||
info = resp.json()
|
||||
print(f"每日上传: {info['daily_upload']} bytes")
|
||||
print(f"每日下载: {info['daily_download']} bytes")
|
||||
print(f"流量限制: {info['traffic_limit']} bytes (20GB)")
|
||||
```
|
||||
|
||||
## 使用客户端脚本
|
||||
|
||||
也可以直接使用附带的客户端脚本上传文件:
|
||||
|
||||
```bash
|
||||
python upload_client.py /path/to/file.zip 24h
|
||||
```
|
||||
|
||||
Contributing
|
||||
- Pull requests are welcome. Please follow the project style and ensure tests pass.
|
||||
## 后续计划
|
||||
|
||||
Contact
|
||||
- If you need to reach the maintainer, use your preferred channel.
|
||||
- [ ] 添加管理员/API 认证
|
||||
- [ ] 添加用户级别的速率限制和上传大小限制
|
||||
- [ ] 添加自动化测试和 CI/CD 集成
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT 许可证 (或您偏好的许可证,按需更新)
|
||||
|
||||
## 联系方式
|
||||
|
||||
如需联系维护者,请使用您偏好的渠道。
|
||||
12
app.py
12
app.py
@@ -3,7 +3,10 @@ import uuid
|
||||
from datetime import datetime
|
||||
from flask import Flask, request, render_template, send_file, jsonify, url_for, abort
|
||||
from config import UPLOAD_FOLDER, SECRET_KEY, MAX_CONTENT_LENGTH, EXPIRY_OPTIONS, DAILY_TRAFFIC_LIMIT
|
||||
from database import init_db, add_file, get_file, delete_file, cleanup_expired, add_upload_traffic, add_download_traffic, get_client_ip, is_traffic_exceeded, get_daily_traffic
|
||||
|
||||
MAX_FILE_SIZE_MB = MAX_CONTENT_LENGTH // (1024 * 1024)
|
||||
DAILY_GB = DAILY_TRAFFIC_LIMIT // (1024 * 1024 * 1024)
|
||||
from database import init_db, add_file, get_file, delete_file, cleanup_expired, add_upload_traffic, add_download_traffic, get_client_ip, is_traffic_exceeded, get_daily_traffic, get_monthly_stats
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['SECRET_KEY'] = SECRET_KEY
|
||||
@@ -15,7 +18,12 @@ init_db()
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('index.html', expiry_options=EXPIRY_OPTIONS)
|
||||
stats = get_monthly_stats()
|
||||
return render_template('index.html',
|
||||
expiry_options=EXPIRY_OPTIONS,
|
||||
max_file_size_mb=MAX_FILE_SIZE_MB,
|
||||
daily_gb=DAILY_GB,
|
||||
stats=stats)
|
||||
|
||||
@app.route('/upload', methods=['POST'])
|
||||
def upload():
|
||||
|
||||
25
database.py
25
database.py
@@ -119,3 +119,28 @@ def is_traffic_exceeded(ip, additional_bytes, direction='upload'):
|
||||
else:
|
||||
total += additional_bytes
|
||||
return total > DAILY_TRAFFIC_LIMIT
|
||||
|
||||
def get_monthly_stats():
|
||||
now = datetime.utcnow()
|
||||
month_start = now.strftime('%Y-%m-01')
|
||||
conn = get_db()
|
||||
traffic_row = conn.execute(
|
||||
'SELECT COALESCE(SUM(upload_bytes),0) AS up, COALESCE(SUM(download_bytes),0) AS down FROM ip_traffic WHERE date >= ?',
|
||||
(month_start,)
|
||||
).fetchone()
|
||||
ip_count = conn.execute(
|
||||
'SELECT COUNT(DISTINCT ip) FROM ip_traffic WHERE date >= ?',
|
||||
(month_start,)
|
||||
).fetchone()[0]
|
||||
file_count = conn.execute(
|
||||
'SELECT COUNT(*) FROM files WHERE created_at >= ?',
|
||||
(month_start,)
|
||||
).fetchone()[0]
|
||||
conn.close()
|
||||
total = (traffic_row['up'] or 0) + (traffic_row['down'] or 0)
|
||||
return {
|
||||
'total_bytes': total,
|
||||
'total_gb': round(total / (1024**3), 2),
|
||||
'ip_count': ip_count,
|
||||
'file_count': file_count,
|
||||
}
|
||||
|
||||
5
desktop/.cargo/config.toml
Normal file
5
desktop/.cargo/config.toml
Normal file
@@ -0,0 +1,5 @@
|
||||
[build]
|
||||
target = "x86_64-pc-windows-gnu"
|
||||
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
2
desktop/.gitignore
vendored
Normal file
2
desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
Cargo.lock
|
||||
14
desktop/Cargo.toml
Normal file
14
desktop/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "temp-file-transfer-desktop"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
eframe = "0.31"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 2
|
||||
lto = true
|
||||
strip = true
|
||||
6
desktop/build.bat
Normal file
6
desktop/build.bat
Normal file
@@ -0,0 +1,6 @@
|
||||
@echo off
|
||||
set PATH=C:\msys64\mingw64\bin;%PATH%
|
||||
cargo build --release
|
||||
echo.
|
||||
echo Build complete: target\x86_64-pc-windows-gnu\release\temp-file-transfer-desktop.exe
|
||||
pause
|
||||
273
desktop/src/main.rs
Normal file
273
desktop/src/main.rs
Normal file
@@ -0,0 +1,273 @@
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
use eframe::egui;
|
||||
use std::process::Command;
|
||||
use std::sync::mpsc;
|
||||
|
||||
const SERVER: &str = "23.226.133.121";
|
||||
const PORT: &str = "10022";
|
||||
const USERNAME: &str = "root";
|
||||
const UPLOAD_DIR: &str = "/opt/temp-file-trans/repo/uploads";
|
||||
const DB_PATH: &str = "/opt/temp-file-trans/repo/files.db";
|
||||
const BASE_URL: &str = "https://xiaji-temp.duckdns.org";
|
||||
|
||||
struct App {
|
||||
status: String,
|
||||
share_url: String,
|
||||
uploading: bool,
|
||||
upload_rx: Option<mpsc::Receiver<Result<String, String>>>,
|
||||
expiry_hours: i32,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
status: "就绪,拖放文件到下方区域上传".to_owned(),
|
||||
share_url: String::new(),
|
||||
uploading: false,
|
||||
upload_rx: None,
|
||||
expiry_hours: 24,
|
||||
}
|
||||
}
|
||||
|
||||
fn start_upload(&mut self, filepath: std::path::PathBuf) {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
self.upload_rx = Some(rx);
|
||||
self.uploading = true;
|
||||
let expiry_hours = self.expiry_hours;
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let result = do_ssh_upload(&filepath, expiry_hours);
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
}
|
||||
|
||||
fn poll_result(&mut self) {
|
||||
if let Some(rx) = &self.upload_rx {
|
||||
if let Ok(result) = rx.try_recv() {
|
||||
self.uploading = false;
|
||||
self.upload_rx = None;
|
||||
match result {
|
||||
Ok(url) => {
|
||||
self.status = "上传成功".to_owned();
|
||||
self.share_url = url;
|
||||
}
|
||||
Err(e) => {
|
||||
self.status = format!("上传失败: {}", e);
|
||||
self.share_url.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn do_ssh_upload(filepath: &std::path::Path, expiry_hours: i32) -> Result<String, String> {
|
||||
let local_path = filepath.to_string_lossy();
|
||||
|
||||
let filename = filepath
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_owned());
|
||||
|
||||
let filesize = std::fs::metadata(&filepath)
|
||||
.map(|m| m.len() as i64)
|
||||
.map_err(|e| format!("读取文件失败: {}", e))?;
|
||||
|
||||
let remote_dest_arg = format!("root@{}:{}/{}", SERVER, UPLOAD_DIR, filename);
|
||||
|
||||
let scp_output = Command::new("scp")
|
||||
.args(["-P", PORT, "-p", &local_path, &remote_dest_arg])
|
||||
.output()
|
||||
.map_err(|e| format!("scp 执行失败: {}", e))?;
|
||||
|
||||
if !scp_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&scp_output.stderr);
|
||||
return Err(format!("scp 上传失败: {}", stderr.trim()));
|
||||
}
|
||||
|
||||
let python_script = format!(
|
||||
"python3 -c \"import sqlite3,uuid,os,datetime; db='{}'; upload_dir='{}'; orig='{}'; fs={}; eh={}; fid=str(uuid.uuid4()); ext=os.path.splitext(orig)[1]; sn=fid+ext; rp=os.path.join(upload_dir,sn); os.rename(os.path.join(upload_dir,'{}'),rp); now=datetime.datetime.utcnow(); ex=now+datetime.timedelta(hours=eh); c=sqlite3.connect(db); c.execute('INSERT INTO files(id,filename,filepath,filesize,expiry_hours,created_at,expires_at) VALUES(?,?,?,?,?,?,?)',(fid,orig,rp,fs,eh,now,ex)); c.commit();c.close();print('{}/file/'+fid)\"",
|
||||
DB_PATH, UPLOAD_DIR, filename, filesize, expiry_hours, filename, BASE_URL
|
||||
);
|
||||
|
||||
let ssh_output = Command::new("ssh")
|
||||
.args(["-p", PORT, &format!("{}@{}", USERNAME, SERVER), &python_script])
|
||||
.output()
|
||||
.map_err(|e| format!("ssh 执行失败: {}", e))?;
|
||||
|
||||
if !ssh_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&ssh_output.stderr);
|
||||
return Err(format!("SSH 命令执行失败: {}", stderr.trim()));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&ssh_output.stdout).trim().to_string();
|
||||
if stdout.starts_with("https://") {
|
||||
Ok(stdout)
|
||||
} else {
|
||||
Err(format!("服务器返回异常: {}", stdout))
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.poll_result();
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(16.0);
|
||||
ui.heading("临时文件传输");
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
egui::RichText::new("通过 SSH 上传 · 服务器: 23.226.133.121:10022")
|
||||
.size(12.0)
|
||||
.color(egui::Color32::from_gray(150)),
|
||||
);
|
||||
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("过期时间:");
|
||||
egui::ComboBox::from_id_salt("expiry")
|
||||
.selected_text(format!("{} 小时", self.expiry_hours))
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut self.expiry_hours, 1, "1 小时");
|
||||
ui.selectable_value(&mut self.expiry_hours, 24, "24 小时");
|
||||
ui.selectable_value(&mut self.expiry_hours, 168, "7 天");
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(12.0);
|
||||
|
||||
let drop_frame = egui::Frame::dark_canvas(ui.style())
|
||||
.inner_margin(egui::Margin::same(40))
|
||||
.corner_radius(egui::CornerRadius::same(12));
|
||||
|
||||
drop_frame.show(ui, |ui| {
|
||||
ui.set_min_size(egui::vec2(440.0, 180.0));
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(50.0);
|
||||
if self.uploading {
|
||||
ui.add(egui::Spinner::new());
|
||||
ui.add_space(8.0);
|
||||
ui.label("正在上传...");
|
||||
} else {
|
||||
ui.label(
|
||||
egui::RichText::new("拖放文件到此处上传")
|
||||
.size(24.0)
|
||||
.color(egui::Color32::from_gray(180)),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
egui::RichText::new("最大 500MB · SSH 直传")
|
||||
.size(14.0)
|
||||
.color(egui::Color32::from_gray(140)),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let dropped = ctx.input(|i| {
|
||||
if !i.raw.dropped_files.is_empty() {
|
||||
Some(i.raw.dropped_files.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(files) = dropped {
|
||||
if !self.uploading {
|
||||
for f in files {
|
||||
if let Some(ref path) = f.path {
|
||||
self.start_upload(path.to_owned());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("状态:");
|
||||
let color = if self.status.starts_with("上传失败") {
|
||||
egui::Color32::from_rgb(220, 80, 80)
|
||||
} else if self.status.starts_with("上传成功") {
|
||||
egui::Color32::from_rgb(80, 200, 80)
|
||||
} else {
|
||||
egui::Color32::from_rgb(100, 180, 100)
|
||||
};
|
||||
ui.label(egui::RichText::new(&self.status).color(color));
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
if !self.share_url.is_empty() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("下载链接:");
|
||||
ui.add_sized(
|
||||
[400.0, 24.0],
|
||||
egui::TextEdit::singleline(&mut self.share_url)
|
||||
.interactive(false),
|
||||
);
|
||||
if ui.button("复制").clicked() {
|
||||
ui.ctx().copy_text(self.share_url.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
||||
fn load_chinese_font(ctx: &egui::Context) {
|
||||
let font_paths = [
|
||||
"C:\\Windows\\Fonts\\msyh.ttc",
|
||||
"C:\\Windows\\Fonts\\msyhbd.ttc",
|
||||
"C:\\Windows\\Fonts\\simhei.ttf",
|
||||
"C:\\Windows\\Fonts\\simsun.ttc",
|
||||
];
|
||||
|
||||
for path in &font_paths {
|
||||
if let Ok(data) = std::fs::read(path) {
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
fonts.font_data.insert(
|
||||
"chinese".to_owned(),
|
||||
std::sync::Arc::new(egui::FontData::from_owned(data)),
|
||||
);
|
||||
fonts
|
||||
.families
|
||||
.get_mut(&egui::FontFamily::Proportional)
|
||||
.unwrap()
|
||||
.insert(0, "chinese".to_owned());
|
||||
fonts
|
||||
.families
|
||||
.get_mut(&egui::FontFamily::Monospace)
|
||||
.unwrap()
|
||||
.insert(0, "chinese".to_owned());
|
||||
ctx.set_fonts(fonts);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_inner_size([560.0, 440.0])
|
||||
.with_resizable(false)
|
||||
.with_title("临时文件传输"),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let _ = eframe::run_native(
|
||||
"临时文件传输",
|
||||
options,
|
||||
Box::new(|cc| {
|
||||
load_chinese_font(&cc.egui_ctx);
|
||||
Ok(Box::new(App::new()))
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
# Temp File Transfer Client - 设计规格
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
- **项目名称**: temp_file_trans_client
|
||||
- **类型**: 桌面客户端应用
|
||||
- **核心功能**: 通过拖放或点击上传文件到临时文件传输服务器,获取分享链接
|
||||
- **目标用户**: 需要快速分享文件给其他人的用户
|
||||
|
||||
## 2. 技术栈
|
||||
|
||||
| 组件 | 技术 |
|
||||
|------|------|
|
||||
| GUI框架 | PySide6 |
|
||||
| HTTP客户端 | requests |
|
||||
| 打包工具 | PyInstaller |
|
||||
| Python版本 | 3.8+ |
|
||||
|
||||
## 3. 功能列表
|
||||
|
||||
### 3.1 服务器配置
|
||||
- 顶部输入框配置服务器地址
|
||||
- 默认值: `http://localhost:5000`
|
||||
- 保存到本地配置文件
|
||||
|
||||
### 3.2 文件上传
|
||||
- 拖放文件到拖放区域上传
|
||||
- 点击拖放区域选择文件
|
||||
- 显示上传进度条
|
||||
- 支持过期时间选择: `1h`, `24h`, `7d`
|
||||
|
||||
### 3.3 结果展示
|
||||
- 上传成功后显示分享链接
|
||||
- 点击链接复制到剪贴板
|
||||
- 显示文件ID和文件名
|
||||
|
||||
### 3.4 错误处理
|
||||
- 网络错误提示
|
||||
- 服务器返回错误展示
|
||||
- 文件大小超限提示(参考服务器500MB限制)
|
||||
|
||||
## 4. UI设计
|
||||
|
||||
### 4.1 窗口布局
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ 服务器: [http://localhost:5000] [保存] │ ← 配置栏 (40px)
|
||||
├──────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ 📁 拖放文件到此处 │ │ ← 拖放区域 (flex)
|
||||
│ │ 或点击选择文件 │ │
|
||||
│ │ │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 过期时间: [24h ▼] [上传文件] │ ← 操作栏 (50px)
|
||||
│ │
|
||||
│ ════════════════════════════════ │ ← 进度条 (隐藏/显示)
|
||||
│ │
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ https://xxx/file_id │ │ ← 结果区 (隐藏/显示)
|
||||
│ │ [复制链接] │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 状态: 就绪 │ ← 状态栏 (30px)
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 窗口规格
|
||||
- 默认尺寸: 500 x 450 px
|
||||
- 最小尺寸: 400 x 350 px
|
||||
- 无边框简约设计(可选)
|
||||
- 居中显示
|
||||
|
||||
### 4.3 拖放区域样式
|
||||
- 虚线边框 (#aaa)
|
||||
- 圆角 (8px)
|
||||
- 悬停时边框变蓝 (#3498db)
|
||||
- 拖拽进入时背景变浅蓝 (#ecf0f1)
|
||||
|
||||
## 5. API交互
|
||||
|
||||
### 5.1 上传接口
|
||||
参考 `upload_client.py`,使用 `requests.post` 上传:
|
||||
|
||||
```
|
||||
POST /api/upload
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file: <binary>
|
||||
expiry: 1h|24h|7d
|
||||
```
|
||||
|
||||
**成功响应 (200)**:
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"filename": "example.pdf",
|
||||
"filesize": 1234567,
|
||||
"expiry_hours": 24,
|
||||
"share_url": "http://server/file/<id>"
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应 (400/429/500)**:
|
||||
```json
|
||||
{
|
||||
"error": "错误描述"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 链接格式
|
||||
```
|
||||
分享链接: {服务器地址}/file/{file_id}
|
||||
直接下载: {服务器地址}/download/{file_id}
|
||||
```
|
||||
|
||||
## 6. 文件结构
|
||||
|
||||
```
|
||||
temp_file_trans_client/
|
||||
├── main.py # 入口,创建应用和主窗口
|
||||
├── ui.py # UI组件和布局
|
||||
├── api.py # API调用封装
|
||||
├── config.json # 本地配置(服务器地址等)
|
||||
├── requirements.txt # 依赖
|
||||
├── build.spec # PyInstaller配置
|
||||
└── README.md # 使用说明
|
||||
```
|
||||
|
||||
## 7. 打包配置
|
||||
|
||||
### PyInstaller spec
|
||||
- 单文件输出
|
||||
- 窗口模式(无控制台)
|
||||
- 包含PySide6和requests
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
1. ✅ 拖放文件到拖放区域触发上传
|
||||
2. ✅ 点击拖放区域可选择文件
|
||||
3. ✅ 显示上传进度
|
||||
4. ✅ 上传成功显示分享链接
|
||||
5. ✅ 可复制分享链接
|
||||
6. ✅ 可配置服务器地址
|
||||
7. ✅ 错误情况有友好提示
|
||||
8. ✅ 可打包成exe运行
|
||||
11
temp_file_trans_client/README.md
Normal file
11
temp_file_trans_client/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Temp File Transfer Client
|
||||
|
||||
## 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
## 运行
|
||||
python main.py
|
||||
|
||||
## 打包
|
||||
pip install pyinstaller
|
||||
pyinstaller build.spec
|
||||
70
temp_file_trans_client/api.py
Normal file
70
temp_file_trans_client/api.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import requests
|
||||
import json
|
||||
import os
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
CONFIG_PATH = os.path.join(os.path.dirname(__file__), 'config.json')
|
||||
|
||||
def load_config():
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
try:
|
||||
with open(CONFIG_PATH, 'r') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return {"server_url": "http://localhost:5000", "default_expiry": "24h"}
|
||||
return {"server_url": "http://localhost:5000", "default_expiry": "24h"}
|
||||
|
||||
def save_config(config):
|
||||
try:
|
||||
with open(CONFIG_PATH, 'w') as f:
|
||||
json.dump(config, f, indent=4)
|
||||
except IOError as e:
|
||||
raise Exception(f"保存配置失败: {e}")
|
||||
|
||||
def upload_file(filepath, expiry='24h', server_url=None):
|
||||
if server_url is None:
|
||||
config = load_config()
|
||||
server_url = config.get('server_url', 'http://localhost:5000')
|
||||
|
||||
url = f"{server_url}/api/upload"
|
||||
|
||||
try:
|
||||
with open(filepath, 'rb') as f:
|
||||
files = {'file': (os.path.basename(filepath), f)}
|
||||
data = {'expiry': expiry}
|
||||
|
||||
response = requests.post(url, files=files, data=data, timeout=30)
|
||||
except RequestException as e:
|
||||
raise Exception(f"网络请求失败: {e}")
|
||||
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
return response.json()
|
||||
except json.JSONDecodeError:
|
||||
raise Exception("服务器响应格式错误")
|
||||
else:
|
||||
try:
|
||||
error_msg = response.json().get('error', '未知错误')
|
||||
except json.JSONDecodeError:
|
||||
error_msg = '未知错误'
|
||||
raise Exception(f"上传失败: {error_msg}")
|
||||
|
||||
def get_file_info(file_id, server_url=None):
|
||||
if server_url is None:
|
||||
config = load_config()
|
||||
server_url = config.get('server_url', 'http://localhost:5000')
|
||||
|
||||
url = f"{server_url}/api/file/{file_id}"
|
||||
|
||||
try:
|
||||
response = requests.get(url, timeout=30)
|
||||
except RequestException as e:
|
||||
raise Exception(f"网络请求失败: {e}")
|
||||
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
return response.json()
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
42
temp_file_trans_client/build.spec
Normal file
42
temp_file_trans_client/build.spec
Normal file
@@ -0,0 +1,42 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
block_cipher = None
|
||||
|
||||
a = Analysis(
|
||||
['main.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[('config.json', '.')],
|
||||
hiddenimports=['PySide6', 'requests'],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
name='temp_file_trans_client',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=False,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=None,
|
||||
)
|
||||
4
temp_file_trans_client/config.json
Normal file
4
temp_file_trans_client/config.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"server_url": "http://localhost:5000",
|
||||
"default_expiry": "24h"
|
||||
}
|
||||
113
temp_file_trans_client/main.py
Normal file
113
temp_file_trans_client/main.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import sys
|
||||
import os
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox, QMainWindow
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
from ui import UiCreator
|
||||
from api import load_config, save_config, upload_file, get_file_info
|
||||
|
||||
class UploadThread(QThread):
|
||||
finished = Signal(dict)
|
||||
error = Signal(str)
|
||||
progress = Signal(int)
|
||||
|
||||
def __init__(self, filepath, expiry, server_url):
|
||||
super().__init__()
|
||||
self.filepath = filepath
|
||||
self.expiry = expiry
|
||||
self.server_url = server_url
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self.progress.emit(50)
|
||||
result = upload_file(self.filepath, self.expiry, self.server_url)
|
||||
self.progress.emit(100)
|
||||
self.finished.emit(result)
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
class MainWindow:
|
||||
def __init__(self):
|
||||
self.app = QApplication(sys.argv)
|
||||
self.window = QMainWindow()
|
||||
|
||||
config = load_config()
|
||||
self.server_url = config.get('server_url', 'http://localhost:5000')
|
||||
self.selected_file = None
|
||||
self.upload_thread = None
|
||||
|
||||
central = UiCreator.create_main_ui(self.window)
|
||||
|
||||
self.window.server_input.setText(self.server_url)
|
||||
self.window.save_btn.clicked.connect(self.save_server_config)
|
||||
self.window.upload_btn.clicked.connect(self.start_upload)
|
||||
self.window.copy_btn.clicked.connect(self.copy_result)
|
||||
|
||||
self.window.drop_zone.file_dropped.connect(self.on_file_selected)
|
||||
|
||||
def save_server_config(self):
|
||||
self.server_url = self.window.server_input.text().strip()
|
||||
config = {"server_url": self.server_url, "default_expiry": "24h"}
|
||||
save_config(config)
|
||||
self.window.status_label.setText("状态: 配置已保存")
|
||||
|
||||
def on_file_selected(self, filepath):
|
||||
self.selected_file = filepath
|
||||
filename = os.path.basename(filepath)
|
||||
self.window.drop_label.setText(f"已选择: {filename}")
|
||||
self.window.status_label.setText(f"状态: 已选择文件: {filename}")
|
||||
|
||||
def start_upload(self):
|
||||
if not self.selected_file:
|
||||
self.window.status_label.setText("状态: 请先选择文件")
|
||||
return
|
||||
|
||||
if self.upload_thread is not None and self.upload_thread.isRunning():
|
||||
self.window.status_label.setText("状态: 上传进行中,请等待")
|
||||
return
|
||||
|
||||
expiry = self.window.expiry_combo.currentText()
|
||||
self.window.progress_bar.setVisible(True)
|
||||
self.window.upload_btn.setEnabled(False)
|
||||
self.window.status_label.setText("状态: 上传中...")
|
||||
|
||||
self.upload_thread = UploadThread(self.selected_file, expiry, self.server_url)
|
||||
self.upload_thread.finished.connect(self.on_upload_success)
|
||||
self.upload_thread.finished.connect(self.upload_thread.deleteLater)
|
||||
self.upload_thread.error.connect(self.on_upload_error)
|
||||
self.upload_thread.progress.connect(self.window.progress_bar.setValue)
|
||||
self.upload_thread.start()
|
||||
|
||||
def on_upload_success(self, result):
|
||||
self.window.progress_bar.setVisible(False)
|
||||
self.window.upload_btn.setEnabled(True)
|
||||
|
||||
share_url = result.get('share_url', '')
|
||||
file_id = result.get('id', '')
|
||||
filename = result.get('filename', '')
|
||||
filesize = result.get('filesize', 0)
|
||||
|
||||
result_text = f"✅ 上传成功!\n\n文件: {filename}\n大小: {filesize:,} 字节\n\n分享链接:\n{share_url}\n\n文件ID: {file_id}"
|
||||
self.window.result_area.setPlainText(result_text)
|
||||
self.window.result_area.setVisible(True)
|
||||
self.window.copy_btn.setVisible(True)
|
||||
self.window.share_url = share_url
|
||||
self.window.status_label.setText("状态: 上传完成")
|
||||
|
||||
def on_upload_error(self, error_msg):
|
||||
self.window.progress_bar.setVisible(False)
|
||||
self.window.upload_btn.setEnabled(True)
|
||||
self.window.status_label.setText(f"状态: 上传失败")
|
||||
QMessageBox.critical(self.window, "上传失败", error_msg)
|
||||
|
||||
def copy_result(self):
|
||||
if hasattr(self.window, 'share_url'):
|
||||
clipboard = self.app.clipboard()
|
||||
clipboard.setText(self.window.share_url)
|
||||
self.window.status_label.setText("状态: 链接已复制到剪贴板")
|
||||
|
||||
def run(self):
|
||||
self.window.show()
|
||||
sys.exit(self.app.exec())
|
||||
|
||||
if __name__ == '__main__':
|
||||
MainWindow().run()
|
||||
2
temp_file_trans_client/requirements.txt
Normal file
2
temp_file_trans_client/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
PySide6>=6.5.0
|
||||
requests>=2.31.0
|
||||
134
temp_file_trans_client/ui.py
Normal file
134
temp_file_trans_client/ui.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from PySide6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QLineEdit, QPushButton, QLabel, QComboBox,
|
||||
QProgressBar, QTextEdit, QFrame, QFileDialog)
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
|
||||
class DropZone(QFrame):
|
||||
file_dropped = Signal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setAcceptDrops(True)
|
||||
self.file_selected = None
|
||||
self.setMinimumSize(400, 200)
|
||||
self.setStyleSheet("""
|
||||
QFrame {
|
||||
border: 2px dashed #aaa;
|
||||
border-radius: 8px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
QFrame:hover {
|
||||
border-color: #3498db;
|
||||
background-color: #ecf0f1;
|
||||
}
|
||||
""")
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
if event.mimeData().hasUrls():
|
||||
event.acceptProposedAction()
|
||||
self.setStyleSheet("""
|
||||
QFrame {
|
||||
border: 2px dashed #3498db;
|
||||
border-radius: 8px;
|
||||
background-color: #d5e8f7;
|
||||
}
|
||||
""")
|
||||
|
||||
def dragLeaveEvent(self, event):
|
||||
self.setStyleSheet("""
|
||||
QFrame {
|
||||
border: 2px dashed #aaa;
|
||||
border-radius: 8px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
""")
|
||||
|
||||
def dropEvent(self, event):
|
||||
if event.mimeData().hasUrls():
|
||||
urls = event.mimeData().urls()
|
||||
if urls:
|
||||
self.file_selected = urls[0].toLocalFile()
|
||||
event.acceptProposedAction()
|
||||
self.file_dropped.emit(self.file_selected)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
filepath, _ = QFileDialog.getOpenFileName(self, "选择文件")
|
||||
if filepath:
|
||||
self.file_selected = filepath
|
||||
self.file_dropped.emit(filepath)
|
||||
|
||||
class UiCreator:
|
||||
@staticmethod
|
||||
def create_main_ui(window):
|
||||
window.setWindowTitle("临时文件传输客户端")
|
||||
window.setMinimumSize(400, 350)
|
||||
window.resize(500, 450)
|
||||
|
||||
central_widget = QWidget()
|
||||
window.setCentralWidget(central_widget)
|
||||
layout = QVBoxLayout(central_widget)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
layout.setSpacing(15)
|
||||
|
||||
server_layout = QHBoxLayout()
|
||||
server_label = QLabel("服务器:")
|
||||
window.server_input = QLineEdit("http://localhost:5000")
|
||||
window.save_btn = QPushButton("保存")
|
||||
window.save_btn.setFixedWidth(60)
|
||||
server_layout.addWidget(server_label)
|
||||
server_layout.addWidget(window.server_input)
|
||||
server_layout.addWidget(window.save_btn)
|
||||
layout.addLayout(server_layout)
|
||||
|
||||
window.drop_zone = DropZone(window)
|
||||
window.drop_label = QLabel("📁 拖放文件到此处\n或点击选择文件")
|
||||
window.drop_label.setAlignment(Qt.AlignCenter)
|
||||
drop_layout = QVBoxLayout(window.drop_zone)
|
||||
drop_layout.addWidget(window.drop_label)
|
||||
layout.addWidget(window.drop_zone)
|
||||
|
||||
action_layout = QHBoxLayout()
|
||||
expiry_label = QLabel("过期时间:")
|
||||
window.expiry_combo = QComboBox()
|
||||
window.expiry_combo.addItems(["1h", "24h", "7d"])
|
||||
window.expiry_combo.setCurrentText("24h")
|
||||
window.upload_btn = QPushButton("上传文件")
|
||||
window.upload_btn.setFixedWidth(100)
|
||||
action_layout.addWidget(expiry_label)
|
||||
action_layout.addWidget(window.expiry_combo)
|
||||
action_layout.addStretch()
|
||||
action_layout.addWidget(window.upload_btn)
|
||||
layout.addLayout(action_layout)
|
||||
|
||||
window.progress_bar = QProgressBar()
|
||||
window.progress_bar.setVisible(False)
|
||||
window.progress_bar.setTextVisible(True)
|
||||
layout.addWidget(window.progress_bar)
|
||||
|
||||
window.result_area = QTextEdit()
|
||||
window.result_area.setVisible(False)
|
||||
window.result_area.setReadOnly(True)
|
||||
window.result_area.setMaximumHeight(80)
|
||||
window.result_area.setStyleSheet("""
|
||||
QTextEdit {
|
||||
background-color: #f0f0f0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
""")
|
||||
layout.addWidget(window.result_area)
|
||||
|
||||
window.copy_btn = QPushButton("复制链接")
|
||||
window.copy_btn.setVisible(False)
|
||||
window.copy_btn.setFixedWidth(80)
|
||||
copy_layout = QHBoxLayout()
|
||||
copy_layout.addStretch()
|
||||
copy_layout.addWidget(window.copy_btn)
|
||||
layout.addLayout(copy_layout)
|
||||
|
||||
window.status_label = QLabel("状态: 就绪")
|
||||
window.status_label.setStyleSheet("color: #666; font-size: 12px;")
|
||||
layout.addWidget(window.status_label)
|
||||
|
||||
return central_widget
|
||||
@@ -4,11 +4,75 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>临时文件传输</title>
|
||||
<style>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { margin: 0; padding: 0; }
|
||||
.container { width: auto; height: auto; overflow: visible; }
|
||||
h1 { font-size: 16px; color: #333; height: auto; overflow: visible; }
|
||||
body {
|
||||
margin: 0; padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
}
|
||||
.status-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 48px 56px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.15);
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
}
|
||||
.status-icon {
|
||||
font-size: 56px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
padding: 4px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #eef0f5;
|
||||
font-size: 15px;
|
||||
color: #555;
|
||||
}
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
.info-label { font-weight: 500; color: #888; }
|
||||
.info-value { font-weight: 600; color: #1a1a2e; }
|
||||
.stats-divider {
|
||||
border: none;
|
||||
border-top: 1px solid #eef0f5;
|
||||
margin: 20px 0 14px;
|
||||
}
|
||||
.stats-title {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.stat-label { color: #aaa; }
|
||||
.stat-value { color: #666; font-weight: 500; }
|
||||
.container { width: 1px; height: 1px; overflow: hidden; }
|
||||
.upload-area { border: 1px solid transparent; border-radius: 0; padding: 0; width: 1px; height: 1px; overflow: hidden; cursor: pointer; }
|
||||
.upload-area:hover, .upload-area.dragover { border-color: transparent; background: transparent; }
|
||||
.upload-area input { display: none; }
|
||||
@@ -22,11 +86,78 @@
|
||||
.result { width: 1px; height: 1px; overflow: hidden; }
|
||||
.result a { font-size: 1px; color: transparent; }
|
||||
.error { width: 1px; height: 1px; overflow: hidden; font-size: 1px; color: transparent; }
|
||||
</style>
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="status-card">
|
||||
<div class="status-icon">⚡</div>
|
||||
<h1>服务正在运行中</h1>
|
||||
<span class="status-tag">运行正常</span>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="info-label">最大文件大小</span>
|
||||
<span class="info-value">{{ max_file_size_mb }} MB</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">可保存时间</span>
|
||||
<span class="info-value">1小时 / 24小时 / 7天</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">每日流量限制</span>
|
||||
<span class="info-value">{{ daily_gb }} GB / IP</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">API 上传</span>
|
||||
<span class="info-value">POST /api/upload</span>
|
||||
</div>
|
||||
|
||||
<hr class="stats-divider">
|
||||
<div class="stats-title">API 调用方法 (参考 Flask 风格)</div>
|
||||
<div style="text-align: left; margin-top: 12px; padding: 12px; background: #f8f9fa; border-radius: 8px; font-size: 12px; color: #444; font-family: 'Courier New', monospace; line-height: 1.5; overflow-x: auto;">
|
||||
<strong>1. 上传文件:</strong><br>
|
||||
<code style="color: #d63384;">POST /api/upload</code><br>
|
||||
Headers: <code style="color: #0d6efd;">Content-Type: multipart/form-data</code><br>
|
||||
Form-data:<br>
|
||||
· file: <文件内容><br>
|
||||
· expiry: 1h | 24h | 7d (可选, 默认24h)<br><br>
|
||||
|
||||
<strong>2. 获取文件信息:</strong><br>
|
||||
<code style="color: #d63384;">GET /api/file/<file_id></code><br><br>
|
||||
|
||||
<strong>3. 下载/访问文件:</strong><br>
|
||||
<code style="color: #d63384;">GET /file/<file_id></code><br><br>
|
||||
|
||||
<strong>cURL 示例:</strong><br>
|
||||
<span style="color: #198754;">curl -k</span> -X POST <span style="color: #0d6efd;">"https://xiaji-temp.duckdns.org/api/upload"</span> \
|
||||
-F "file=@example.jpg" \
|
||||
-F "expiry=24h"<br><br>
|
||||
|
||||
<strong>Python requests 示例:</strong><br>
|
||||
<span style="color: #0d6efd;">import</span> requests<br><br>
|
||||
url = <span style="color: #198754;">"https://xiaji-temp.duckdns.org/api/upload"</span><br>
|
||||
files = {<span style="color: #198754;">'file'</span>: <span style="color #d63384">open</span>(<span style="color: #198754;">'example.jpg'</span>, <span style="color: #198754;">'rb'</span>)}<br>
|
||||
data = {<span style="color: #198754;">'expiry'</span>: <span style="color: #198754;">'24h'</span>}<br>
|
||||
resp = requests.post(url, files=files, data=data, verify=<span style="color: #d63384">False</span>)<br>
|
||||
<span style="color: #0d6efd;">print</span>(resp.json())
|
||||
</div>
|
||||
|
||||
<hr class="stats-divider">
|
||||
<div class="stats-title">本月统计</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">总流量</span>
|
||||
<span class="stat-value">{{ stats.total_gb }} GB</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">访问 IP 数</span>
|
||||
<span class="stat-value">{{ stats.ip_count }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">文件上传数</span>
|
||||
<span class="stat-value">{{ stats.file_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="upload-area" id="uploadArea">
|
||||
<input type="file" id="fileInput">
|
||||
<p>点击或拖拽文件到此处</p>
|
||||
|
||||
Reference in New Issue
Block a user