init project

This commit is contained in:
2025-11-02 16:10:27 +08:00
commit a67272f938
16 changed files with 2409 additions and 0 deletions

10
.env.example Normal file
View File

@@ -0,0 +1,10 @@
ACCESS_KEY_ID=YOUR_R2_ACCESS_KEY_ID
SECRET_ACCESS_KEY=YOUR_R2_SECRET_ACCESS_KEY
R2_BUCKET_NAME=drive
R2_ENDPOINT_URL=https://<your-account-id>.r2.cloudflarestorage.com
R2_REGION=auto
REDIS_URL=redis://localhost:6379/0
# 可选: 公共访问 URL
R2_PUBLIC_URL=https://pub-<bucket-name>.r2.dev

31
.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# flask api
*.pyc
__pycache__/
instance/
.pytest_cache/
.coverage
htmlcov/
dist/
build/
*.egg-info/
# vercel
.vercel
*.log
*.pyc
__pycache__
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
.vercel
.vercel

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["humao.rest-client"]
}

10
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"python.defaultInterpreterPath": ".venv/bin/python",
"python.linting.enabled": true,
"python.linting.ruffEnabled": true,
"python.formatting.provider": "ruff",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
}
}

420
app.py Normal file
View File

@@ -0,0 +1,420 @@
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
# 加载环境变量
load_dotenv()
app = Flask(__name__, static_url_path="/static", static_folder="static")
# 缩略图默认 TTL可通过环境变量覆盖
THUMB_TTL = int(os.getenv("THUMB_TTL_SECONDS", "3600"))
# 注册一个安全的 filesizeformat 过滤器,处理 None 和非数字值
@app.template_filter("filesizeformat")
def filesizeformat_filter(value):
try:
if value is None:
return "-"
num = float(value) # 使用 float 而不是 int 以保持精度
except Exception:
return "-"
for unit in ["B", "KB", "MB", "GB", "TB"]:
if num < 1024:
# 对于字节,显示整数
if unit == "B":
return f"{int(num)}{unit}"
# 其他单位保留两位小数
return f"{num:.2f}{unit}"
num = num / 1024.0
return f"{num:.2f}PB"
# 注册一个文件图标过滤器
@app.template_filter("fileicon")
def fileicon_filter(filename):
if not filename:
return "fas fa-file"
ext = filename.lower().split(".")[-1] if "." in filename else ""
# 图片文件
if ext in ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"]:
return "fas fa-image"
# 音频文件
if ext in ["mp3", "wav", "ogg", "flac", "m4a", "aac"]:
return "fas fa-music"
# 视频文件
if ext in ["mp4", "webm", "avi", "mov", "wmv", "flv", "mkv"]:
return "fas fa-video"
# 文档文件
if ext in ["pdf", "doc", "docx", "txt", "md", "rtf"]:
return "fas fa-file-alt"
# 压缩文件
if ext in ["zip", "rar", "7z", "tar", "gz"]:
return "fas fa-file-archive"
# 代码文件
if ext in ["py", "js", "html", "css", "java", "cpp", "c", "php"]:
return "fas fa-file-code"
# 表格文件
if ext in ["xls", "xlsx", "csv"]:
return "fas fa-file-excel"
# 演示文件
if ext in ["ppt", "pptx"]:
return "fas fa-file-powerpoint"
# 默认文件图标
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}"
def format_timestamp(timestamp) -> str:
"""
格式化时间戳为人类可读的格式
"""
if isinstance(timestamp, datetime):
return timestamp.strftime("%Y-%m-%d %H:%M:%S")
return str(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)
def get_file_url(key: str) -> str:
"""生成通过服务器访问文件的 URL"""
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")
app.run(host=host, port=port, debug=True)

40
debug/apitest.http Normal file
View File

@@ -0,0 +1,40 @@
# "REST Client" from Exensions
# 记得安装 “ REST Client ” 插件
# @baseUri=http://localhost:3000
@baseUri=http://127.0.0.1:5000
### root - app
GET {{baseUri}}/
### get - app
GET {{baseUri}}/get
### post - app
POST {{baseUri}}/post
Content-Type: application/json
{
"msg": "hello? couly you get it?"
}
### double get - app
GET {{baseUri}}/double
### double post - app
POST {{baseUri}}/double
Content-Type: application/json
{
"msg": "a post for doule"
}
### Blueprint - blueprint
GET {{baseUri}}/blueprint/
### Blueprint - g - blueprint
GET {{baseUri}}/blueprint/g
### lib
GET {{baseUri}}/lib

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"engines": {
"node": "22.x"
}
}

42
pyproject.toml Normal file
View File

@@ -0,0 +1,42 @@
[project]
name = "musictool"
version = "0.1.0"
description = "A music tool API built with FastAPI"
authors = [{ name = "RhenCloud", email = "i@rhen.cloud" }]
readme = "README.md"
requires-python = ">=3.9"
dependencies = [
"flask>=3.1.2",
"uvicorn[standard]>=0.38.0",
"pydantic>=2.12.3",
"requests>=2.32.5",
"boto3>=1.40.64",
"dotenv>=0.9.9",
"pillow>=11.3.0",
]
[project.optional-dependencies]
dev = ["ruff>=0.1.6"]
# 添加运行脚本配置
# [project.scripts]
# start = "flask run api:app"
# dev = "ruff run --watch api/app.py"
# [build-system]
# requires = ["setuptools>=45", "wheel"]
# build-backend = "setuptools.build_meta"
[tool.ruff]
line-length = 88
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"C", # flake8-comprehensions
"B", # flake8-bugbear
]
extend-ignore = [
"E501", # line too long, handled by black
]

1
runtime.txt Normal file
View File

@@ -0,0 +1 @@
python-3.11.4

7
static/favicon.svg Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 640 640">
<!-- Font Awesome Free v7.1.0 by @fontawesome - https://fontawesome.com License -
https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc. -->
<path fill="#4FC3F7"
d="M32 400C32 479.5 96.5 544 176 544L480 544C550.7 544 608 486.7 608 416C608 364.4 577.5 319.9 533.5 299.7C540.2 286.6 544 271.7 544 256C544 203 501 160 448 160C430.3 160 413.8 164.8 399.6 173.1C375.5 127.3 327.4 96 272 96C192.5 96 128 160.5 128 240C128 248 128.7 255.9 129.9 263.5C73 282.7 32 336.6 32 400z" />
</svg>

After

Width:  |  Height:  |  Size: 644 B

37
static/sw.js Normal file
View File

@@ -0,0 +1,37 @@
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;
});
});
})
);
}
});

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180">
<rect width="320" height="180" fill="#e9ecef" />
<g fill="#adb5bd" font-family="Arial, Helvetica, sans-serif" font-size="14">
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle">No preview</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 378 B

9
templates/footer.html Normal file
View File

@@ -0,0 +1,9 @@
<footer class="site-footer">
<div class="container-footer">
<span>© {{ current_year if current_year else '2025' }} RhenCloud</span>
<span>·</span>
<a href="https://github.com/RhenCloud/R2-Index" target="_blank" rel="noopener">GitHub</a>
<span>·</span>
<a href="mailto:i@rhen.cloud">联系</a>
</div>
</footer>

403
templates/index.html Normal file
View File

@@ -0,0 +1,403 @@
<!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">
<h1>
RhenCloud's R2 Drive
<div>
<button class="theme-toggle" aria-label="切换深色模式">
<i class="fas fa-moon"></i>
</button>
<button class="view-toggle" aria-label="切换视图">
<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 %}
</div>
{% if entries %}
<table class="files-table">
<thead>
<tr>
<th>名称</th>
<th>大小</th>
<th>最后修改时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr>
<td>
{% if entry.is_dir %}
<i class="file-icon folder fas fa-folder"></i>
<a href="/{{ entry.key.rstrip('/') }}">{{ entry.name }}</a>
{% else %}
<i class="file-icon file {{ entry.name|fileicon }}"></i>
<a href="{{ entry.file_url }}" target="_blank">{{ entry.name }}</a>
{% endif %}
</td>
<td class="file-size">
{% if not entry.is_dir %} {{ entry.size|filesizeformat }} {% else %} - {% endif %}
</td>
<td class="last-modified">
{% 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>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="grid-container" id="gridContainer">
{% for entry in entries %}
<div class="grid-card">
{% 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>
{% else %} {% if entry.name|fileicon == 'fas fa-image' %}
<a href="{{ entry.file_url }}" target="_blank">
<img class="grid-thumb" src="{{ entry.file_url }}" alt="{{ entry.name }}" />
</a>
{% 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>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-message">
<p>存储桶为空或未找到任何文件</p>
</div>
{% endif %}
</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>

1365
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

19
vercel.json Normal file
View File

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