feat: 完整的 R2 文件管理系统初始化

- 实现 11 个 API 端点:上传、删除、重命名、复制、移动、创建文件夹等
- 构建存储抽象层,支持多后端(R2、S3、GitHub)
- 添加完整的前端界面:列表/网格视图、文件操作、批量管理
- 添加深色/浅色主题支持
- 迁移至 Jinja2 模板继承架构
- 添加完整的 API 文档和项目文档
- 优化 Vercel 部署配置
This commit is contained in:
2025-11-08 17:45:37 +08:00
parent dcdd617a68
commit b065133519
18 changed files with 4005 additions and 754 deletions

View File

@@ -1,10 +1,32 @@
# 存储类型选择
## Done
# r2 | Cloudflare R2
## TODO LIST:
# cnbcool | Tencent cnb.cool
# s3 | Amazon S3
# github-repo | GitHub Repo
STORAGE_TYPE=r2
# ==================== Cloudflare R2 配置 ====================
# R2 访问凭证
ACCESS_KEY_ID=7bd97fe81b9b0f7cd68a745a4eb3f19f
SECRET_ACCESS_KEY=dbb41f793f3ae8ae90fb60342ca09716596df3cc6c42d602215c550d3579b3f8
# R2 存储桶名称
R2_BUCKET_NAME=drive
# R2 Endpoint URL
R2_ENDPOINT_URL=https://731b927733dccabbd9210b33c3ee513e.r2.cloudflarestorage.com
# R2 区域 (默认: auto)
R2_REGION=auto
REDIS_URL=redis://localhost:6379/0
# 可选: 公共访问 URL
# R2 公共访问 URL (可选,例如: https://pub-<bucket-name>.r2.dev)
R2_PUBLIC_URL=https://pub-<bucket-name>.r2.dev
# R2 预签名 URL 过期时间(秒,默认: 3600
R2_PRESIGN_EXPIRES=3600
# ==================== 应用配置 ====================
# 缩略图缓存时间(秒,默认: 3600
THUMB_TTL_SECONDS=3600

5
.gitignore vendored
View File

@@ -28,4 +28,7 @@ env.bak/
venv.bak/
.vercel
.vercel
test*
tests/
.VSCodeCounter

38
.vercelignore Normal file
View File

@@ -0,0 +1,38 @@
# 测试文件
test_*.py
*_test.py
tests/
# 开发工具
debug/
.vscode/
.idea/
*.pyc
__pycache__/
# 文档
docs/
*.md
!README.md
# 环境配置
.env
.env.local
.env.*.local
# Git
.git/
.gitignore
# Python
venv/
env/
*.egg-info/
test/
tests/
test*
# 临时文件
*.tmp
*.log

275
README.md Normal file
View File

@@ -0,0 +1,275 @@
# R2-Index
一个支持多种云存储后端的文件管理、索引和浏览服务。
## 特性
- 📁 浏览和预览云存储中的文件
- 🖼️ 图片缩略图生成
- 🌐 公共访问 URL 支持
- 🔄 多存储后端支持(可扩展)
- ⬆️ 文件上传功能
- 🗑️ 文件删除功能
- ✏️ 文件/文件夹重命名
- 📂 创建文件夹
- 📋 文件/文件夹复制
- 🔄 文件/文件夹移动
- 📱 响应式界面,多种设备尺寸支持
- 🌙 深色模式支持
## TODO
- [] Github Repo 储存支持
- [] Github Release 储存支持
- [] 基于数据库的用户/权限管理
- [] 操作日志记录
- [] Office Documents 预览支持
- [] 视频预览支持
- [] 文件夹打包下载支持
## 支持的存储后端
- **Cloudflare R2** - Cloudflare 的对象存储服务S3 兼容)
- **Amazon S3** - Amazon S3 对象存储服务
<!-- - **Github Repo** - 基于 GitHub Repository 的存储服务 -->
## 快速开始
### 1. 克隆项目
```bash
git clone https://github.com/RhenCloud/R2-Index.git
cd R2-Index
```
### 2. 安装依赖
```bash
pip install -r requirements.txt
```
### 3. 配置环境变量
复制 `.env.example``.env` 并填写配置:
```bash
cp .env.example .env
```
### 4. 运行应用
```bash
python app.py
```
## 配置说明
### 选择存储类型
`.env` 文件中设置 `STORAGE_TYPE` 来选择存储后端:
```env
# 使用 Cloudflare R2
STORAGE_TYPE=r2
# 或使用腾讯云 cnb.cool
STORAGE_TYPE=cnbcool
```
### Cloudflare R2 配置
```env
STORAGE_TYPE=r2
# R2 访问凭证
ACCESS_KEY_ID=your_access_key_id
SECRET_ACCESS_KEY=your_secret_access_key
# R2 存储桶配置
R2_BUCKET_NAME=your_bucket_name
R2_ENDPOINT_URL=https://your-account-id.r2.cloudflarestorage.com
R2_REGION=auto
# 可选:公共访问 URL
R2_PUBLIC_URL=https://pub-your-bucket.r2.dev
# 可选:预签名 URL 过期时间(秒)
R2_PRESIGN_EXPIRES=3600
```
## 项目结构
```
r2-index/
├── app.py # Flask 应用主入口
├── handlers/
│ └── routes.py # 路由处理器
├── storages/ # 存储后端实现
│ ├── __init__.py
│ ├── base.py # 基础存储类(抽象类)
│ ├── factory.py # 存储工厂类
│ ├── r2.py # Cloudflare R2 实现
│ └── cnbcool.py # 腾讯云 cnb.cool 实现
├── templates/ # HTML 模板
│ ├── index.html
│ └── footer.html
├── static/ # 静态资源
│ └── thumbs/
├── .env.example # 环境变量示例
└── requirements.txt # Python 依赖
```
## 贡献
项目采用策略模式和工厂模式,使得添加新的存储后端变得简单:
1. **BaseStorage** - 定义存储后端的统一接口
2. **具体实现** (R2Storage, CnbCoolStorage) - 实现具体的存储逻辑
3. **StorageFactory** - 根据配置创建对应的存储实例
### 添加新的存储后端
1.`storages/` 目录下创建新的存储实现文件
2. 继承 `BaseStorage` 并实现所有抽象方法
3.`StorageFactory` 中添加对应的创建逻辑
4. 更新 `.env.example` 添加新的配置项
## API 路由
- `GET /` - 浏览根目录
- `GET /<path:prefix_path>` - 浏览指定目录
- `GET /file/<path:file_path>` - 获取文件内容
- `GET /thumb/<path:file_path>` - 获取图片缩略图
- `POST /upload` - 上传文件
- `DELETE /delete/<path:file_path>` - 删除文件
- `POST /rename/<path:old_key>` - 重命名文件
- `DELETE /delete_folder/<path:prefix>` - 删除文件夹
- `POST /rename_folder/<path:old_prefix>` - 重命名文件夹
- `POST /copy` - 复制文件或文件夹
- `POST /move` - 移动文件或文件夹
- `POST /create_folder` - 创建文件夹
详细 API 文档:[API 文档](docs/api.md)
## 部署
### Vercel 部署
项目包含 `vercel.json` 配置文件,可直接部署到 Vercel
1. 在 Vercel 中导入项目
2. 在 Vercel 项目设置中配置环境变量
3. 部署
### Docker 部署
您也可以使用 Docker 部署此应用:
```dockerfile
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
```
构建和运行:
```bash
docker build -t r2-index .
docker run -p 5000:5000 --env-file .env r2-index
```
### 本地开发
```bash
# 1. 克隆项目
git clone https://github.com/RhenCloud/R2-Index.git
cd R2-Index
# 2. 创建虚拟环境
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 3. 安装依赖
pip install -r requirements.txt
# 4. 配置环境变量
cp .env.example .env
# 编辑 .env 文件,填入你的存储配置
# 5. 运行应用
python app.py
```
应用将在 `http://localhost:5000` 启动。
## 技术栈
- **Flask** - Web 框架
- **Boto3** - AWS SDK用于 S3 兼容存储)
- **Pillow** - 图片处理
- **Python-dotenv** - 环境变量管理
## 常见问题
### Q: 如何限制上传文件大小?
A: 在 `handlers/routes.py` 中的 `upload()` 函数中添加文件大小检查:
```python
@main_route.route("/upload", methods=["POST"])
def upload():
file = request.files["file"]
# 限制文件大小为 100MB
MAX_FILE_SIZE = 100 * 1024 * 1024
if len(file.read()) > MAX_FILE_SIZE:
return jsonify({"success": False, "error": "File too large"}), 413
file.seek(0) # 重置文件指针
# ... 继续上传逻辑
```
### Q: 如何添加访问认证?
A: 参考 [API 文档](docs/api.md) 的"安全建议"部分,可以添加基于 Token 的简单认证。
### Q: 支持哪些图片格式的缩略图生成?
A: 支持 `jpg`, `jpeg`, `png`, `gif`, `bmp`, `webp`, `svg`, `ico` 等常见格式。
### Q: 如何在深色模式和浅色模式间切换?
A: 点击页面顶部的月亮/太阳图标即可切换。设置将保存在本地存储中。
### Q: 支持哪些存储后端?
A: 当前支持:
- Cloudflare R2推荐
- Amazon S3
- GitHub Repository通过 cnb.cool
### Q: 如何添加新的存储后端?
A: 参考项目结构中的"添加新的存储后端"部分,继承 `BaseStorage` 并实现所有抽象方法即可。
## 贡献指南
欢迎提交 Issue 和 Pull Request
1. Fork 项目
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 开启 Pull Request
## 许可证
MIT License

336
app.py
View File

@@ -1,24 +1,24 @@
import hashlib
import os
from datetime import datetime
from io import BytesIO
from pathlib import Path
from typing import Any, Dict, List
import boto3
from botocore.config import Config
from dotenv import load_dotenv
from flask import Flask, Response, abort, render_template, request, send_file
from PIL import Image
import dotenv
from flask import Flask
# 加载环境变量
load_dotenv()
from handlers.routes import main_route
from storages.factory import StorageFactory
app = Flask(__name__, static_url_path="/static", static_folder="static")
dotenv.load_dotenv()
app = Flask(__name__)
# 注册蓝图
app.register_blueprint(main_route)
# 初始化存储(使用工厂模式)
storage = StorageFactory.get_storage()
# 缩略图默认 TTL可通过环境变量覆盖
THUMB_TTL = int(os.getenv("THUMB_TTL_SECONDS", "3600"))
THUMB_TTL = int(os.environ.get("THUMB_TTL_SECONDS", "3600"))
# 注册一个安全的 filesizeformat 过滤器,处理 None 和非数字值
@@ -86,258 +86,25 @@ def fileicon_filter(filename):
return "fas fa-file"
def get_s3_client():
"""
创建并返回配置好的 S3 客户端,用于访问 R2 存储
"""
endpoint = os.getenv("R2_ENDPOINT_URL")
if not endpoint:
# 更明确的错误,便于调试环境变量问题
raise RuntimeError("R2_ENDPOINT_URL environment variable is not set")
# 支持常见 AWS 环境变量名AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
access_key = os.getenv("ACCESS_KEY_ID") or os.getenv("ACCESS_KEY_ID")
secret_key = os.getenv("SECRET_ACCESS_KEY") or os.getenv("SECRET_ACCESS_KEY")
if not access_key or not secret_key:
raise RuntimeError("ACCESS_KEY_ID and SECRET_ACCESS_KEY must be set")
return boto3.client(
"s3",
endpoint_url=endpoint,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
config=Config(signature_version="s3v4"),
region_name=os.getenv("R2_REGION", "auto"),
)
def get_public_url(key: str) -> str:
"""
生成对象的公共访问 URL
"""
base_url = os.getenv("R2_PUBLIC_URL")
if not base_url:
return None
return f"{base_url.rstrip('/')}/{key}"
return storage.get_public_url(key)
def format_timestamp(timestamp) -> str:
"""
格式化时间戳为人类可读的格式
"""
if isinstance(timestamp, datetime):
return timestamp.strftime("%Y-%m-%d %H:%M:%S")
return str(timestamp)
return storage.format_timestamp(timestamp)
def generate_presigned_url(
s3_client, bucket_name: str, key: str, expires: int = None
) -> str:
"""为指定对象生成 presigned URLGET"""
if expires is None:
try:
expires = int(os.getenv("R2_PRESIGN_EXPIRES", "3600"))
except Exception:
expires = 3600
try:
url = s3_client.generate_presigned_url(
"get_object", Params={"Bucket": bucket_name, "Key": key}, ExpiresIn=expires
)
return url
except Exception:
app.logger.exception("Failed to generate presigned URL for %s", key)
return None
@app.route("/")
def index():
"""
返回 R2 存储桶中的文件和目录列表的 HTML 页面。
"""
try:
s3_client = get_s3_client()
bucket_name = os.getenv("R2_BUCKET_NAME")
# 支持 prefix 查询参数(用于浏览子目录)
prefix = request.args.get("prefix", "") or ""
if prefix and not prefix.endswith("/"):
prefix = prefix + "/"
list_kwargs = {"Bucket": bucket_name, "Delimiter": "/"}
if prefix:
list_kwargs["Prefix"] = prefix
# 列出指定前缀(根或子目录)下的对象
response = s3_client.list_objects_v2(**list_kwargs)
entries: List[Dict[str, Any]] = []
if "Contents" in response:
for obj in response["Contents"]:
# 跳过等于 prefix 的条目(有时存在)
key = obj.get("Key", "")
if prefix and key == prefix:
continue
if key.endswith("/"):
continue
# 显示相对名称(去掉当前 prefix
rel_name = key[len(prefix) :] if prefix else key
entry = {
"name": rel_name,
"key": key,
"size": obj.get("Size"),
"last_modified": format_timestamp(obj.get("LastModified")),
"is_dir": False,
}
# 添加公共访问 URL如果配置了
public_url = get_public_url(key)
if public_url:
entry["public_url"] = public_url
# 添加 presigned URL优先于后端代理预览
presigned = generate_presigned_url(s3_client, bucket_name, key)
if presigned:
entry["presigned_url"] = presigned
# 通过服务器访问的文件 URL用于预览和缩略图
entry["file_url"] = get_file_url(key)
entries.append(entry)
# 添加当前前缀下的文件夹CommonPrefixes
if "CommonPrefixes" in response:
for p in response["CommonPrefixes"]:
pref = p.get("Prefix")
# 相对文件夹名
rel = pref[len(prefix) :].rstrip("/") if prefix else pref.rstrip("/")
entries.append({"name": rel, "key": pref, "is_dir": True})
# 按照类型(目录在前)和名称排序
entries.sort(key=lambda x: (not x.get("is_dir", False), x["name"]))
# 构造面包屑导航
crumbs = []
if prefix:
segs = prefix.rstrip("/").split("/")
acc = ""
for seg in segs:
acc = acc + seg + "/"
crumbs.append({"name": seg, "prefix": acc})
return render_template(
"index.html",
entries=entries,
current_prefix=prefix,
crumbs=crumbs,
current_year=datetime.now().year,
)
except Exception as e:
app.logger.exception("Error listing R2 bucket")
return render_template(
"index.html", error=str(e), current_year=datetime.now().year
)
@app.route("/<path:prefix_path>")
def browse(prefix_path):
"""漂亮的目录路由。将 URL /a/b 映射为 prefix 'a/b/' 并重用 index 的逻辑。"""
# delegate to index-like logic but with provided prefix
try:
s3_client = get_s3_client()
bucket_name = os.getenv("R2_BUCKET_NAME")
prefix = prefix_path or ""
if prefix and not prefix.endswith("/"):
prefix = prefix + "/"
list_kwargs = {"Bucket": bucket_name, "Delimiter": "/", "Prefix": prefix}
response = s3_client.list_objects_v2(**list_kwargs)
entries: List[Dict[str, Any]] = []
if "Contents" in response:
for obj in response["Contents"]:
key = obj.get("Key", "")
if prefix and key == prefix:
continue
if key.endswith("/"):
continue
rel_name = key[len(prefix) :] if prefix else key
entry = {
"name": rel_name,
"key": key,
"size": obj.get("Size"),
"last_modified": format_timestamp(obj.get("LastModified")),
"is_dir": False,
"file_url": get_file_url(key),
}
entries.append(entry)
if "CommonPrefixes" in response:
for p in response["CommonPrefixes"]:
pref = p.get("Prefix")
rel = pref[len(prefix) :].rstrip("/") if prefix else pref.rstrip("/")
entries.append({"name": rel, "key": pref, "is_dir": True})
entries.sort(key=lambda x: (not x.get("is_dir", False), x["name"]))
crumbs = []
if prefix:
segs = prefix.rstrip("/").split("/")
acc = ""
for seg in segs:
acc = acc + seg + "/"
crumbs.append({"name": seg, "prefix": acc})
return render_template(
"index.html",
entries=entries,
current_prefix=prefix,
crumbs=crumbs,
current_year=datetime.now().year,
)
except Exception as e:
app.logger.exception("Error browsing R2 bucket")
return render_template(
"index.html", error=str(e), current_year=datetime.now().year
)
@app.route("/file/<path:file_path>")
def serve_file(file_path):
"""通过服务器提供文件访问"""
try:
s3_client = get_s3_client()
bucket_name = os.getenv("R2_BUCKET_NAME")
# 获取文件的基本信息
try:
response = s3_client.head_object(Bucket=bucket_name, Key=file_path)
except s3_client.exceptions.ClientError as e:
if e.response["Error"]["Code"] == "404":
abort(404)
else:
abort(500)
# 获取文件对象
file_obj = s3_client.get_object(Bucket=bucket_name, Key=file_path)
# 设置响应头
headers = {
"Content-Type": file_obj["ContentType"],
"Content-Length": str(file_obj["ContentLength"]),
}
# 使用 Response 流式传输文件内容
return Response(
file_obj["Body"].iter_chunks(), headers=headers, direct_passthrough=True
)
except Exception as e:
app.logger.exception("Error serving file")
abort(500)
return storage.generate_presigned_url(key, expires)
def get_file_url(key: str) -> str:
@@ -345,75 +112,6 @@ def get_file_url(key: str) -> str:
return f"/file/{key}"
@app.route("/thumb/<path:file_path>")
def thumb(file_path):
"""返回图片的缩略图,使用 Vercel Cache Headers 避免重复从 R2 拉取"""
bucket_name = os.getenv("R2_BUCKET_NAME")
# 设置更长的缓存控制头以支持浏览器本地缓存
cache_headers = {
"Cache-Control": f"public, max-age={THUMB_TTL}",
"ETag": f'W/"{hashlib.md5(file_path.encode("utf-8")).hexdigest()}"',
}
# 先检查客户端是否已经有缓存版本
etag = request.headers.get("If-None-Match")
if etag and etag == cache_headers["ETag"]:
return Response(status=304, headers=cache_headers)
# 从 R2 获取原始对象并生成缩略图
try:
s3 = get_s3_client()
try:
obj = s3.get_object(Bucket=bucket_name, Key=file_path)
data = obj["Body"].read()
except Exception:
app.logger.exception("Failed to fetch object for thumb: %s", file_path)
response = send_file(
os.path.join(app.static_folder, "thumb_placeholder.svg"),
mimetype="image/svg+xml",
)
response.headers.update(cache_headers)
return response
try:
img = Image.open(BytesIO(data))
img = img.convert("RGB")
img.thumbnail((320, 320))
buf = BytesIO()
img.save(buf, "JPEG", quality=80, optimize=True)
buf.seek(0)
thumb_bytes = buf.getvalue()
response = Response(thumb_bytes, mimetype="image/jpeg")
response.headers.update(cache_headers)
return response
except Exception:
app.logger.exception("Failed to generate thumbnail for %s", file_path)
response = send_file(
os.path.join(app.static_folder, "thumb_placeholder.svg"),
mimetype="image/svg+xml",
)
response.headers.update(cache_headers)
return response
except Exception:
app.logger.exception("Unexpected error in thumb endpoint")
response = send_file(
os.path.join(app.static_folder, "thumb_placeholder.svg"),
mimetype="image/svg+xml",
)
response.headers.update(cache_headers)
return response
# 添加路由以提供Service Worker文件
@app.route("/static/sw.js")
def sw():
return send_file(
os.path.join(app.static_folder, "sw.js"), mimetype="application/javascript"
)
if __name__ == "__main__":
port = int(os.environ.get("PORT", 5000))
host = os.environ.get("HOST", "0.0.0.0")

626
docs/api.md Normal file
View File

@@ -0,0 +1,626 @@
# API 文档
Cloud Index 提供了 REST API 用于文件管理操作。
## API
### 1. 上传文件
**端点:** `POST /upload`
**描述:** 上传文件到存储
**请求:**
- Method: `POST`
- Content-Type: `multipart/form-data`
- Body:
- `file` (required): 要上传的文件
- `prefix` (optional): 目标路径前缀
**示例 (cURL):**
```bash
# 上传到根目录
curl -X POST http://localhost:5000/upload \
-F "file=@/path/to/your/file.jpg"
# 上传到指定目录
curl -X POST http://localhost:5000/upload \
-F "file=@/path/to/your/file.jpg" \
-F "prefix=images/"
```
**示例 (JavaScript):**
```javascript
const formData = new FormData();
formData.append('file', fileInput.files[0]);
formData.append('prefix', 'images/');
const response = await fetch('/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
console.log(result);
```
**响应:**
成功 (200):
```json
{
"success": true,
"message": "File uploaded successfully",
"path": "images/file.jpg"
}
```
失败 (400/500):
```json
{
"success": false,
"error": "Error message"
}
```
### 2. 删除文件
**端点:** `DELETE /delete/<path:file_path>`
**描述:** 删除存储中的文件
**请求:**
- Method: `DELETE``POST`
- Path Parameter:
- `file_path`: 要删除的文件路径
**示例 (cURL):**
```bash
# 使用 DELETE 方法
curl -X DELETE http://localhost:5000/delete/images/file.jpg
# 使用 POST 方法(某些环境不支持 DELETE
curl -X POST http://localhost:5000/delete/images/file.jpg
```
**示例 (JavaScript):**
```javascript
const response = await fetch('/delete/images/file.jpg', {
method: 'DELETE'
});
const result = await response.json();
console.log(result);
```
**响应:**
成功 (200):
```json
{
"success": true,
"message": "File deleted successfully"
}
```
失败 (500):
```json
{
"success": false,
"error": "Error message"
}
```
### 3. 列出文件
**端点:** `GET /``GET /<path:prefix_path>`
**描述:** 列出指定路径下的文件和目录
**请求:**
- Method: `GET`
- Query Parameters:
- `prefix` (optional): 路径前缀
**示例:**
```bash
# 列出根目录
curl http://localhost:5000/
# 列出指定目录
curl http://localhost:5000/images/
# 使用查询参数
curl http://localhost:5000/?prefix=images/
```
### 4. 获取文件
**端点:** `GET /file/<path:file_path>`
**描述:** 获取文件内容或重定向到预签名 URL大文件
**请求:**
- Method: `GET`
- Path Parameter:
- `file_path`: 文件路径
**示例:**
```bash
curl http://localhost:5000/file/images/photo.jpg
```
**响应:**
- 小文件 (< 6MB): 直接返回文件内容
- 大文件 (>= 6MB): 302 重定向到预签名 URL
### 5. 获取缩略图
**端点:** `GET /thumb/<path:file_path>`
**描述:** 获取图片的缩略图320x320 JPEG
**请求:**
- Method: `GET`
- Path Parameter:
- `file_path`: 图片文件路径
**示例:**
```bash
curl http://localhost:5000/thumb/images/photo.jpg
```
**响应:**
- Content-Type: `image/jpeg`
- Body: 缩略图数据
### 6. 重命名文件
**端点:** `POST /rename/<path:old_key>`
**描述:** 重命名文件
**请求:**
- Method: `POST`
- Content-Type: `application/json`
- Path Parameter:
- `old_key`: 旧的文件路径
- Body:
- `newName`: 新的文件名
**示例 (cURL):**
```bash
curl -X POST http://localhost:5000/rename/images/photo.jpg \
-H "Content-Type: application/json" \
-d '{"newName":"photo_new.jpg"}'
```
**示例 (JavaScript):**
```javascript
const response = await fetch('/rename/images/photo.jpg', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({newName: 'photo_new.jpg'})
});
const result = await response.json();
console.log(result);
```
**响应:**
成功 (200):
```json
{
"success": true,
"message": "File renamed successfully",
"newKey": "images/photo_new.jpg"
}
```
失败 (400/500):
```json
{
"success": false,
"error": "Error message"
}
```
### 7. 删除文件夹
**端点:** `DELETE /delete_folder/<path:prefix>`
**描述:** 删除文件夹及其所有内容
**请求:**
- Method: `DELETE`
- Path Parameter:
- `prefix`: 文件夹路径
**示例 (cURL):**
```bash
curl -X DELETE http://localhost:5000/delete_folder/images
```
**示例 (JavaScript):**
```javascript
const response = await fetch('/delete_folder/images', {
method: 'DELETE'
});
const result = await response.json();
console.log(result);
```
**响应:**
成功 (200):
```json
{
"success": true,
"message": "Folder deleted successfully"
}
```
失败 (500):
```json
{
"success": false,
"error": "Error message"
}
```
### 8. 重命名文件夹
**端点:** `POST /rename_folder/<path:old_prefix>`
**描述:** 重命名文件夹
**请求:**
- Method: `POST`
- Content-Type: `application/json`
- Path Parameter:
- `old_prefix`: 旧的文件夹路径
- Body:
- `newName`: 新的文件夹名
**示例 (cURL):**
```bash
curl -X POST http://localhost:5000/rename_folder/old_folder \
-H "Content-Type: application/json" \
-d '{"newName":"new_folder"}'
```
**示例 (JavaScript):**
```javascript
const response = await fetch('/rename_folder/old_folder', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({newName: 'new_folder'})
});
const result = await response.json();
console.log(result);
```
**响应:**
成功 (200):
```json
{
"success": true,
"message": "Folder renamed successfully",
"newPrefix": "new_folder/"
}
```
失败 (500):
```json
{
"success": false,
"error": "Error message"
}
```
### 9. 复制文件或文件夹
**端点:** `POST /copy`
**描述:** 复制文件或文件夹到另一位置
**请求:**
- Method: `POST`
- Content-Type: `application/json`
- Body:
- `source`: 源文件或文件夹路径
- `destination`: 目标文件或文件夹路径
- `is_folder`: 是否为文件夹true/false
**示例 (cURL):**
```bash
# 复制文件
curl -X POST http://localhost:5000/copy \
-H "Content-Type: application/json" \
-d '{"source":"images/photo.jpg","destination":"backup/photo.jpg","is_folder":false}'
# 复制文件夹
curl -X POST http://localhost:5000/copy \
-H "Content-Type: application/json" \
-d '{"source":"images/","destination":"backup/images/","is_folder":true}'
```
**示例 (JavaScript):**
```javascript
const response = await fetch('/copy', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
source: 'images/photo.jpg',
destination: 'backup/photo.jpg',
is_folder: false
})
});
const result = await response.json();
console.log(result);
```
**响应:**
成功 (200):
```json
{
"success": true,
"message": "Item copied successfully"
}
```
失败 (400/500):
```json
{
"success": false,
"error": "Error message"
}
```
### 10. 移动文件或文件夹
**端点:** `POST /move`
**描述:** 移动(重命名路径)文件或文件夹到另一位置
**请求:**
- Method: `POST`
- Content-Type: `application/json`
- Body:
- `source`: 源文件或文件夹路径
- `destination`: 目标文件或文件夹路径
- `is_folder`: 是否为文件夹true/false
**示例 (cURL):**
```bash
# 移动文件
curl -X POST http://localhost:5000/move \
-H "Content-Type: application/json" \
-d '{"source":"images/photo.jpg","destination":"archive/photo.jpg","is_folder":false}'
# 移动文件夹
curl -X POST http://localhost:5000/move \
-H "Content-Type: application/json" \
-d '{"source":"images/","destination":"archive/images/","is_folder":true}'
```
**示例 (JavaScript):**
```javascript
const response = await fetch('/move', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
source: 'images/photo.jpg',
destination: 'archive/photo.jpg',
is_folder: false
})
});
const result = await response.json();
console.log(result);
```
**响应:**
成功 (200):
```json
{
"success": true,
"message": "Item moved successfully"
}
```
失败 (400/500):
```json
{
"success": false,
"error": "Error message"
}
```
### 11. 创建文件夹
**端点:** `POST /create_folder`
**描述:** 创建新文件夹
**请求:**
- Method: `POST`
- Content-Type: `application/json`
- Body:
- `path`: 新文件夹的路径
**示例 (cURL):**
```bash
curl -X POST http://localhost:5000/create_folder \
-H "Content-Type: application/json" \
-d '{"path":"new_folder/subfolder"}'
```
**示例 (JavaScript):**
```javascript
const response = await fetch('/create_folder', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path: 'new_folder/subfolder'})
});
const result = await response.json();
console.log(result);
```
**响应:**
成功 (200):
```json
{
"success": true,
"message": "Folder created successfully"
}
```
失败 (400/500):
```json
{
"success": false,
"error": "Error message"
}
```
## 错误代码
- `400 Bad Request`: 请求参数错误或缺少必要参数
- `404 Not Found`: 文件或文件夹不存在
- `413 Payload Too Large`: 文件太大(超过 6MB 且无预签名 URL
- `500 Internal Server Error`: 服务器内部错误
## 功能特性
### 文件操作
- ✅ 上传文件
- ✅ 下载文件(直接下载或预签名 URL
- ✅ 删除文件
- ✅ 重命名文件
- ✅ 复制文件
- ✅ 移动文件
### 文件夹操作
- ✅ 创建文件夹
- ✅ 删除文件夹(包含其所有内容)
- ✅ 重命名文件夹
- ✅ 复制文件夹(包含其所有内容)
- ✅ 移动文件夹(包含其所有内容)
- ✅ 列出文件夹内容
### 其他功能
- ✅ 生成缩略图(图片文件)
- ✅ 批量文件操作
- ✅ 预签名 URL用于大文件直接访问
## 批量操作
### 批量上传
前端可以遍历多个文件并依次调用上传 API
```javascript
async function uploadMultipleFiles(files) {
for (let file of files) {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
console.log(`${file.name}: ${result.success ? 'success' : 'failed'}`);
}
}
```
## 限制说明
### Cloudflare R2免费套餐
- 总文件大小: 最多 10GB
- API 请求频率: 根据您的 R2 套餐
<!-- ### Github
- 单文件大小: 最大 100MB建议 < 50MB
- API 请求频率:
- 有 Token: 5000 请求/小时
- 无 Token: 60 请求/小时
- Repository 总大小: 建议 < 1GB -->
## 安全建议
1. **添加认证**: 在生产环境中为所有 API 端点添加认证
2. **文件类型验证**: 验证上传文件的类型和扩展名,防止恶意文件上传
3. **文件大小限制**: 在应用层限制上传文件的大小
4. **路径验证**: 验证文件和文件夹路径,防止目录遍历攻击
5. **速率限制**: 防止滥用 API实施请求频率限制
6. **CORS 配置**: 正确配置跨域访问策略
7. **日志记录**: 记录所有文件操作以用于审计
8. **访问控制**: 根据用户身份实施细粒度的访问控制
9. **加密**: 对敏感数据进行加密存储和传输
10. **备份**: 定期备份存储中的重要数据

482
handlers/routes.py Normal file
View File

@@ -0,0 +1,482 @@
import hashlib
import os
from datetime import datetime
from typing import Any, Dict, List
from flask import (
Blueprint,
Response,
abort,
jsonify,
redirect,
render_template,
request,
)
from storages.factory import StorageFactory
main_route = Blueprint("main", __name__)
# 初始化存储(使用工厂模式)
storage = StorageFactory.get_storage()
# 缩略图默认 TTL可通过环境变量覆盖
THUMB_TTL = int(os.environ.get("THUMB_TTL_SECONDS", "3600"))
def format_timestamp(timestamp) -> str:
"""
格式化时间戳为人类可读的格式
"""
return storage.format_timestamp(timestamp)
def get_public_url(key: str) -> str:
"""
生成对象的公共访问 URL
"""
return storage.get_public_url(key)
def get_file_url(key: str) -> str:
"""生成通过服务器访问文件的 URL"""
return f"/file/{key}"
def build_file_entry(obj: Dict[str, Any], prefix: str) -> Dict[str, Any] | None:
"""根据对象信息构建文件条目。"""
key = obj.get("Key", "")
if not key:
return None
if prefix and key == prefix:
return None
if key.endswith("/"):
return None
rel_name = key[len(prefix) :] if prefix else key
entry: Dict[str, Any] = {
"name": rel_name,
"key": key,
"size": obj.get("Size"),
"last_modified": format_timestamp(obj.get("LastModified")),
"is_dir": False,
"file_url": get_file_url(key),
}
public_url = get_public_url(key)
if public_url:
entry["public_url"] = public_url
presigned = storage.generate_presigned_url(key)
if presigned:
entry["presigned_url"] = presigned
return entry
def build_directory_entry(
prefix_value: str | None, current_prefix: str
) -> Dict[str, Any] | None:
"""根据前缀构建目录条目。"""
if not prefix_value:
return None
rel = (
prefix_value[len(current_prefix) :].rstrip("/")
if current_prefix
else prefix_value.rstrip("/")
)
return {"name": rel, "key": prefix_value, "is_dir": True}
def build_entries(response: Dict[str, Any], prefix: str) -> List[Dict[str, Any]]:
"""将存储响应转换为用于模板渲染的条目列表。"""
entries: List[Dict[str, Any]] = []
for obj in response.get("Contents", []):
entry = build_file_entry(obj, prefix)
if entry:
entries.append(entry)
for pref in response.get("CommonPrefixes", []):
directory_entry = build_directory_entry(pref.get("Prefix"), prefix)
if directory_entry:
entries.append(directory_entry)
entries.sort(key=lambda x: (not x.get("is_dir", False), x["name"]))
return entries
def build_crumbs(prefix: str) -> List[Dict[str, str]]:
"""根据当前前缀构建面包屑导航数据。"""
crumbs: List[Dict[str, str]] = []
if prefix:
segs = prefix.rstrip("/").split("/")
acc = ""
for seg in segs:
acc = acc + seg + "/"
crumbs.append({"name": seg, "prefix": acc})
return crumbs
@main_route.route("/")
def index():
"""
返回文件和目录列表的 HTML 页面。
"""
try:
prefix = request.args.get("prefix", "") or ""
response = storage.list_objects(prefix)
entries = build_entries(response, prefix)
crumbs = build_crumbs(prefix)
return render_template(
"index.html",
entries=entries,
current_prefix=prefix,
crumbs=crumbs,
current_year=datetime.now().year,
)
except Exception:
abort(500)
@main_route.route("/<path:prefix_path>")
def browse(prefix_path):
"""目录路由。将 URL /a/b 映射为 prefix 'a/b/' 并重用 index 的逻辑。"""
try:
prefix = prefix_path or ""
if prefix and not prefix.endswith("/"):
prefix = prefix + "/"
response = storage.list_objects(prefix)
entries = build_entries(response, prefix)
crumbs = build_crumbs(prefix)
return render_template(
"index.html",
entries=entries,
current_prefix=prefix,
crumbs=crumbs,
current_year=datetime.now().year,
)
except Exception:
abort(500)
@main_route.route("/file/<path:file_path>")
def serve_file(file_path):
"""通过服务器提供文件访问"""
try:
# 获取文件的基本信息,验证文件存在并检查大小
try:
info = storage.get_object_info(file_path)
except Exception:
abort(404)
size = int(info.get("ContentLength", 0) or 0)
limit = 6 * 1024 * 1024 # 6 MB
# 如果文件较大,避免通过 Serverless 函数传输,返回预签名 URL 的重定向
if size > limit:
presigned = storage.generate_presigned_url(file_path)
if presigned:
return redirect(presigned)
# 如果没有预签名 URL则返回 413Payload Too Large
abort(413)
# 小文件:直接从存储获取并通过 Response 返回(流式)
file_obj = storage.get_object(file_path)
headers = {
"Content-Type": file_obj.get("ContentType", "application/octet-stream"),
"Content-Length": str(file_obj.get("ContentLength", 0)),
}
return Response(
file_obj["Body"].iter_chunks(), headers=headers, direct_passthrough=True
)
except Exception:
abort(500)
@main_route.route("/thumb/<path:file_path>")
def thumb(file_path):
"""返回图片的缩略图,使用 Vercel Cache Headers 避免重复从 R2 拉取"""
# 设置更长的缓存控制头以支持浏览器本地缓存
cache_headers = {
"Cache-Control": f"public, max-age={THUMB_TTL}",
"ETag": f'W/"{hashlib.md5(file_path.encode("utf-8")).hexdigest()}"',
}
# 先检查客户端是否已经有缓存版本
etag = request.headers.get("If-None-Match")
if etag and etag == cache_headers["ETag"]:
return Response(status=304, headers=cache_headers)
# 从获取原始对象并生成缩略图
try:
# 对于较大的源文件,避免在函数内读取并处理,优先使用预签名 URL
try:
info = storage.get_object_info(file_path)
except Exception:
abort(404)
size = int(info.get("ContentLength", 0) or 0)
limit = 6 * 1024 * 1024
if size > limit:
presigned = storage.generate_presigned_url(file_path)
if presigned:
return redirect(presigned)
abort(413)
thumb_bytes = storage.generate_thumbnail(file_path)
response = Response(thumb_bytes, mimetype="image/jpeg")
response.headers.update(cache_headers)
return response
except Exception:
abort(404)
@main_route.route("/upload", methods=["POST"])
def upload():
"""上传文件到存储"""
try:
# 检查是否有文件
if "file" not in request.files:
return jsonify({"success": False, "error": "No file provided"}), 400
file = request.files["file"]
# 检查文件名
if file.filename == "":
return jsonify({"success": False, "error": "No file selected"}), 400
# 获取目标路径(可选)
prefix = request.form.get("prefix", "")
if prefix and not prefix.endswith("/"):
prefix = prefix + "/"
# 构建完整的文件路径
file_path = prefix + file.filename
# 读取文件数据
file_data = file.read()
# 获取文件类型
content_type = file.content_type
# 上传文件
success = storage.upload_file(file_path, file_data, content_type)
if success:
return jsonify(
{
"success": True,
"message": "File uploaded successfully",
"path": file_path,
}
)
else:
return jsonify({"success": False, "error": "Upload failed"}), 500
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@main_route.route("/delete/<path:file_path>", methods=["DELETE", "POST"])
def delete(file_path):
"""删除存储中的文件"""
try:
# 删除文件
success = storage.delete_file(file_path)
if success:
return jsonify({"success": True, "message": "File deleted successfully"})
else:
return jsonify({"success": False, "error": "Delete failed"}), 500
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@main_route.route("/rename/<path:old_key>", methods=["POST"])
def rename(old_key):
"""重命名存储中的文件"""
try:
data = request.get_json()
new_name = data.get("newName")
if not new_name:
return jsonify({"success": False, "error": "New name not provided"}), 400
# 构建新的文件路径
prefix = os.path.dirname(old_key)
if prefix:
new_key = f"{prefix}/{new_name}"
else:
new_key = new_name
# 重命名文件
success = storage.rename_file(old_key, new_key)
if success:
return jsonify(
{
"success": True,
"message": "File renamed successfully",
"newKey": new_key,
}
)
else:
return jsonify({"success": False, "error": "Rename failed"}), 500
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@main_route.route("/delete_folder/<path:prefix>", methods=["DELETE"])
def delete_folder_route(prefix):
"""删除存储中的文件夹"""
try:
if not prefix.endswith("/"):
prefix += "/"
success = storage.delete_folder(prefix)
if success:
return jsonify({"success": True, "message": "Folder deleted successfully"})
else:
return jsonify({"success": False, "error": "Folder delete failed"}), 500
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@main_route.route("/rename_folder/<path:old_prefix>", methods=["POST"])
def rename_folder_route(old_prefix):
"""重命名存储中的文件夹"""
try:
data = request.get_json()
new_name = data.get("newName")
if not new_name:
return jsonify({"success": False, "error": "New name not provided"}), 400
if not old_prefix.endswith("/"):
old_prefix += "/"
# 构建新的文件夹路径
parent_prefix = os.path.dirname(os.path.dirname(old_prefix))
if parent_prefix:
new_prefix = f"{parent_prefix}/{new_name}/"
else:
new_prefix = f"{new_name}/"
success = storage.rename_folder(old_prefix, new_prefix)
if success:
return jsonify(
{
"success": True,
"message": "Folder renamed successfully",
"newPrefix": new_prefix,
}
)
else:
return jsonify({"success": False, "error": "Folder rename failed"}), 500
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@main_route.route("/copy", methods=["POST"])
def copy_item():
"""复制文件或文件夹"""
try:
data = request.get_json()
source = data.get("source")
destination = data.get("destination")
is_folder = data.get("is_folder", False)
if not source or not destination:
return (
jsonify(
{"success": False, "error": "Source or destination not provided"}
),
400,
)
if is_folder:
if not source.endswith("/"):
source += "/"
if not destination.endswith("/"):
destination += "/"
success = storage.copy_folder(source, destination)
else:
success = storage.copy_file(source, destination)
if success:
return jsonify({"success": True, "message": "Item copied successfully"})
else:
return jsonify({"success": False, "error": "Copy failed"}), 500
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@main_route.route("/move", methods=["POST"])
def move_item():
"""移动文件或文件夹"""
try:
data = request.get_json()
source = data.get("source")
destination = data.get("destination")
is_folder = data.get("is_folder", False)
if not source or not destination:
return (
jsonify(
{"success": False, "error": "Source or destination not provided"}
),
400,
)
if is_folder:
if not source.endswith("/"):
source += "/"
if not destination.endswith("/"):
destination += "/"
success = storage.rename_folder(source, destination)
else:
success = storage.rename_file(source, destination)
if success:
return jsonify({"success": True, "message": "Item moved successfully"})
else:
return jsonify({"success": False, "error": "Move failed"}), 500
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@main_route.route("/create_folder", methods=["POST"])
def create_folder_route():
"""创建文件夹"""
try:
data = request.get_json()
path = data.get("path")
if not path:
return jsonify({"success": False, "error": "Path not provided"}), 400
if not path.endswith("/"):
path += "/"
success = storage.create_folder(path)
if success:
return jsonify({"success": True, "message": "Folder created successfully"})
else:
return jsonify({"success": False, "error": "Folder creation failed"}), 500
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
flask>=2.3.0
boto3>=1.28.0
python-dotenv>=1.0.0
Pillow>=10.0.0
botocore>=1.31.0
requests>=2.31.0

705
static/css/main.css Normal file
View File

@@ -0,0 +1,705 @@
:root {
--bg-color: #f5f5f5;
--container-bg: white;
--text-color: #2c3e50;
--border-color: #eee;
--hover-bg: #f8f9fa;
--secondary-text: #6c757d;
--link-color: #007bff;
--folder-color: #ffc107;
--file-color: #6c757d;
--shadow-color: rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] {
--bg-color: #1a1a1a;
--container-bg: #2d2d2d;
--text-color: #e1e1e1;
--border-color: #404040;
--hover-bg: #363636;
--secondary-text: #a0a0a0;
--link-color: #66b3ff;
--folder-color: #ffd54f;
--file-color: #b0b0b0;
--shadow-color: rgba(0, 0, 0, 0.3);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: var(--bg-color);
color: var(--text-color);
transition: background-color 0.3s ease, color 0.3s ease;
}
.container {
background-color: var(--container-bg);
border-radius: 8px;
box-shadow: 0 2px 4px var(--shadow-color);
padding: 20px;
}
h1 {
color: var(--text-color);
margin-bottom: 30px;
border-bottom: 2px solid var(--border-color);
padding-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
/* 通用按钮样式 */
.btn {
background: none;
border: none;
cursor: pointer;
padding: 8px;
border-radius: 6px;
font-size: 1em;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn:hover {
background-color: var(--hover-bg);
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
}
/* 导航栏按钮 */
.nav-btn {
color: var(--secondary-text);
font-size: 1.1em;
padding: 8px 10px;
}
.nav-btn:hover {
color: var(--text-color);
}
/* 兼容旧的类名 */
.theme-toggle,
.view-toggle {
background: none;
border: none;
color: var(--secondary-text);
cursor: pointer;
padding: 8px 10px;
border-radius: 6px;
font-size: 1.1em;
transition: all 0.2s ease;
}
.theme-toggle:hover,
.view-toggle:hover {
background-color: var(--hover-bg);
color: var(--text-color);
transform: translateY(-1px);
}
.theme-toggle:active,
.view-toggle:active {
transform: translateY(0);
}
/* 导航栏删除按钮特殊样式 */
.view-toggle[style*="color: #dc3545"]:hover {
color: #c82333 !important;
background-color: rgba(220, 53, 69, 0.1);
}
/* Grid view styles */
.grid-container {
display: none;
margin-top: 20px;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
.grid-card {
background: var(--container-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px;
text-align: center;
box-shadow: 0 1px 2px var(--shadow-color);
}
.grid-checkbox {
text-align: left;
margin-bottom: 8px;
}
.grid-checkbox input {
transform: scale(1.05);
accent-color: var(--link-color);
}
.grid-thumb {
width: 100%;
height: 100px;
object-fit: cover;
border-radius: 6px;
margin-bottom: 8px;
background-color: #f0f0f0;
}
.grid-name {
display: block;
font-size: 0.95em;
color: var(--text-color);
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 网格卡片操作按钮 */
.grid-actions {
display: flex;
gap: 4px;
justify-content: center;
margin-top: 8px;
}
.grid-action-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px 6px;
border-radius: 4px;
font-size: 0.85em;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 3px;
}
.grid-action-btn.preview {
color: var(--link-color);
}
.grid-action-btn.preview:hover {
background-color: rgba(0, 123, 255, 0.1);
}
.grid-action-btn.download {
color: #28a745;
}
.grid-action-btn.download:hover {
background-color: rgba(40, 167, 69, 0.1);
}
.grid-action-btn.delete {
color: #dc3545;
}
.grid-action-btn.delete:hover {
background-color: rgba(220, 53, 69, 0.1);
}
/* Toggle visibility based on data-view on root */
:root[data-view="grid"] .files-table {
display: none;
}
:root[data-view="grid"] .grid-container {
display: grid;
}
.files-table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
.checkbox-col {
width: 48px;
text-align: center;
}
.checkbox-col input {
transform: scale(1.05);
accent-color: var(--link-color);
}
.files-table th,
.files-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.files-table th {
background-color: var(--hover-bg);
color: var(--text-color);
font-weight: 600;
}
.files-table tr:hover {
background-color: var(--hover-bg);
}
.file-icon {
margin-right: 8px;
}
.folder {
color: var(--folder-color);
}
.file {
color: var(--file-color);
}
a {
color: var(--link-color);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.file-size {
color: var(--secondary-text);
font-size: 0.9em;
}
.last-modified {
color: var(--secondary-text);
font-size: 0.9em;
}
.empty-message {
text-align: center;
padding: 40px;
color: var(--secondary-text);
}
.breadcrumb {
margin-bottom: 12px;
font-size: 0.95em;
color: var(--secondary-text);
background-color: var(--container-bg);
padding: 8px 12px;
border-radius: 4px;
}
/* 页脚样式 */
.site-footer {
margin-top: 20px;
text-align: center;
color: var(--secondary-text);
font-size: 0.9em;
padding: 14px 12px;
}
.site-footer a {
color: var(--link-color);
text-decoration: none;
margin: 0 6px;
}
.site-footer a:hover {
text-decoration: underline;
}
@media (max-width: 600px) {
.site-footer {
font-size: 0.85em;
padding: 12px 8px;
}
}
/* 文件上传输入框 */
.upload-input {
display: none;
}
/* 删除按钮 */
.delete-btn {
background: none;
border: none;
color: #dc3545;
cursor: pointer;
padding: 6px 10px;
border-radius: 6px;
font-size: 0.9em;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 4px;
}
.delete-btn:hover {
background-color: rgba(220, 53, 69, 0.1);
color: #c82333;
transform: translateY(-1px);
}
.delete-btn:active {
transform: translateY(0);
}
/* 操作链接按钮 */
.action-link {
color: var(--link-color);
text-decoration: none;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 4px;
}
.action-link:hover {
background-color: var(--hover-bg);
text-decoration: none;
}
/* 预览按钮button标签样式 */
button.action-link {
border: none;
background: none;
cursor: pointer;
font-size: 0.9em;
font-family: inherit;
}
/* 下载按钮 */
.download-btn {
background: none;
border: none;
color: #28a745;
cursor: pointer;
padding: 6px 10px;
border-radius: 6px;
font-size: 0.9em;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 4px;
}
.download-btn:hover {
background-color: rgba(40, 167, 69, 0.1);
color: #218838;
transform: translateY(-1px);
}
.download-btn:active {
transform: translateY(0);
}
/* 预览模态框 */
.preview-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.9);
z-index: 1000;
opacity: 0;
transition: opacity 0.3s ease;
}
.preview-modal.show {
display: flex;
opacity: 1;
justify-content: center;
align-items: center;
}
.preview-container {
position: relative;
max-width: 90%;
max-height: 90%;
display: flex;
flex-direction: column;
align-items: center;
}
.preview-content {
max-width: 100%;
max-height: 85vh;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
}
.preview-controls {
position: absolute;
top: 20px;
right: 20px;
display: flex;
gap: 10px;
z-index: 1001;
}
.preview-btn {
background-color: rgba(255, 255, 255, 0.9);
border: none;
color: #333;
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
font-size: 1.2em;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.preview-btn:hover {
background-color: white;
transform: scale(1.1);
}
.preview-info {
margin-top: 15px;
color: white;
text-align: center;
font-size: 0.95em;
background-color: rgba(0, 0, 0, 0.7);
padding: 10px 20px;
border-radius: 20px;
max-width: 80%;
word-break: break-all;
}
.preview-loading {
color: white;
font-size: 1.2em;
text-align: center;
}
.preview-error {
color: #ff6b6b;
font-size: 1.1em;
text-align: center;
padding: 20px;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
video.preview-content {
background-color: #000;
}
@media (max-width: 768px) {
.preview-container {
max-width: 95%;
}
.preview-controls {
top: 10px;
right: 10px;
}
.preview-btn {
width: 35px;
height: 35px;
font-size: 1em;
}
.preview-info {
font-size: 0.85em;
padding: 8px 15px;
}
}
/* 上传进度提示 */
.upload-status {
padding: 10px;
border-radius: 4px;
margin-bottom: 10px;
display: none;
}
.upload-status.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
display: block;
}
.upload-status.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
display: block;
}
/* 自定义对话框样式 */
.app-dialog[hidden] {
display: none;
}
.app-dialog {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(2px);
z-index: 1100;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.app-dialog.is-visible {
opacity: 1;
pointer-events: auto;
}
.app-dialog__backdrop {
position: absolute;
inset: 0;
}
.app-dialog__panel {
position: relative;
background-color: var(--container-bg);
color: var(--text-color);
border-radius: 12px;
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.25);
padding: 24px;
min-width: 320px;
max-width: min(420px, 90vw);
transform: translateY(20px);
transition: transform 0.2s ease;
}
.app-dialog.is-visible .app-dialog__panel {
transform: translateY(0);
}
.app-dialog__title {
margin: 0 0 12px;
font-size: 1.15em;
font-weight: 600;
}
.app-dialog__message {
margin-bottom: 18px;
line-height: 1.5;
color: var(--text-color);
word-break: break-word;
}
.app-dialog__input {
margin-bottom: 18px;
display: block;
width: 100%;
}
.app-dialog__input input {
width: 100%;
padding: 10px 12px;
border-radius: 6px;
border: 1px solid var(--border-color);
background-color: var(--container-bg);
color: var(--text-color);
font-size: 0.95em;
box-sizing: border-box;
font-family: inherit;
}
.app-dialog__input input::placeholder {
color: var(--secondary-text);
}
.app-dialog__input input:focus {
outline: none;
border-color: var(--link-color);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.15);
}
.app-dialog__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.app-dialog__btn {
padding: 8px 16px;
border-radius: 6px;
border: none;
cursor: pointer;
font-size: 0.95em;
transition: transform 0.15s ease, background-color 0.2s ease;
}
.app-dialog__btn:hover {
transform: translateY(-1px);
}
.app-dialog__cancel {
background-color: var(--hover-bg);
color: var(--secondary-text);
}
.app-dialog__cancel:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.app-dialog__confirm {
background-color: #dc3545;
color: #fff;
}
.app-dialog__confirm:hover {
background-color: #c82333;
}
@media (max-width: 480px) {
.app-dialog__panel {
width: calc(100vw - 40px);
padding: 20px;
}
}
.action-link:hover,
.grid-action-btn:hover {
color: var(--link-hover-color);
background-color: var(--hover-bg-color);
}
#deleteTrigger,
.delete-btn,
.grid-action-btn.delete {
color: var(--danger-color, #dc3545);
}
#deleteTrigger:hover,
.delete-btn:hover,
.grid-action-btn.delete:hover {
color: var(--danger-hover-color, #a0202d);
}
.rename-btn,
.grid-action-btn.rename {
color: var(--warning-color, #ffc107);
}
.rename-btn:hover,
.grid-action-btn.rename:hover {
color: var(--warning-hover-color, #e0a800);
}

947
static/js/main.js Normal file
View File

@@ -0,0 +1,947 @@
(function () {
function updateStatus(message, state) {
const statusDiv = document.getElementById("uploadStatus");
if (!statusDiv) {
return null;
}
statusDiv.textContent = message;
statusDiv.className = "upload-status" + (state ? ` ${state}` : "");
statusDiv.style.display = "block";
return statusDiv;
}
function hideStatusLater(statusDiv, delay = 2000) {
if (!statusDiv) {
return;
}
setTimeout(() => {
statusDiv.style.display = "none";
}, delay);
}
const dialogState = {
container: null,
title: null,
message: null,
inputWrapper: null,
input: null,
confirmBtn: null,
cancelBtn: null,
resolve: null,
options: null,
previousActiveElement: null,
};
function isDialogOpen() {
return Boolean(dialogState.container && !dialogState.container.hasAttribute("hidden"));
}
function closeDialog(confirmed) {
if (!dialogState.resolve || !dialogState.container) {
return;
}
const showInput = dialogState.options?.showInput;
const value = confirmed && showInput && dialogState.input ? dialogState.input.value : undefined;
dialogState.container.classList.remove("is-visible");
dialogState.container.setAttribute("aria-hidden", "true");
window.setTimeout(() => {
if (!dialogState.container.classList.contains("is-visible")) {
dialogState.container.setAttribute("hidden", "");
}
}, 200);
if (dialogState.inputWrapper) {
dialogState.inputWrapper.hidden = true;
}
const resolve = dialogState.resolve;
dialogState.resolve = null;
const options = dialogState.options || {};
dialogState.options = null;
if (dialogState.previousActiveElement && typeof dialogState.previousActiveElement.focus === "function") {
dialogState.previousActiveElement.focus({ preventScroll: true });
}
dialogState.previousActiveElement = null;
resolve({
confirmed,
value: value !== undefined ? value : undefined,
options,
});
}
function openDialog(options) {
if (!dialogState.container) {
return Promise.resolve({ confirmed: false });
}
return new Promise((resolve) => {
dialogState.resolve = resolve;
dialogState.options = options;
dialogState.previousActiveElement =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
dialogState.container.removeAttribute("hidden");
dialogState.container.setAttribute("aria-hidden", "false");
if (dialogState.title) {
dialogState.title.textContent = options.title || "";
dialogState.title.hidden = !options.title;
}
if (dialogState.message) {
dialogState.message.textContent = options.message || "";
}
if (dialogState.inputWrapper && dialogState.input) {
dialogState.inputWrapper.hidden = !options.showInput;
dialogState.input.value = options.defaultValue || "";
dialogState.input.placeholder = options.placeholder || "";
}
if (dialogState.confirmBtn) {
dialogState.confirmBtn.textContent = options.confirmLabel || "确定";
}
if (dialogState.cancelBtn) {
dialogState.cancelBtn.textContent = options.cancelLabel || "取消";
dialogState.cancelBtn.hidden = options.hideCancel || false;
}
window.requestAnimationFrame(() => {
if (!dialogState.container) {
return;
}
dialogState.container.classList.add("is-visible");
if (options.showInput && dialogState.input) {
dialogState.input.focus();
dialogState.input.select();
} else if (dialogState.confirmBtn) {
dialogState.confirmBtn.focus();
}
});
});
}
function initDialog() {
const container = document.getElementById("appDialog");
if (!container || container.dataset.initialized === "true") {
return;
}
container.dataset.initialized = "true";
dialogState.container = container;
dialogState.title = document.getElementById("appDialogTitle");
dialogState.message = document.getElementById("appDialogMessage");
dialogState.inputWrapper = document.getElementById("appDialogInputWrapper");
dialogState.input = document.getElementById("appDialogInput");
dialogState.confirmBtn = document.getElementById("appDialogConfirm");
dialogState.cancelBtn = document.getElementById("appDialogCancel");
if (dialogState.confirmBtn) {
dialogState.confirmBtn.addEventListener("click", () => closeDialog(true));
}
if (dialogState.cancelBtn) {
dialogState.cancelBtn.addEventListener("click", () => closeDialog(false));
}
container.addEventListener("click", (event) => {
if (
event.target === container ||
(event.target instanceof HTMLElement && event.target.dataset.dialogDismiss === "true")
) {
closeDialog(false);
}
});
document.addEventListener("keydown", (event) => {
if (!isDialogOpen()) {
return;
}
if (event.key === "Escape") {
event.preventDefault();
closeDialog(false);
return;
}
if (event.key === "Enter" && dialogState.options?.showInput) {
const active = document.activeElement;
if (active === dialogState.input) {
event.preventDefault();
closeDialog(true);
}
}
});
}
function showConfirm(message, options = {}) {
return openDialog({
title: options.title || "确认操作",
message,
confirmLabel: options.confirmLabel || "确定",
cancelLabel: options.cancelLabel || "取消",
hideCancel: options.hideCancel || false,
}).then((result) => Boolean(result.confirmed));
}
async function showPrompt(message, options = {}) {
const result = await openDialog({
title: options.title || "请输入内容",
message,
confirmLabel: options.confirmLabel || "确定",
cancelLabel: options.cancelLabel || "取消",
showInput: true,
defaultValue: options.defaultValue || "",
placeholder: options.placeholder || "",
});
if (!result.confirmed) {
return null;
}
const value = typeof result.value === "string" ? result.value.trim() : "";
return value === "" ? null : value;
}
function initThemeAndView() {
const themeToggle = document.getElementById("themeToggle");
const viewToggle = document.getElementById("viewToggle");
if (!themeToggle || !viewToggle) {
return;
}
const themeIcon = themeToggle.querySelector("i");
const viewIcon = viewToggle.querySelector("i");
const savedTheme = localStorage.getItem("theme");
if (savedTheme === "dark") {
document.documentElement.setAttribute("data-theme", "dark");
if (themeIcon) {
themeIcon.classList.remove("fa-moon");
themeIcon.classList.add("fa-sun");
}
}
const savedView = localStorage.getItem("view") || "list";
document.documentElement.setAttribute("data-view", savedView);
if (viewIcon) {
if (savedView === "grid") {
viewIcon.classList.remove("fa-th-large");
viewIcon.classList.add("fa-th-list");
} else {
viewIcon.classList.remove("fa-th-list");
viewIcon.classList.add("fa-th-large");
}
}
viewToggle.addEventListener("click", () => {
const current = document.documentElement.getAttribute("data-view") || "list";
const next = current === "grid" ? "list" : "grid";
document.documentElement.setAttribute("data-view", next);
localStorage.setItem("view", next);
if (!viewIcon) {
return;
}
if (next === "grid") {
viewIcon.classList.remove("fa-th-large");
viewIcon.classList.add("fa-th-list");
} else {
viewIcon.classList.remove("fa-th-list");
viewIcon.classList.add("fa-th-large");
}
});
themeToggle.addEventListener("click", () => {
const currentTheme = document.documentElement.getAttribute("data-theme");
const newTheme = currentTheme === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", newTheme);
localStorage.setItem("theme", newTheme);
if (!themeIcon) {
return;
}
if (newTheme === "dark") {
themeIcon.classList.remove("fa-moon");
themeIcon.classList.add("fa-sun");
} else {
themeIcon.classList.remove("fa-sun");
themeIcon.classList.add("fa-moon");
}
});
if (!savedTheme) {
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (prefersDark) {
document.documentElement.setAttribute("data-theme", "dark");
if (themeIcon) {
themeIcon.classList.remove("fa-moon");
themeIcon.classList.add("fa-sun");
}
localStorage.setItem("theme", "dark");
}
}
}
function registerServiceWorker() {
if (!("serviceWorker" in navigator)) {
return;
}
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/static/sw.js")
.then((registration) => {
console.log("SW registered:", registration);
})
.catch((error) => {
console.log("SW registration failed:", error);
});
});
}
function registerModalHandlers() {
const modal = document.getElementById("previewModal");
if (!modal) {
return;
}
modal.addEventListener("click", (event) => {
if (event.target === modal) {
closePreview();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !isDialogOpen()) {
closePreview();
}
});
}
async function uploadFiles(files) {
const currentPrefix = document.body.dataset.currentPrefix || "";
for (const file of files) {
const formData = new FormData();
formData.append("file", file);
formData.append("prefix", currentPrefix);
try {
updateStatus(`正在上传: ${file.name}...`, null);
const response = await fetch("/upload", {
method: "POST",
body: formData,
});
const result = await response.json();
if (result.success) {
const statusDiv = updateStatus(`${file.name} 上传成功!`, "success");
hideStatusLater(statusDiv);
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
updateStatus(`${file.name} 上传失败: ${result.error}`, "error");
}
} catch (error) {
updateStatus(`${file.name} 上传失败: ${error.message}`, "error");
}
}
}
async function promptDelete() {
const suggested = "";
const path = await showPrompt("请输入要删除的文件路径相对于存储桶例如folder/file.jpg", {
title: "删除文件",
defaultValue: suggested,
placeholder: "folder/file.jpg",
confirmLabel: "删除",
});
if (path) {
await deleteFile(path);
}
}
async function deleteFolder(prefix) {
const confirmed = await showConfirm(`确定要删除文件夹 "${prefix}" 及其所有内容吗?此操作不可逆!`, {
title: "删除文件夹",
confirmLabel: "确认删除",
});
if (!confirmed) {
return;
}
updateStatus(`正在删除文件夹: ${prefix}...`, null);
try {
const response = await fetch(`/delete_folder/${prefix}`, {
method: "DELETE",
});
const result = await response.json();
if (result.success) {
const statusDiv = updateStatus("✓ 文件夹删除成功!", "success");
hideStatusLater(statusDiv);
setTimeout(() => {
// 返回上一级目录
const parentPath = prefix.split("/").slice(0, -2).join("/");
window.location.href = parentPath ? `/${parentPath}` : "/";
}, 1500);
} else {
updateStatus(`✗ 删除失败: ${result.error}`, "error");
}
} catch (error) {
updateStatus(`✗ 删除失败: ${error.message}`, "error");
}
}
async function deleteFile(filePath, options = {}) {
const { skipConfirm = false, suppressReload = false, suppressStatus = false } = options;
if (!skipConfirm) {
const confirmed = await showConfirm(`确定要删除 "${filePath}" 吗?`, {
title: "删除文件",
confirmLabel: "删除",
});
if (!confirmed) {
return false;
}
}
if (!suppressStatus) {
updateStatus(`正在删除: ${filePath}...`, null);
}
try {
const response = await fetch(`/delete/${filePath}`, {
method: "DELETE",
});
const result = await response.json();
if (result.success) {
if (!suppressStatus) {
const statusDiv = updateStatus("✓ 文件删除成功!", "success");
hideStatusLater(statusDiv);
}
if (!suppressReload) {
setTimeout(() => {
window.location.reload();
}, 2000);
}
return true;
}
if (!suppressStatus) {
updateStatus(`✗ 删除失败: ${result.error}`, "error");
}
return false;
} catch (error) {
if (!suppressStatus) {
updateStatus(`✗ 删除失败: ${error.message}`, "error");
}
return false;
}
}
function getEntryCheckboxes() {
return document.querySelectorAll(".entry-checkbox");
}
function updateSelectAllState() {
const master = document.getElementById("selectAll");
if (!master) {
return;
}
const checkboxes = Array.from(getEntryCheckboxes());
if (checkboxes.length === 0) {
master.checked = false;
master.indeterminate = false;
return;
}
const checkedCount = checkboxes.filter((checkbox) => checkbox.checked).length;
if (checkedCount === 0) {
master.checked = false;
master.indeterminate = false;
} else if (checkedCount === checkboxes.length) {
master.checked = true;
master.indeterminate = false;
} else {
master.checked = false;
master.indeterminate = true;
}
}
function toggleSelectAll(master) {
const checkboxes = getEntryCheckboxes();
const desiredState = Boolean(master.checked);
checkboxes.forEach((checkbox) => {
checkbox.checked = desiredState;
});
master.indeterminate = false;
updateSelectAllState();
}
function attachEntryCheckboxListeners() {
const master = document.getElementById("selectAll");
if (master && !master.dataset.listenerAttached) {
master.addEventListener("change", () => toggleSelectAll(master));
master.dataset.listenerAttached = "true";
}
getEntryCheckboxes().forEach((checkbox) => {
if (!checkbox.dataset.listenerAttached) {
checkbox.addEventListener("change", updateSelectAllState);
checkbox.dataset.listenerAttached = "true";
}
});
updateSelectAllState();
}
function downloadFile(url, filename) {
if (!url) {
updateStatus("✗ 无法下载:缺少下载链接", "error");
return;
}
const link = document.createElement("a");
link.href = url;
link.download = filename || "";
link.target = "_blank";
link.rel = "noopener";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
const statusDiv = updateStatus(`✓ 开始下载: ${filename || ""}`, "success");
hideStatusLater(statusDiv);
}
async function deleteSelectedEntries() {
const selected = Array.from(getEntryCheckboxes()).filter((checkbox) => checkbox.checked);
if (selected.length === 0) {
const statusDiv = updateStatus("✗ 请先选择要删除的项目", "error");
hideStatusLater(statusDiv);
return;
}
const directories = selected.filter((checkbox) => checkbox.dataset.type === "dir");
if (directories.length > 0) {
const statusDiv = updateStatus("✗ 暂不支持批量删除文件夹,请仅选择文件", "error");
hideStatusLater(statusDiv);
return;
}
const files = selected.map((checkbox) => checkbox.value);
const confirmMessage =
files.length === 1 ? `确定要删除 "${files[0]}" 吗?` : `确定要删除选中的 ${files.length} 个文件吗?`;
const confirmed = await showConfirm(confirmMessage, {
title: "批量删除",
confirmLabel: "删除",
});
if (!confirmed) {
return;
}
const deleteButton = document.getElementById("deleteTrigger");
if (deleteButton) {
deleteButton.disabled = true;
deleteButton.classList.add("is-disabled");
}
const inProgressStatus = updateStatus(`正在删除 ${files.length} 个文件...`, null);
const failures = [];
let successCount = 0;
for (const filePath of files) {
// 跳过额外提示和页面刷新,在批量完成后统一处理
const result = await deleteFile(filePath, {
skipConfirm: true,
suppressReload: true,
suppressStatus: true,
});
if (result) {
successCount += 1;
} else {
failures.push(filePath);
}
}
if (deleteButton) {
deleteButton.disabled = false;
deleteButton.classList.remove("is-disabled");
}
if (inProgressStatus) {
inProgressStatus.style.display = "none";
}
if (failures.length === 0 && successCount > 0) {
const statusDiv = updateStatus(`✓ 已删除 ${successCount} 个文件`, "success");
hideStatusLater(statusDiv, 3000);
setTimeout(() => {
window.location.reload();
}, 1500);
return;
}
if (failures.length > 0) {
const message =
failures.length === files.length
? "✗ 删除失败,请稍后重试"
: `删除部分文件失败:${failures.join(", ")}`;
const statusDiv = updateStatus(message, "error");
hideStatusLater(statusDiv, 4000);
if (successCount > 0) {
setTimeout(() => {
window.location.reload();
}, 1500);
}
}
}
async function promptRename(oldKey, oldName, isFolder = false) {
const title = isFolder ? "重命名文件夹" : "重命名文件";
const newName = await showPrompt(`请输入新的名称:`, {
title: title,
defaultValue: oldName,
confirmLabel: "重命名",
});
if (newName && newName !== oldName) {
if (isFolder) {
await renameFolder(oldKey, newName);
} else {
await renameFile(oldKey, newName);
}
}
}
async function renameFile(oldKey, newName) {
updateStatus(`正在重命名: ${oldKey}...`, null);
try {
const response = await fetch(`/rename/${oldKey}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ newName: newName }),
});
const result = await response.json();
if (result.success) {
const statusDiv = updateStatus("✓ 文件重命名成功!", "success");
hideStatusLater(statusDiv);
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
updateStatus(`✗ 重命名失败: ${result.error}`, "error");
}
} catch (error) {
updateStatus(`✗ 重命名失败: ${error.message}`, "error");
}
}
async function renameFolder(oldPrefix, newName) {
updateStatus(`正在重命名文件夹: ${oldPrefix}...`, null);
try {
const response = await fetch(`/rename_folder/${oldPrefix}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ newName: newName }),
});
const result = await response.json();
if (result.success) {
const statusDiv = updateStatus("✓ 文件夹重命名成功!", "success");
hideStatusLater(statusDiv);
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
updateStatus(`✗ 重命名失败: ${result.error}`, "error");
}
} catch (error) {
updateStatus(`✗ 重命名失败: ${error.message}`, "error");
}
}
async function promptCopyOrMove(source, isFolder, operation) {
const opText = operation === "copy" ? "复制" : "移动";
const itemText = isFolder ? "文件夹" : "文件";
const dest = await showPrompt(`请输入目标目录路径:`, {
title: `${opText}${itemText}`,
confirmLabel: opText,
});
if (dest) {
// 确保 dest 以 / 结尾
let normalizedDest = dest;
if (!normalizedDest.endsWith("/")) {
normalizedDest += "/";
}
// 提取源名称
let name = source
.split("/")
.filter((p) => p)
.pop();
if (isFolder) {
name += "/";
}
const fullDest = normalizedDest + name;
if (operation === "copy") {
await copyItem(source, fullDest, isFolder);
} else {
await moveItem(source, fullDest, isFolder);
}
}
}
async function copyItem(source, destination, isFolder) {
updateStatus(`正在复制...`, null);
await performOperation("/copy", "复制", { source, destination, is_folder: isFolder });
}
async function moveItem(source, destination, isFolder) {
updateStatus(`正在移动...`, null);
await performOperation("/move", "移动", { source, destination, is_folder: isFolder });
}
async function performOperation(endpoint, opText, body) {
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const result = await response.json();
if (result.success) {
const statusDiv = updateStatus(`${opText}成功!`, "success");
hideStatusLater(statusDiv);
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
updateStatus(`${opText}失败: ${result.error}`, "error");
}
} catch (error) {
updateStatus(`${opText}失败: ${error.message}`, "error");
}
}
function getFileType(filename) {
const extension = filename.toLowerCase().split(".").pop();
const imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "ico"];
const videoExtensions = ["mp4", "webm", "ogg", "mov", "avi", "mkv", "m4v"];
if (imageExtensions.includes(extension)) {
return "image";
}
if (videoExtensions.includes(extension)) {
return "video";
}
return "unknown";
}
function openPreview(url, filename) {
const modal = document.getElementById("previewModal");
const container = document.getElementById("previewContainer");
const info = document.getElementById("previewInfo");
if (!modal || !container || !info) {
window.open(url, "_blank");
return;
}
const fileType = getFileType(filename);
if (fileType === "unknown") {
window.open(url, "_blank");
return;
}
container.innerHTML = '<div class="preview-loading">加载中...</div>';
info.textContent = filename;
modal.classList.add("show");
document.body.style.overflow = "hidden";
setTimeout(() => {
if (fileType === "image") {
const image = document.createElement("img");
image.className = "preview-content";
image.src = url;
image.alt = filename;
image.onload = () => {
container.innerHTML = "";
container.appendChild(image);
};
image.onerror = () => {
container.innerHTML = '<div class="preview-error">图片加载失败</div>';
};
} else if (fileType === "video") {
const video = document.createElement("video");
video.className = "preview-content";
video.src = url;
video.controls = true;
video.autoplay = false;
video.onloadedmetadata = () => {
container.innerHTML = "";
container.appendChild(video);
};
video.onerror = () => {
container.innerHTML = '<div class="preview-error">视频加载失败</div>';
};
}
}, 100);
}
function closePreview() {
const modal = document.getElementById("previewModal");
const container = document.getElementById("previewContainer");
if (!modal || !container) {
return;
}
modal.classList.remove("show");
document.body.style.overflow = "";
setTimeout(() => {
container.innerHTML = "";
}, 300);
}
function downloadPreview() {
const container = document.getElementById("previewContainer");
const info = document.getElementById("previewInfo");
if (!container || !info) {
return;
}
const media = container.querySelector(".preview-content");
if (media && media.src) {
downloadFile(media.src, info.textContent);
}
}
document.addEventListener("DOMContentLoaded", () => {
initDialog();
initThemeAndView();
registerModalHandlers();
registerServiceWorker();
attachEntryCheckboxListeners();
});
window.uploadFiles = uploadFiles;
window.promptDelete = promptDelete;
window.deleteFile = deleteFile;
window.deleteSelectedEntries = deleteSelectedEntries;
window.toggleSelectAll = (master) => toggleSelectAll(master);
window.downloadFile = downloadFile;
window.openPreview = openPreview;
window.closePreview = closePreview;
window.downloadPreview = downloadPreview;
window.promptRename = promptRename;
window.deleteFolder = deleteFolder;
window.promptCopyOrMove = promptCopyOrMove;
async function promptCreateFolder() {
const currentPrefix = document.body.dataset.currentPrefix || "";
const folderName = await showPrompt("请输入新文件夹的名称:", {
title: "新建文件夹",
confirmLabel: "创建",
});
if (folderName) {
const path = currentPrefix + folderName;
await createFolder(path);
}
}
async function createFolder(path) {
updateStatus(`正在创建文件夹: ${path}...`, null);
try {
const response = await fetch(`/create_folder`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ path: path }),
});
const result = await response.json();
if (result.success) {
const statusDiv = updateStatus("✓ 文件夹创建成功!", "success");
hideStatusLater(statusDiv);
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
updateStatus(`✗ 创建失败: ${result.error}`, "error");
}
} catch (error) {
updateStatus(`✗ 创建失败: ${error.message}`, "error");
}
}
window.promptCreateFolder = promptCreateFolder;
})();

View File

@@ -1,37 +0,0 @@
const CACHE_NAME = 'thumbnail-cache-v1';
const THUMB_URL_PATTERN = /^\/thumb\//;
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(clients.claim());
});
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// 只缓存缩略图请求
if (THUMB_URL_PATTERN.test(url.pathname)) {
event.respondWith(
caches.open(CACHE_NAME).then((cache) => {
return cache.match(request).then((response) => {
// 如果缓存中有响应,直接返回
if (response) {
return response;
}
// 否则发起网络请求,并将响应添加到缓存中
return fetch(request).then((networkResponse) => {
// 复制响应,因为流只能读取一次
const responseToCache = networkResponse.clone();
cache.put(request, responseToCache);
return networkResponse;
});
});
})
);
}
});

6
storages/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
from .base import BaseStorage
from .cnbcool import CnbCoolStorage
from .factory import StorageFactory
from .r2 import R2Storage
__all__ = ["BaseStorage", "R2Storage", "CnbCoolStorage", "StorageFactory"]

210
storages/base.py Normal file
View File

@@ -0,0 +1,210 @@
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Any, Dict
class BaseStorage(ABC):
"""存储后端的基类,定义统一接口"""
@abstractmethod
def list_objects(self, prefix: str = "") -> Dict[str, Any]:
"""
列出存储桶中的对象
Args:
prefix: 对象前缀(用于目录浏览)
Returns:
包含对象列表的字典
"""
pass
@abstractmethod
def get_object_info(self, key: str) -> Dict[str, Any]:
"""
获取对象基本信息
Args:
key: 对象键名
Returns:
对象元数据
"""
pass
@abstractmethod
def get_object(self, key: str) -> Dict[str, Any]:
"""
获取对象内容
Args:
key: 对象键名
Returns:
包含对象内容的字典
"""
pass
@abstractmethod
def generate_presigned_url(self, key: str, expires: int = None) -> str:
"""
为指定对象生成预签名 URL
Args:
key: 对象键名
expires: 过期时间(秒)
Returns:
预签名 URL失败返回 None
"""
pass
@abstractmethod
def get_public_url(self, key: str) -> str:
"""
生成对象的公共访问 URL
Args:
key: 对象键名
Returns:
公共 URL未配置返回 None
"""
pass
def format_timestamp(self, timestamp) -> str:
"""
格式化时间戳为人类可读的格式
Args:
timestamp: 时间戳对象
Returns:
格式化后的时间字符串
"""
if isinstance(timestamp, datetime):
return timestamp.strftime("%Y-%m-%d %H:%M:%S")
return str(timestamp)
@abstractmethod
def generate_thumbnail(self, file_path: str) -> bytes:
"""
生成图片缩略图
Args:
file_path: 文件路径
Returns:
缩略图字节数据
"""
pass
@abstractmethod
def upload_file(self, key: str, file_data: bytes, content_type: str = None) -> bool:
"""
上传文件到存储
Args:
key: 对象键名(文件路径)
file_data: 文件二进制数据
content_type: 文件类型MIME type
Returns:
上传成功返回 True失败返回 False
"""
pass
@abstractmethod
def delete_file(self, key: str) -> bool:
"""
删除存储中的文件
Args:
key: 对象键名(文件路径)
Returns:
删除成功返回 True失败返回 False
"""
pass
@abstractmethod
def rename_file(self, old_key: str, new_key: str) -> bool:
"""
重命名存储中的文件
Args:
old_key: 旧的对象键名
new_key: 新的对象键名
Returns:
重命名成功返回 True失败返回 False
"""
pass
@abstractmethod
def delete_folder(self, prefix: str) -> bool:
"""
删除存储中的文件夹(前缀)
Args:
prefix: 要删除的文件夹前缀
Returns:
删除成功返回 True失败返回 False
"""
pass
@abstractmethod
def rename_folder(self, old_prefix: str, new_prefix: str) -> bool:
"""
重命名存储中的文件夹(前缀)
Args:
old_prefix: 旧的文件夹前缀
new_prefix: 新的文件夹前缀
Returns:
重命名成功返回 True失败返回 False
"""
pass
@abstractmethod
def copy_file(self, source_key: str, dest_key: str) -> bool:
"""
复制存储中的文件
Args:
source_key: 源对象键名
dest_key: 目标对象键名
Returns:
复制成功返回 True失败返回 False
"""
pass
@abstractmethod
def copy_folder(self, source_prefix: str, dest_prefix: str) -> bool:
"""
复制存储中的文件夹(前缀)
Args:
source_prefix: 源文件夹前缀
dest_prefix: 目标文件夹前缀
Returns:
复制成功返回 True失败返回 False
"""
pass
@abstractmethod
def create_folder(self, key: str) -> bool:
"""
创建文件夹
Args:
key: 文件夹路径(以 / 结尾)
Returns:
创建成功返回 True失败返回 False
"""
pass

54
storages/factory.py Normal file
View File

@@ -0,0 +1,54 @@
import os
from typing import Optional
import dotenv
from .base import BaseStorage
# from .cnbcool import CnbCoolStorage
from .r2 import R2Storage
dotenv.load_dotenv()
class StorageFactory:
"""存储工厂类,根据配置创建对应的存储实例"""
_instance: Optional[BaseStorage] = None
@classmethod
def get_storage(cls) -> BaseStorage:
"""
获取存储实例(单例模式)
Returns:
BaseStorage: 存储实例
Raises:
RuntimeError: 当存储类型未配置或不支持时
"""
if cls._instance is not None:
return cls._instance
storage_type = os.getenv(
"STORAGE_TYPE",
).lower()
if storage_type == "r2":
cls._instance = R2Storage()
# elif storage_type == "github":
# cls._instance = GithubStorage()
# elif storage_type == "cnbcool":
# cls._instance = CnbCoolStorage()
else:
raise RuntimeError(
f"Unsupported storage type: {storage_type}. "
f"Supported types: r2, cnbcool"
)
return cls._instance
@classmethod
def reset(cls):
"""重置单例实例(主要用于测试)"""
cls._instance = None

324
storages/r2.py Normal file
View File

@@ -0,0 +1,324 @@
import os
from io import BytesIO
from typing import Any, Dict
import boto3
import dotenv
from botocore.config import Config
from PIL import Image
from .base import BaseStorage
dotenv.load_dotenv()
class R2Storage(BaseStorage):
def __init__(self):
self.endpoint = os.getenv("R2_ENDPOINT_URL")
if not self.endpoint:
raise RuntimeError("R2_ENDPOINT_URL environment variable is not set")
self.access_key = os.getenv("ACCESS_KEY_ID") or os.getenv("ACCESS_KEY_ID")
self.secret_key = os.getenv("SECRET_ACCESS_KEY") or os.getenv(
"SECRET_ACCESS_KEY"
)
if not self.access_key or not self.secret_key:
raise RuntimeError("ACCESS_KEY_ID and SECRET_ACCESS_KEY must be set")
self.region_name = os.getenv("R2_REGION", "auto")
self.bucket_name = os.getenv("R2_BUCKET_NAME")
def get_s3_client(self):
"""
创建并返回配置好的 S3 客户端,用于访问 R2 存储
"""
return boto3.client(
"s3",
endpoint_url=self.endpoint,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
config=Config(signature_version="s3v4"),
region_name=self.region_name,
)
def list_objects(self, prefix: str = "") -> Dict[str, Any]:
"""
列出存储桶中的对象
"""
s3_client = self.get_s3_client()
if prefix and not prefix.endswith("/"):
prefix = prefix + "/"
list_kwargs = {"Bucket": self.bucket_name, "Delimiter": "/"}
if prefix:
list_kwargs["Prefix"] = prefix
return s3_client.list_objects_v2(**list_kwargs)
def get_object_info(self, key: str) -> Dict[str, Any]:
"""
获取对象基本信息
"""
s3_client = self.get_s3_client()
return s3_client.head_object(Bucket=self.bucket_name, Key=key)
def get_object(self, key: str) -> Dict[str, Any]:
"""
获取对象内容
"""
s3_client = self.get_s3_client()
return s3_client.get_object(Bucket=self.bucket_name, Key=key)
def generate_presigned_url(self, key: str, expires: int = None) -> str:
"""为指定对象生成 presigned URLGET"""
s3_client = self.get_s3_client()
if expires is None:
try:
expires = int(os.getenv("R2_PRESIGN_EXPIRES", "3600"))
except Exception:
expires = 3600
try:
url = s3_client.generate_presigned_url(
"get_object",
Params={"Bucket": self.bucket_name, "Key": key},
ExpiresIn=expires,
)
return url
except Exception:
return None
def get_public_url(self, key: str) -> str:
"""
生成对象的公共访问 URL
"""
base_url = os.getenv("R2_PUBLIC_URL")
if not base_url:
return None
return f"{base_url.rstrip('/')}/{key}"
def generate_thumbnail(self, file_path: str) -> bytes:
"""
生成图片缩略图
"""
try:
obj = self.get_object(file_path)
data = obj["Body"].read()
img = Image.open(BytesIO(data))
img = img.convert("RGB")
img.thumbnail((320, 320))
buf = BytesIO()
img.save(buf, "JPEG", quality=80, optimize=True)
buf.seek(0)
return buf.getvalue()
except Exception:
raise
def upload_file(self, key: str, file_data: bytes, content_type: str = None) -> bool:
"""
上传文件到 R2 存储
"""
try:
s3_client = self.get_s3_client()
# 如果没有指定 content_type尝试根据文件扩展名猜测
if not content_type:
content_type = self._guess_content_type(key)
# 上传文件
s3_client.put_object(
Bucket=self.bucket_name,
Key=key,
Body=file_data,
ContentType=content_type,
)
return True
except Exception as e:
print(f"Upload failed: {str(e)}")
return False
def delete_file(self, key: str) -> bool:
"""
从 R2 存储删除文件
"""
try:
s3_client = self.get_s3_client()
s3_client.delete_object(Bucket=self.bucket_name, Key=key)
return True
except Exception as e:
print(f"Delete failed: {str(e)}")
return False
def rename_file(self, old_key: str, new_key: str) -> bool:
"""
重命名 R2 中的对象,通过复制和删除实现
"""
try:
s3_client = self.get_s3_client()
# 复制对象到新路径
copy_source = {"Bucket": self.bucket_name, "Key": old_key}
s3_client.copy_object(
CopySource=copy_source, Bucket=self.bucket_name, Key=new_key
)
# 删除原对象
s3_client.delete_object(Bucket=self.bucket_name, Key=old_key)
return True
except Exception as e:
print(f"Rename failed: {str(e)}")
return False
def delete_folder(self, prefix: str) -> bool:
"""
删除 R2 中整个文件夹(前缀)下的所有对象
"""
try:
s3_client = self.get_s3_client()
paginator = s3_client.get_paginator("list_objects_v2")
pages = paginator.paginate(Bucket=self.bucket_name, Prefix=prefix)
objects_to_delete = []
for page in pages:
if "Contents" in page:
for obj in page["Contents"]:
objects_to_delete.append({"Key": obj["Key"]})
if not objects_to_delete:
return True # 文件夹为空,直接返回成功
# 分批次删除S3/R2 一次最多删除 1000 个
for i in range(0, len(objects_to_delete), 1000):
chunk = objects_to_delete[i : i + 1000]
s3_client.delete_objects(
Bucket=self.bucket_name, Delete={"Objects": chunk}
)
return True
except Exception as e:
print(f"Folder delete failed: {str(e)}")
return False
def rename_folder(self, old_prefix: str, new_prefix: str) -> bool:
"""
重命名 R2 中的文件夹(前缀),通过复制和删除实现
"""
try:
s3_client = self.get_s3_client()
paginator = s3_client.get_paginator("list_objects_v2")
pages = paginator.paginate(Bucket=self.bucket_name, Prefix=old_prefix)
objects_to_rename = []
for page in pages:
if "Contents" in page:
for obj in page["Contents"]:
objects_to_rename.append(obj["Key"])
if not objects_to_rename:
return True # 文件夹为空,直接返回成功
for old_key in objects_to_rename:
new_key = old_key.replace(old_prefix, new_prefix, 1)
copy_source = {"Bucket": self.bucket_name, "Key": old_key}
s3_client.copy_object(
CopySource=copy_source, Bucket=self.bucket_name, Key=new_key
)
# 删除旧文件夹下的所有对象
self.delete_folder(old_prefix)
return True
except Exception as e:
print(f"Folder rename failed: {str(e)}")
return False
def copy_file(self, source_key: str, dest_key: str) -> bool:
"""
复制 R2 中的对象
"""
try:
s3_client = self.get_s3_client()
copy_source = {"Bucket": self.bucket_name, "Key": source_key}
s3_client.copy_object(
CopySource=copy_source, Bucket=self.bucket_name, Key=dest_key
)
return True
except Exception as e:
print(f"File copy failed: {str(e)}")
return False
def copy_folder(self, source_prefix: str, dest_prefix: str) -> bool:
"""
复制 R2 中的文件夹(前缀)
"""
try:
s3_client = self.get_s3_client()
paginator = s3_client.get_paginator("list_objects_v2")
pages = paginator.paginate(Bucket=self.bucket_name, Prefix=source_prefix)
for page in pages:
if "Contents" in page:
for obj in page["Contents"]:
old_key = obj["Key"]
new_key = old_key.replace(source_prefix, dest_prefix, 1)
copy_source = {"Bucket": self.bucket_name, "Key": old_key}
s3_client.copy_object(
CopySource=copy_source,
Bucket=self.bucket_name,
Key=new_key,
)
return True
except Exception as e:
print(f"Folder copy failed: {str(e)}")
return False
def create_folder(self, key: str) -> bool:
"""
在 R2 中创建文件夹(通过创建一个以 / 结尾的 0 字节对象)
"""
try:
s3_client = self.get_s3_client()
s3_client.put_object(Bucket=self.bucket_name, Key=key, Body=b"")
return True
except Exception as e:
print(f"Folder creation failed: {str(e)}")
return False
def _guess_content_type(self, filename: str) -> str:
"""
根据文件扩展名猜测 Content-Type
"""
ext = filename.lower().split(".")[-1] if "." in filename else ""
content_types = {
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"png": "image/png",
"gif": "image/gif",
"webp": "image/webp",
"svg": "image/svg+xml",
"pdf": "application/pdf",
"html": "text/html",
"css": "text/css",
"js": "application/javascript",
"json": "application/json",
"xml": "application/xml",
"txt": "text/plain",
"md": "text/markdown",
"mp4": "video/mp4",
"webm": "video/webm",
"mp3": "audio/mpeg",
"wav": "audio/wav",
"zip": "application/zip",
"rar": "application/x-rar-compressed",
"7z": "application/x-7z-compressed",
"tar": "application/x-tar",
"gz": "application/gzip",
}
return content_types.get(ext, "application/octet-stream")

33
templates/base.html Normal file
View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}Cloud Index{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}" />
{% block head_extra %}{% endblock %}
</head>
<body {% block body_attrs %}{% endblock %}>
{% block content %}{% endblock %}
<div id="appDialog" class="app-dialog" role="dialog" aria-modal="true" aria-hidden="true" hidden>
<div class="app-dialog__backdrop" data-dialog-dismiss="true"></div>
<div class="app-dialog__panel" role="document">
<h2 class="app-dialog__title" id="appDialogTitle"></h2>
<div class="app-dialog__message" id="appDialogMessage"></div>
<div class="app-dialog__input" id="appDialogInputWrapper" hidden>
<input type="text" id="appDialogInput" autocomplete="off" />
</div>
<div class="app-dialog__actions">
<button type="button" class="app-dialog__btn app-dialog__cancel" id="appDialogCancel">取消</button>
<button type="button" class="app-dialog__btn app-dialog__confirm" id="appDialogConfirm">
确定
</button>
</div>
</div>
</div>
<script defer src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -1,256 +1,64 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>R2 存储浏览器</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" />
<style>
:root {
--bg-color: #f5f5f5;
--container-bg: white;
--text-color: #2c3e50;
--border-color: #eee;
--hover-bg: #f8f9fa;
--secondary-text: #6c757d;
--link-color: #007bff;
--folder-color: #ffc107;
--file-color: #6c757d;
--shadow-color: rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] {
--bg-color: #1a1a1a;
--container-bg: #2d2d2d;
--text-color: #e1e1e1;
--border-color: #404040;
--hover-bg: #363636;
--secondary-text: #a0a0a0;
--link-color: #66b3ff;
--folder-color: #ffd54f;
--file-color: #b0b0b0;
--shadow-color: rgba(0, 0, 0, 0.3);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: var(--bg-color);
color: var(--text-color);
transition: background-color 0.3s ease, color 0.3s ease;
}
.container {
background-color: var(--container-bg);
border-radius: 8px;
box-shadow: 0 2px 4px var(--shadow-color);
padding: 20px;
}
h1 {
color: var(--text-color);
margin-bottom: 30px;
border-bottom: 2px solid var(--border-color);
padding-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.theme-toggle {
background: none;
border: none;
color: var(--secondary-text);
cursor: pointer;
padding: 8px;
border-radius: 4px;
font-size: 1.2em;
}
.theme-toggle:hover {
background-color: var(--hover-bg);
}
.view-toggle {
background: none;
border: none;
color: var(--secondary-text);
cursor: pointer;
padding: 8px;
border-radius: 4px;
font-size: 1.2em;
margin-left: 8px;
}
.view-toggle:hover {
background-color: var(--hover-bg);
}
/* Grid view styles */
.grid-container {
display: none; /* 默认隐藏,只有 data-view="grid" 时显示 */
margin-top: 20px;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
.grid-card {
background: var(--container-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px;
text-align: center;
box-shadow: 0 1px 2px var(--shadow-color);
}
.grid-thumb {
width: 100%;
height: 100px;
object-fit: cover;
border-radius: 6px;
margin-bottom: 8px;
background-color: #f0f0f0;
}
.grid-name {
display: block;
font-size: 0.95em;
color: var(--text-color);
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Toggle visibility based on data-view on root */
:root[data-view="grid"] .files-table {
display: none;
}
:root[data-view="grid"] .grid-container {
display: grid;
}
.files-table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
.files-table th,
.files-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.files-table th {
background-color: var(--hover-bg);
color: var(--text-color);
font-weight: 600;
}
.files-table tr:hover {
background-color: var(--hover-bg);
}
.file-icon {
margin-right: 8px;
}
.folder {
color: var(--folder-color);
}
.file {
color: var(--file-color);
}
a {
color: var(--link-color);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.file-size {
color: var(--secondary-text);
font-size: 0.9em;
}
.last-modified {
color: var(--secondary-text);
font-size: 0.9em;
}
.empty-message {
text-align: center;
padding: 40px;
color: var(--secondary-text);
}
.breadcrumb {
margin-bottom: 12px;
font-size: 0.95em;
color: var(--secondary-text);
background-color: var(--container-bg);
padding: 8px 12px;
border-radius: 4px;
}
/* 页脚样式 */
.site-footer {
margin-top: 20px;
text-align: center;
color: var(--secondary-text);
font-size: 0.9em;
padding: 14px 12px;
}
.site-footer a {
color: var(--link-color);
text-decoration: none;
margin: 0 6px;
}
.site-footer a:hover {
text-decoration: underline;
}
@media (max-width: 600px) {
.site-footer {
font-size: 0.85em;
padding: 12px 8px;
}
}
</style>
</head>
<body>
<div class="container">
{% extends 'base.html' %} {% block title %}Cloud Index{% endblock %} {% block body_attrs
%}data-current-prefix="{{ current_prefix }}"{% endblock %} {% block content %}
<div class="container">
<h1>
RhenCloud's R2 Drive
Cloud Index
<div>
<button class="theme-toggle" aria-label="切换深色模式">
<button
class="view-toggle"
id="createFolderButton"
aria-label="新建文件夹"
onclick="promptCreateFolder()"
title="新建文件夹"
>
<i class="fas fa-folder-plus"></i>
</button>
<button
class="view-toggle"
id="uploadButton"
aria-label="上传文件"
onclick="document.getElementById('fileInput').click()"
title="上传文件"
>
<i class="fas fa-upload"></i>
</button>
<button
class="view-toggle"
id="deleteTrigger"
style="color: #dc3545"
aria-label="删除文件"
onclick="deleteSelectedEntries()"
title="删除文件"
>
<i class="fas fa-trash"></i>
</button>
<button class="theme-toggle" id="themeToggle" aria-label="切换深色模式" title="切换深色/浅色模式">
<i class="fas fa-moon"></i>
</button>
<button class="view-toggle" aria-label="切换视图">
<button class="view-toggle" id="viewToggle" aria-label="切换视图" title="切换列表/网格视图">
<i class="fas fa-th-list"></i>
</button>
</div>
</h1>
<div class="breadcrumb">
<a href="/">Home</a>
{% if crumbs %} &nbsp;/&nbsp; {% for c in crumbs %}
<a href="/{{ c.prefix.rstrip('/') }}">{{ c.name }}</a>{% if not loop.last %} / {% endif %} {% endfor %}
{% endif %}
<a href="/{{ c.prefix.rstrip('/') }}">{{ c.name }}</a>{% if not loop.last %} / {% endif %} {% endfor %} {% endif
%}
</div>
<div id="uploadStatus" class="upload-status"></div>
<input type="file" id="fileInput" class="upload-input" multiple onchange="uploadFiles(this.files)" />
{% if entries %}
<table class="files-table">
<thead>
<tr>
<th class="checkbox-col">
<input type="checkbox" id="selectAll" aria-label="全选" />
</th>
<th>名称</th>
<th>大小</th>
<th>最后修改时间</th>
@@ -260,6 +68,15 @@
<tbody>
{% for entry in entries %}
<tr>
<td class="checkbox-col">
<input
type="checkbox"
class="entry-checkbox"
value="{{ entry.key }}"
data-type="{{ 'dir' if entry.is_dir else 'file' }}"
aria-label="选择 {{ entry.name }}"
/>
</td>
<td>
{% if entry.is_dir %}
<i class="file-icon folder fas fa-folder"></i>
@@ -276,8 +93,41 @@
{% if not entry.is_dir %} {{ entry.last_modified }} {% else %} - {% endif %}
</td>
<td>
{% if not entry.is_dir %}
<a href="{{ entry.file_url }}" target="_blank">预览</a>
{% if entry.is_dir %}
<button
class="action-link rename-btn"
onclick="promptRename('{{ entry.key }}', '{{ entry.name }}', true)"
>
<i class="fas fa-edit"></i> 重命名
</button>
<button class="action-link delete-btn" onclick="deleteFolder('{{ entry.key }}')">
<i class="fas fa-trash"></i> 删除
</button>
{% else %}
<button class="action-link" onclick="openPreview('{{ entry.file_url }}', '{{ entry.name }}')">
<i class="fas fa-eye"></i> 预览
</button>
<button
class="action-link download-btn"
onclick="downloadFile({{ (entry.public_url or entry.file_url)|tojson|safe }}, {{ entry.name|tojson|safe }})"
>
<i class="fas fa-download"></i> 下载
</button>
<button class="action-link delete-btn" onclick="deleteFile('{{ entry.key }}')">
<i class="fas fa-trash"></i> 删除
</button>
<button
class="action-link rename-btn"
onclick="promptRename('{{ entry.key }}', '{{ entry.name }}')"
>
<i class="fas fa-edit"></i> 重命名
</button>
<button class="action-link copy-btn" onclick="promptCopyOrMove('{{ entry.key }}', false, 'copy')">
<i class="fas fa-copy"></i> 复制
</button>
<button class="action-link move-btn" onclick="promptCopyOrMove('{{ entry.key }}', false, 'move')">
<i class="fas fa-arrows-alt-h"></i> 移动
</button>
{% endif %}
</td>
</tr>
@@ -287,117 +137,126 @@
<div class="grid-container" id="gridContainer">
{% for entry in entries %}
<div class="grid-card">
<div class="grid-card" data-key="{{ entry.key }}" data-type="{{ 'dir' if entry.is_dir else 'file' }}">
<div class="grid-checkbox">
<input
type="checkbox"
class="entry-checkbox"
value="{{ entry.key }}"
data-type="{{ 'dir' if entry.is_dir else 'file' }}"
aria-label="选择 {{ entry.name }}"
/>
</div>
{% if entry.is_dir %}
<i class="fas fa-folder fa-2x" style="color: var(--folder-color)"></i>
<a class="grid-name" href="/{{ entry.key.rstrip('/') }}">{{ entry.name }}</a>
<div class="grid-actions">
<button
class="grid-action-btn rename"
onclick="promptRename('{{ entry.key }}', '{{ entry.name }}', true)"
title="重命名"
>
<i class="fas fa-edit"></i>
</button>
<button class="grid-action-btn delete" onclick="deleteFolder('{{ entry.key }}')" title="删除">
<i class="fas fa-trash"></i>
</button>
<button
class="grid-action-btn copy"
onclick="promptCopyOrMove('{{ entry.key }}', true, 'copy')"
title="复制"
>
<i class="fas fa-copy"></i>
</button>
<button
class="grid-action-btn move"
onclick="promptCopyOrMove('{{ entry.key }}', true, 'move')"
title="移动"
>
<i class="fas fa-arrows-alt-h"></i>
</button>
</div>
{% else %} {% if entry.name|fileicon == 'fas fa-image' %}
<a href="{{ entry.file_url }}" target="_blank">
<div style="cursor: pointer" onclick="openPreview('{{ entry.file_url }}', '{{ entry.name }}')">
<img class="grid-thumb" src="{{ entry.file_url }}" alt="{{ entry.name }}" />
</a>
</div>
{% else %}
<i class="{{ entry.name|fileicon }} fa-2x" style="color: var(--file-color)"></i>
{% endif %}
<a class="grid-name" href="{{ entry.file_url }}" target="_blank">{{ entry.name }}</a>
<div class="file-size">{% if not entry.is_dir %} {{ entry.size|filesizeformat }} {% endif %}</div>
<a
class="grid-name"
href="javascript:void(0)"
onclick="openPreview('{{ entry.file_url }}', '{{ entry.name }}')"
title="{{ entry.name }}"
>
{{ entry.name }}
</a>
<div class="file-size">{{ entry.size|filesizeformat }}</div>
<div class="grid-actions">
<button
class="grid-action-btn preview"
onclick="openPreview('{{ entry.file_url }}', '{{ entry.name }}')"
title="预览"
>
<i class="fas fa-eye"></i>
</button>
<button
class="grid-action-btn download"
onclick="downloadFile({{ (entry.public_url or entry.file_url)|tojson|safe }}, {{ entry.name|tojson|safe }})"
title="下载"
>
<i class="fas fa-download"></i>
</button>
<button class="grid-action-btn delete" onclick="deleteFile('{{ entry.key }}')" title="删除">
<i class="fas fa-trash"></i>
</button>
<button
class="grid-action-btn rename"
onclick="promptRename('{{ entry.key }}', '{{ entry.name }}')"
title="重命名"
>
<i class="fas fa-edit"></i>
</button>
<button
class="grid-action-btn copy"
onclick="promptCopyOrMove('{{ entry.key }}', false, 'copy')"
title="复制"
>
<i class="fas fa-copy"></i>
</button>
<button
class="grid-action-btn move"
onclick="promptCopyOrMove('{{ entry.key }}', false, 'move')"
title="移动"
>
<i class="fas fa-arrows-alt-h"></i>
</button>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-message">
<p>存储为空或未找到任何文件</p>
<p>存储为空或未找到任何文件</p>
</div>
{% endif %}
</div>
{% include 'footer.html' %}
<div id="previewModal" class="preview-modal">
<div class="preview-controls">
<button class="preview-btn" onclick="downloadPreview()" title="下载">
<i class="fas fa-download"></i>
</button>
<button class="preview-btn" onclick="closePreview()" title="关闭">
<i class="fas fa-times"></i>
</button>
</div>
{% include 'footer.html' %}
<script>
// 主题切换功能
document.addEventListener("DOMContentLoaded", () => {
const themeToggle = document.querySelector(".theme-toggle");
const themeIcon = themeToggle.querySelector("i");
// 检查用户之前的主题偏好
const savedTheme = localStorage.getItem("theme");
if (savedTheme === "dark") {
document.documentElement.setAttribute("data-theme", "dark");
themeIcon.classList.remove("fa-moon");
themeIcon.classList.add("fa-sun");
}
// 视图切换(列表 / 网格)
const viewToggle = document.querySelector(".view-toggle");
const viewIcon = viewToggle.querySelector("i");
// 初始化视图(从 localStorage 或默认 list
const savedView = localStorage.getItem("view") || "list";
document.documentElement.setAttribute("data-view", savedView);
if (savedView === "grid") {
viewIcon.classList.remove("fa-th-list");
viewIcon.classList.add("fa-th-large");
} else {
viewIcon.classList.remove("fa-th-large");
viewIcon.classList.add("fa-th-list");
}
viewToggle.addEventListener("click", () => {
const current = document.documentElement.getAttribute("data-view") || "list";
const next = current === "grid" ? "list" : "grid";
document.documentElement.setAttribute("data-view", next);
localStorage.setItem("view", next);
if (next === "grid") {
viewIcon.classList.remove("fa-th-list");
viewIcon.classList.add("fa-th-large");
} else {
viewIcon.classList.remove("fa-th-large");
viewIcon.classList.add("fa-th-list");
}
});
// 切换主题
themeToggle.addEventListener("click", () => {
const currentTheme = document.documentElement.getAttribute("data-theme");
const newTheme = currentTheme === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", newTheme);
localStorage.setItem("theme", newTheme);
// 更新图标
if (newTheme === "dark") {
themeIcon.classList.remove("fa-moon");
themeIcon.classList.add("fa-sun");
} else {
themeIcon.classList.remove("fa-sun");
themeIcon.classList.add("fa-moon");
}
});
// 检查系统主题偏好
if (!savedTheme) {
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (prefersDark) {
document.documentElement.setAttribute("data-theme", "dark");
themeIcon.classList.remove("fa-moon");
themeIcon.classList.add("fa-sun");
localStorage.setItem("theme", "dark");
}
}
// 注册 Service Worker 来处理缩略图缓存
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/static/sw.js')
.then(function(registration) {
console.log('SW registered: ', registration);
})
.catch(function(registrationError) {
console.log('SW registration failed: ', registrationError);
});
});
}
});
</script>
</body>
</html>
<div class="preview-container">
<div id="previewContainer"></div>
<div id="previewInfo" class="preview-info"></div>
</div>
</div>
{% endblock %}

View File

@@ -1,13 +1,13 @@
{
"version": 2,
"devCommand": "python ./app.py",
"builds": [
{
"src": "app.py",
"use": "@vercel/python",
"config": {
"pythonVersion": "3.11"
}
"use": "@vercel/python"
},
{
"src": "/static/**",
"use": "@vercel/static"
}
],
"routes": [