mirror of
https://github.com/RhenCloud/Cloud-Index.git
synced 2025-12-06 15:26:10 +08:00
Compare commits
9 Commits
e404764ea9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f528483713 | |||
| 9d5f753621 | |||
| d0bd44c526 | |||
| 730ee20048 | |||
| 724351a551 | |||
| 97bcf978cf | |||
| 0a640a91fc | |||
| 33fe06c59e | |||
| 07a8fafff2 |
88
.env.example
88
.env.example
@@ -1,51 +1,75 @@
|
|||||||
# 存储类型选择
|
# ==================== 存储类型选择 ====================
|
||||||
## Done
|
# 支持的存储类型 (Supported Storage Types):
|
||||||
# r2 | Cloudflare R2
|
# - r2: Cloudflare R2
|
||||||
# github | GitHub Repository
|
# - github: GitHub Repository
|
||||||
## TODO LIST:
|
# - onedrive: Microsoft OneDrive
|
||||||
# cnbcool | Tencent cnb.cool
|
|
||||||
# s3 | Amazon S3
|
|
||||||
# Microsoft Onedrive
|
|
||||||
STORAGE_TYPE=r2
|
STORAGE_TYPE=r2
|
||||||
|
|
||||||
# ==================== Cloudflare R2 配置 ====================
|
# ==================== Cloudflare R2 配置 ====================
|
||||||
# R2 访问凭证
|
# 仅当 STORAGE_TYPE=r2 时需要配置
|
||||||
ACCESS_KEY_ID=YOUR_R2_ACCESS_KEY_ID
|
|
||||||
SECRET_ACCESS_KEY=YOUR_R2_SECRET_ACCESS_KEY
|
# R2 账户 ID (Cloudflare 账户 ID)
|
||||||
|
R2_ACCOUNT_ID=your-account-id
|
||||||
|
|
||||||
|
# R2 访问密钥 ID
|
||||||
|
R2_ACCESS_KEY_ID=YOUR_R2_ACCESS_KEY_ID
|
||||||
|
|
||||||
|
# R2 访问密钥密码
|
||||||
|
R2_SECRET_ACCESS_KEY=YOUR_R2_SECRET_ACCESS_KEY
|
||||||
|
|
||||||
# R2 存储桶名称
|
# R2 存储桶名称
|
||||||
R2_BUCKET_NAME=drive
|
R2_BUCKET_NAME=drive
|
||||||
|
|
||||||
# R2 Endpoint URL
|
# R2 公共访问域名 (可选,用于生成公共 URL)
|
||||||
R2_ENDPOINT_URL=https://<your-account-id>.r2.cloudflarestorage.com
|
# 示例: https://pub-<bucket-name>.r2.dev
|
||||||
|
R2_PUBLIC_DOMAIN=https://pub-<bucket-name>.r2.dev
|
||||||
# R2 区域 (默认: auto)
|
|
||||||
R2_REGION=auto
|
|
||||||
|
|
||||||
# R2 公共访问 URL (可选,例如: https://pub-<bucket-name>.r2.dev)
|
|
||||||
R2_PUBLIC_URL=https://pub-<bucket-name>.r2.dev
|
|
||||||
|
|
||||||
# R2 预签名 URL 过期时间(秒,默认: 3600)
|
|
||||||
R2_PRESIGN_EXPIRES=3600
|
|
||||||
|
|
||||||
# ==================== GitHub 存储配置 ====================
|
# ==================== GitHub 存储配置 ====================
|
||||||
# GitHub 仓库所有者
|
# 仅当 STORAGE_TYPE=github 时需要配置
|
||||||
GITHUB_REPO_OWNER=your-username
|
|
||||||
|
|
||||||
# GitHub 仓库名称
|
# GitHub 仓库地址 (格式: owner/repo)
|
||||||
GITHUB_REPO_NAME=your-repo
|
# 示例: RhenCloud/Cloud-Index
|
||||||
|
GITHUB_REPO=your-username/your-repo
|
||||||
|
|
||||||
# GitHub 访问令牌 (需要 repo 权限)
|
# GitHub 访问令牌 (需要 repo 权限)
|
||||||
GITHUB_ACCESS_TOKEN=your-access-token
|
# 生成: https://github.com/settings/tokens
|
||||||
|
GITHUB_TOKEN=your-access-token
|
||||||
|
|
||||||
# GitHub 分支名称 (默认: main)
|
# GitHub 分支名称 (默认: main)
|
||||||
GITHUB_BRANCH=main
|
GITHUB_BRANCH=main
|
||||||
|
|
||||||
# GitHub Raw 文件反向代理 URL (可选,用于加速访问)
|
# ==================== Microsoft OneDrive 配置 ====================
|
||||||
# 例如: https://raw.fastgit.org 或 https://ghproxy.com/https://raw.githubusercontent.com
|
# 仅当 STORAGE_TYPE=onedrive 时需要配置
|
||||||
# 留空则使用官方 raw.githubusercontent.com
|
|
||||||
GITHUB_RAW_PROXY_URL=
|
# OneDrive 访问令牌
|
||||||
|
ONEDRIVE_REFRESH_TOKEN=your-refresh-token
|
||||||
|
ONEDRIVE_CLIENT_ID=your-client-id
|
||||||
|
ONEDRIVE_CLIENT_SECRET=your-client-secret
|
||||||
|
|
||||||
|
# OneDrive 文件夹 ID (可选,留空则使用根目录)
|
||||||
|
# 默认值: 使用 /me/drive/root (OneDrive 根目录)
|
||||||
|
# ONEDRIVE_FOLDER_ID=folder-item-id
|
||||||
|
|
||||||
# ==================== 应用配置 ====================
|
# ==================== 应用配置 ====================
|
||||||
# 缩略图缓存时间(秒,默认: 3600)
|
|
||||||
|
# 服务器监听地址
|
||||||
|
# 0.0.0.0: 监听所有网卡
|
||||||
|
# 127.0.0.1: 仅本地访问
|
||||||
|
HOST=0.0.0.0
|
||||||
|
|
||||||
|
# 服务器监听端口 (默认: 5000)
|
||||||
|
PORT=5000
|
||||||
|
|
||||||
|
# 调试模式 (true/false,默认: false)
|
||||||
|
# 生产环境应设置为 false
|
||||||
|
DEBUG=false
|
||||||
|
|
||||||
|
# ==================== 缓存和 URL 配置 ====================
|
||||||
|
|
||||||
|
# 缩略图缓存时间 (秒,默认: 3600)
|
||||||
|
# 用于浏览器缓存缩略图,减少服务器负担
|
||||||
THUMB_TTL_SECONDS=3600
|
THUMB_TTL_SECONDS=3600
|
||||||
|
|
||||||
|
# 预签名 URL 过期时间 (秒,默认: 3600)
|
||||||
|
# 用于生成临时访问链接,3600 秒 = 1 小时
|
||||||
|
PRESIGNED_URL_EXPIRES=3600
|
||||||
|
|||||||
70
README.md
70
README.md
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
我在这个项目上至少花费了:
|
我在这个项目上至少花费了:
|
||||||
|
|
||||||
[](https://codetime.dev)
|
[](https://codetime.dev)
|
)](https://wakapi.rhen.cloud)
|
||||||
|
|
||||||
## 特性
|
## 特性
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
- [x] Github Repo 储存支持
|
- [x] Github Repo 储存支持
|
||||||
- [ ] Github Release 储存支持
|
- [ ] Github Release 储存支持
|
||||||
- [ ] Microsoft Onedrive 储存支持
|
- [x] Microsoft Onedrive 储存支持
|
||||||
- [ ] 基于数据库的用户/权限管理
|
- [ ] 基于数据库的用户/权限管理
|
||||||
- [ ] 操作日志记录
|
- [ ] 操作日志记录
|
||||||
- [ ] Office Documents 预览支持
|
- [ ] Office Documents 预览支持
|
||||||
@@ -48,6 +48,7 @@
|
|||||||
- **Cloudflare R2** - Cloudflare 的对象存储服务(S3 兼容)
|
- **Cloudflare R2** - Cloudflare 的对象存储服务(S3 兼容)
|
||||||
- **Amazon S3** - Amazon S3 对象存储服务
|
- **Amazon S3** - Amazon S3 对象存储服务
|
||||||
- **GitHub Repository** - 基于 GitHub Repository 的存储服务
|
- **GitHub Repository** - 基于 GitHub Repository 的存储服务
|
||||||
|
- **Microsoft Onedrive** - Microsoft Onedrive 云端硬盘
|
||||||
<!-- - **Github Release** - 基于 GitHub Release 的存储服务 -->
|
<!-- - **Github Release** - 基于 GitHub Release 的存储服务 -->
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
@@ -96,20 +97,18 @@ python app.py
|
|||||||
```env
|
```env
|
||||||
STORAGE_TYPE=r2
|
STORAGE_TYPE=r2
|
||||||
|
|
||||||
|
# R2 账户 ID
|
||||||
|
R2_ACCOUNT_ID=your-account-id
|
||||||
|
|
||||||
# R2 访问凭证
|
# R2 访问凭证
|
||||||
ACCESS_KEY_ID=your_access_key_id
|
R2_ACCESS_KEY_ID=your_access_key_id
|
||||||
SECRET_ACCESS_KEY=your_secret_access_key
|
R2_SECRET_ACCESS_KEY=your_secret_access_key
|
||||||
|
|
||||||
# R2 存储桶配置
|
# R2 存储桶配置
|
||||||
R2_BUCKET_NAME=your_bucket_name
|
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
|
R2_PUBLIC_DOMAIN=https://pub-your-bucket.r2.dev
|
||||||
|
|
||||||
# 可选:预签名 URL 过期时间(秒)
|
|
||||||
R2_PRESIGN_EXPIRES=3600
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### GitHub Repository 配置
|
### GitHub Repository 配置
|
||||||
@@ -117,26 +116,15 @@ R2_PRESIGN_EXPIRES=3600
|
|||||||
```env
|
```env
|
||||||
STORAGE_TYPE=github
|
STORAGE_TYPE=github
|
||||||
|
|
||||||
# GitHub 仓库所有者(用户名或组织)
|
# GitHub 仓库 (格式: owner/repo)
|
||||||
GITHUB_REPO_OWNER=your-username
|
GITHUB_REPO=your-username/your-repo
|
||||||
|
|
||||||
# GitHub 仓库名称
|
|
||||||
GITHUB_REPO_NAME=your-repo
|
|
||||||
|
|
||||||
# GitHub 个人访问令牌(需要 repo 权限)
|
# GitHub 个人访问令牌(需要 repo 权限)
|
||||||
# 获取方式:https://github.com/settings/tokens
|
# 获取方式:https://github.com/settings/tokens
|
||||||
GITHUB_ACCESS_TOKEN=ghp_your_token_here
|
GITHUB_TOKEN=ghp_your_token_here
|
||||||
|
|
||||||
# GitHub 分支名称(可选,默认: main)
|
# GitHub 分支名称(可选,默认: main)
|
||||||
GITHUB_BRANCH=main
|
GITHUB_BRANCH=main
|
||||||
|
|
||||||
# GitHub Raw 文件反向代理 URL(可选,用于加速访问)
|
|
||||||
# 常用反向代理:
|
|
||||||
# - https://raw.fastgit.org (推荐,速度快)
|
|
||||||
# - https://ghproxy.com
|
|
||||||
# - https://raw.kgithub.com
|
|
||||||
# 留空则使用官方 raw.githubusercontent.com(国内可能较慢)
|
|
||||||
GITHUB_RAW_PROXY_URL=https://raw.fastgit.org
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
@@ -144,6 +132,8 @@ GITHUB_RAW_PROXY_URL=https://raw.fastgit.org
|
|||||||
```bash
|
```bash
|
||||||
cloud-index/
|
cloud-index/
|
||||||
├── app.py # Flask 应用主入口
|
├── app.py # Flask 应用主入口
|
||||||
|
├── config.py # 统一配置管理
|
||||||
|
├── utils.py # 工具函数模块
|
||||||
├── handlers/
|
├── handlers/
|
||||||
│ └── routes.py # 路由处理器
|
│ └── routes.py # 路由处理器
|
||||||
├── storages/ # 存储后端实现
|
├── storages/ # 存储后端实现
|
||||||
@@ -153,11 +143,16 @@ cloud-index/
|
|||||||
│ ├── r2.py # Cloudflare R2 实现
|
│ ├── r2.py # Cloudflare R2 实现
|
||||||
│ └── github.py # GitHub Repository 实现
|
│ └── github.py # GitHub Repository 实现
|
||||||
├── templates/ # HTML 模板
|
├── templates/ # HTML 模板
|
||||||
|
│ ├── base.html
|
||||||
│ ├── index.html
|
│ ├── index.html
|
||||||
│ └── footer.html
|
│ └── footer.html
|
||||||
├── static/ # 静态资源
|
├── static/ # 静态资源
|
||||||
│ └── thumbs/
|
│ ├── css/
|
||||||
|
│ │ └── main.css
|
||||||
|
│ └── js/
|
||||||
|
│ └── main.js
|
||||||
├── .env.example # 环境变量示例
|
├── .env.example # 环境变量示例
|
||||||
|
├── pyproject.toml # 项目配置和依赖
|
||||||
└── requirements.txt # Python 依赖
|
└── requirements.txt # Python 依赖
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -272,29 +267,22 @@ A: 参考项目结构中的"添加新的存储后端"部分,继承 `BaseStorag
|
|||||||
|
|
||||||
**基本流程**:
|
**基本流程**:
|
||||||
|
|
||||||
1. **Fork 项目**
|
1. **Fork 项目**
|
||||||
点击 GitHub 页面右上角的 "Fork" 按钮
|
点击 GitHub 页面右上角的 "Fork" 按钮
|
||||||
|
|
||||||
2. **克隆到本地**
|
2. **克隆到本地**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/your-username/Cloud-Index.git
|
git clone https://github.com/your-username/Cloud-Index.git
|
||||||
cd Cloud-Index
|
cd Cloud-Index
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **创建功能分支**
|
3. **进行开发**
|
||||||
|
|
||||||
```bash
|
|
||||||
git checkout -b feature/your-feature-name
|
|
||||||
# 或修复 Bug: git checkout -b fix/bug-description
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **进行开发**
|
|
||||||
- 遵循现有的代码风格
|
- 遵循现有的代码风格
|
||||||
- 添加必要的注释
|
- 添加必要的注释
|
||||||
- 确保代码能正常运行
|
- 确保代码能正常运行
|
||||||
|
|
||||||
5. **提交更改**
|
4. **提交更改**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add .
|
git add .
|
||||||
@@ -302,13 +290,13 @@ A: 参考项目结构中的"添加新的存储后端"部分,继承 `BaseStorag
|
|||||||
# 或 "fix: 修复某某问题"
|
# 或 "fix: 修复某某问题"
|
||||||
```
|
```
|
||||||
|
|
||||||
6. **推送到 GitHub**
|
5. **推送到 GitHub**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git push origin feature/your-feature-name
|
git push origin main
|
||||||
```
|
```
|
||||||
|
|
||||||
7. **创建 Pull Request**
|
6. **创建 Pull Request**
|
||||||
- 在 GitHub 上打开你的 Fork
|
- 在 GitHub 上打开你的 Fork
|
||||||
- 点击 "New Pull Request"
|
- 点击 "New Pull Request"
|
||||||
- 填写 PR 描述,说明你的更改内容和原因
|
- 填写 PR 描述,说明你的更改内容和原因
|
||||||
|
|||||||
76
app.py
76
app.py
@@ -1,14 +1,15 @@
|
|||||||
import os
|
|
||||||
import tomllib
|
import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import dotenv
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
|
import utils
|
||||||
|
from config import Config
|
||||||
from handlers.routes import main_route
|
from handlers.routes import main_route
|
||||||
from storages.factory import StorageFactory
|
from storages.factory import StorageFactory
|
||||||
|
|
||||||
dotenv.load_dotenv()
|
# 验证配置
|
||||||
|
Config.validate()
|
||||||
|
|
||||||
|
|
||||||
# 从 pyproject.toml 读取版本号
|
# 从 pyproject.toml 读取版本号
|
||||||
@@ -34,73 +35,18 @@ app.register_blueprint(main_route)
|
|||||||
# 初始化存储(使用工厂模式)
|
# 初始化存储(使用工厂模式)
|
||||||
storage = StorageFactory.get_storage()
|
storage = StorageFactory.get_storage()
|
||||||
|
|
||||||
# 缩略图默认 TTL(秒),可通过环境变量覆盖
|
|
||||||
THUMB_TTL = int(os.environ.get("THUMB_TTL_SECONDS", "3600"))
|
|
||||||
|
|
||||||
|
# 注册模板过滤器
|
||||||
# 注册一个安全的 filesizeformat 过滤器,处理 None 和非数字值
|
|
||||||
@app.template_filter("filesizeformat")
|
@app.template_filter("filesizeformat")
|
||||||
def filesizeformat_filter(value):
|
def filesizeformat_filter(value):
|
||||||
try:
|
"""格式化文件大小"""
|
||||||
if value is None:
|
return utils.format_file_size(value)
|
||||||
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")
|
@app.template_filter("fileicon")
|
||||||
def fileicon_filter(filename):
|
def fileicon_filter(filename):
|
||||||
if not filename:
|
"""获取文件图标"""
|
||||||
return "fas fa-file"
|
return utils.get_file_icon(filename)
|
||||||
|
|
||||||
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_public_url(key: str) -> str:
|
def get_public_url(key: str) -> str:
|
||||||
@@ -135,6 +81,4 @@ def inject_version():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
port = int(os.environ.get("PORT", 5000))
|
app.run(host=Config.HOST, port=Config.PORT, debug=Config.DEBUG)
|
||||||
host = os.environ.get("HOST", "0.0.0.0")
|
|
||||||
app.run(host=host, port=port, debug=True)
|
|
||||||
|
|||||||
104
config.py
Normal file
104
config.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"""
|
||||||
|
配置管理模块
|
||||||
|
集中管理所有环境变量和应用配置
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import dotenv
|
||||||
|
|
||||||
|
# 加载环境变量
|
||||||
|
dotenv.load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""应用配置类"""
|
||||||
|
|
||||||
|
# 存储配置
|
||||||
|
STORAGE_TYPE: str = os.getenv("STORAGE_TYPE", "").lower()
|
||||||
|
|
||||||
|
# R2 配置
|
||||||
|
R2_ACCOUNT_ID: Optional[str] = os.getenv("R2_ACCOUNT_ID")
|
||||||
|
R2_ACCESS_KEY_ID: Optional[str] = os.getenv("R2_ACCESS_KEY_ID")
|
||||||
|
R2_SECRET_ACCESS_KEY: Optional[str] = os.getenv("R2_SECRET_ACCESS_KEY")
|
||||||
|
R2_BUCKET_NAME: Optional[str] = os.getenv("R2_BUCKET_NAME")
|
||||||
|
R2_PUBLIC_DOMAIN: Optional[str] = os.getenv("R2_PUBLIC_DOMAIN")
|
||||||
|
|
||||||
|
# GitHub 配置
|
||||||
|
GITHUB_TOKEN: Optional[str] = os.getenv("GITHUB_TOKEN")
|
||||||
|
GITHUB_REPO: Optional[str] = os.getenv("GITHUB_REPO") # 格式: owner/repo
|
||||||
|
GITHUB_BRANCH: str = os.getenv("GITHUB_BRANCH", "main")
|
||||||
|
|
||||||
|
# OneDrive 配置
|
||||||
|
ONEDRIVE_REFRESH_TOKEN: Optional[str] = os.getenv("ONEDRIVE_REFRESH_TOKEN")
|
||||||
|
ONEDRIVE_CLIENT_ID: Optional[str] = os.getenv("ONEDRIVE_CLIENT_ID")
|
||||||
|
ONEDRIVE_CLIENT_SECRET: Optional[str] = os.getenv("ONEDRIVE_CLIENT_SECRET")
|
||||||
|
ONEDRIVE_FOLDER_ID: Optional[str] = os.getenv("ONEDRIVE_FOLDER_ID") # 可选,默认使用 /me/drive/root
|
||||||
|
ONEDRIVE_REDIRECT_URI: Optional[str] = os.getenv("ONEDRIVE_REDIRECT_URI") # 可选,刷新令牌时某些应用需要
|
||||||
|
|
||||||
|
# 应用配置
|
||||||
|
HOST: str = os.getenv("HOST", "0.0.0.0")
|
||||||
|
PORT: int = int(os.getenv("PORT", "5000"))
|
||||||
|
DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true"
|
||||||
|
|
||||||
|
# 缩略图配置
|
||||||
|
THUMB_TTL_SECONDS: int = int(os.getenv("THUMB_TTL_SECONDS", "3600"))
|
||||||
|
THUMB_SIZE: tuple[int, int] = (300, 300) # 缩略图尺寸
|
||||||
|
|
||||||
|
# URL过期时间配置
|
||||||
|
PRESIGNED_URL_EXPIRES: int = int(os.getenv("PRESIGNED_URL_EXPIRES", "3600"))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate(cls) -> None:
|
||||||
|
"""验证必需的配置项是否已设置"""
|
||||||
|
if not cls.STORAGE_TYPE:
|
||||||
|
raise ValueError("STORAGE_TYPE environment variable is not set. Supported types: r2, github, onedrive")
|
||||||
|
|
||||||
|
if cls.STORAGE_TYPE == "r2":
|
||||||
|
required = ["R2_ACCOUNT_ID", "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "R2_BUCKET_NAME"]
|
||||||
|
missing = [key for key in required if not getattr(cls, key)]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"Missing required R2 configuration: {', '.join(missing)}")
|
||||||
|
|
||||||
|
elif cls.STORAGE_TYPE == "github":
|
||||||
|
required = ["GITHUB_TOKEN", "GITHUB_REPO"]
|
||||||
|
missing = [key for key in required if not getattr(cls, key)]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"Missing required GitHub configuration: {', '.join(missing)}")
|
||||||
|
|
||||||
|
elif cls.STORAGE_TYPE == "onedrive":
|
||||||
|
required = ["ONEDRIVE_REFRESH_TOKEN", "ONEDRIVE_CLIENT_ID", "ONEDRIVE_CLIENT_SECRET"]
|
||||||
|
missing = [key for key in required if not getattr(cls, key)]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"Missing required OneDrive configuration: {', '.join(missing)}")
|
||||||
|
|
||||||
|
elif cls.STORAGE_TYPE not in ["r2", "github", "onedrive"]:
|
||||||
|
raise ValueError(f"Unsupported storage type: {cls.STORAGE_TYPE}. Supported types: r2, github, onedrive")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_storage_config(cls) -> dict:
|
||||||
|
"""获取当前存储类型的配置字典"""
|
||||||
|
if cls.STORAGE_TYPE == "r2":
|
||||||
|
return {
|
||||||
|
"account_id": cls.R2_ACCOUNT_ID,
|
||||||
|
"access_key_id": cls.R2_ACCESS_KEY_ID,
|
||||||
|
"secret_access_key": cls.R2_SECRET_ACCESS_KEY,
|
||||||
|
"bucket_name": cls.R2_BUCKET_NAME,
|
||||||
|
"public_domain": cls.R2_PUBLIC_DOMAIN,
|
||||||
|
}
|
||||||
|
elif cls.STORAGE_TYPE == "github":
|
||||||
|
return {
|
||||||
|
"token": cls.GITHUB_TOKEN,
|
||||||
|
"repo": cls.GITHUB_REPO,
|
||||||
|
"branch": cls.GITHUB_BRANCH,
|
||||||
|
}
|
||||||
|
elif cls.STORAGE_TYPE == "onedrive":
|
||||||
|
return {
|
||||||
|
"client_id": cls.ONEDRIVE_CLIENT_ID,
|
||||||
|
"client_secret": cls.ONEDRIVE_CLIENT_SECRET,
|
||||||
|
"refresh_token": cls.ONEDRIVE_REFRESH_TOKEN,
|
||||||
|
"folder_id": cls.ONEDRIVE_FOLDER_ID,
|
||||||
|
"redirect_uri": cls.ONEDRIVE_REDIRECT_URI,
|
||||||
|
}
|
||||||
|
return {}
|
||||||
@@ -1,33 +1,24 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import os
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from flask import Blueprint, Response, abort, jsonify, redirect, render_template, request
|
from flask import Blueprint, Response, abort, jsonify, redirect, render_template, request
|
||||||
|
|
||||||
|
from config import Config
|
||||||
from storages.factory import StorageFactory
|
from storages.factory import StorageFactory
|
||||||
|
|
||||||
main_route = Blueprint("main", __name__)
|
main_route = Blueprint("main", __name__)
|
||||||
|
|
||||||
# 初始化存储(使用工厂模式)
|
# 延迟初始化的存储实例
|
||||||
storage = StorageFactory.get_storage()
|
_storage = None
|
||||||
|
|
||||||
# 缩略图默认 TTL(秒),可通过环境变量覆盖
|
|
||||||
THUMB_TTL = int(os.environ.get("THUMB_TTL_SECONDS", "3600"))
|
|
||||||
|
|
||||||
|
|
||||||
def format_timestamp(timestamp) -> str:
|
def get_storage():
|
||||||
"""
|
"""获取存储实例(延迟初始化)"""
|
||||||
格式化时间戳为人类可读的格式
|
global _storage
|
||||||
"""
|
if _storage is None:
|
||||||
return storage.format_timestamp(timestamp)
|
_storage = StorageFactory.get_storage()
|
||||||
|
return _storage
|
||||||
|
|
||||||
def get_public_url(key: str) -> str:
|
|
||||||
"""
|
|
||||||
生成对象的公共访问 URL
|
|
||||||
"""
|
|
||||||
return storage.get_public_url(key)
|
|
||||||
|
|
||||||
|
|
||||||
def get_file_url(key: str) -> str:
|
def get_file_url(key: str) -> str:
|
||||||
@@ -37,6 +28,7 @@ def get_file_url(key: str) -> str:
|
|||||||
|
|
||||||
def build_file_entry(obj: Dict[str, Any], prefix: str) -> Dict[str, Any] | None:
|
def build_file_entry(obj: Dict[str, Any], prefix: str) -> Dict[str, Any] | None:
|
||||||
"""根据对象信息构建文件条目。"""
|
"""根据对象信息构建文件条目。"""
|
||||||
|
storage = get_storage()
|
||||||
key = obj.get("Key", "")
|
key = obj.get("Key", "")
|
||||||
if not key:
|
if not key:
|
||||||
return None
|
return None
|
||||||
@@ -53,18 +45,13 @@ def build_file_entry(obj: Dict[str, Any], prefix: str) -> Dict[str, Any] | None:
|
|||||||
"name": rel_name,
|
"name": rel_name,
|
||||||
"key": key,
|
"key": key,
|
||||||
"size": obj.get("Size"),
|
"size": obj.get("Size"),
|
||||||
"last_modified": format_timestamp(obj.get("LastModified")),
|
"last_modified": storage.format_timestamp(obj.get("LastModified")),
|
||||||
"is_dir": False,
|
"is_dir": False,
|
||||||
"file_url": get_file_url(key),
|
"file_url": get_file_url(key),
|
||||||
}
|
}
|
||||||
|
|
||||||
public_url = get_public_url(key)
|
# 性能优化:避免在列表阶段为每个文件预取公共/预签名链接
|
||||||
if public_url:
|
# 统一走 /file/<key> 路由,点击时再获取所需链接
|
||||||
entry["public_url"] = public_url
|
|
||||||
|
|
||||||
presigned = storage.generate_presigned_url(key)
|
|
||||||
if presigned:
|
|
||||||
entry["presigned_url"] = presigned
|
|
||||||
|
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
@@ -115,6 +102,7 @@ def index():
|
|||||||
返回文件和目录列表的 HTML 页面。
|
返回文件和目录列表的 HTML 页面。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
storage = get_storage()
|
||||||
prefix = request.args.get("prefix", "") or ""
|
prefix = request.args.get("prefix", "") or ""
|
||||||
|
|
||||||
response = storage.list_objects(prefix)
|
response = storage.list_objects(prefix)
|
||||||
@@ -136,6 +124,7 @@ def index():
|
|||||||
def browse(prefix_path):
|
def browse(prefix_path):
|
||||||
"""目录路由。将 URL /a/b 映射为 prefix 'a/b/' 并重用 index 的逻辑。"""
|
"""目录路由。将 URL /a/b 映射为 prefix 'a/b/' 并重用 index 的逻辑。"""
|
||||||
try:
|
try:
|
||||||
|
storage = get_storage()
|
||||||
prefix = prefix_path or ""
|
prefix = prefix_path or ""
|
||||||
if prefix and not prefix.endswith("/"):
|
if prefix and not prefix.endswith("/"):
|
||||||
prefix = prefix + "/"
|
prefix = prefix + "/"
|
||||||
@@ -159,6 +148,7 @@ def browse(prefix_path):
|
|||||||
def serve_file(file_path):
|
def serve_file(file_path):
|
||||||
"""重定向到原始存储 URL,节省服务器资源"""
|
"""重定向到原始存储 URL,节省服务器资源"""
|
||||||
try:
|
try:
|
||||||
|
storage = get_storage()
|
||||||
# 验证文件存在
|
# 验证文件存在
|
||||||
try:
|
try:
|
||||||
storage.get_object_info(file_path)
|
storage.get_object_info(file_path)
|
||||||
@@ -186,6 +176,7 @@ def serve_file(file_path):
|
|||||||
def download_file(file_path):
|
def download_file(file_path):
|
||||||
"""下载文件,支持所有存储类型"""
|
"""下载文件,支持所有存储类型"""
|
||||||
try:
|
try:
|
||||||
|
storage = get_storage()
|
||||||
# 验证文件存在
|
# 验证文件存在
|
||||||
try:
|
try:
|
||||||
storage.get_object_info(file_path)
|
storage.get_object_info(file_path)
|
||||||
@@ -218,9 +209,10 @@ def download_file(file_path):
|
|||||||
@main_route.route("/thumb/<path:file_path>")
|
@main_route.route("/thumb/<path:file_path>")
|
||||||
def thumb(file_path):
|
def thumb(file_path):
|
||||||
"""返回图片的缩略图,使用 Vercel Cache Headers 避免重复从 R2 拉取"""
|
"""返回图片的缩略图,使用 Vercel Cache Headers 避免重复从 R2 拉取"""
|
||||||
|
storage = get_storage()
|
||||||
# 设置更长的缓存控制头以支持浏览器本地缓存
|
# 设置更长的缓存控制头以支持浏览器本地缓存
|
||||||
cache_headers = {
|
cache_headers = {
|
||||||
"Cache-Control": f"public, max-age={THUMB_TTL}",
|
"Cache-Control": f"public, max-age={Config.THUMB_TTL_SECONDS}",
|
||||||
"ETag": f'W/"{hashlib.md5(file_path.encode("utf-8")).hexdigest()}"',
|
"ETag": f'W/"{hashlib.md5(file_path.encode("utf-8")).hexdigest()}"',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +249,7 @@ def thumb(file_path):
|
|||||||
def upload():
|
def upload():
|
||||||
"""上传文件到存储"""
|
"""上传文件到存储"""
|
||||||
try:
|
try:
|
||||||
|
storage = get_storage()
|
||||||
# 检查是否有文件
|
# 检查是否有文件
|
||||||
if "file" not in request.files:
|
if "file" not in request.files:
|
||||||
return jsonify({"success": False, "error": "No file provided"}), 400
|
return jsonify({"success": False, "error": "No file provided"}), 400
|
||||||
@@ -303,6 +296,7 @@ def upload():
|
|||||||
def delete(file_path):
|
def delete(file_path):
|
||||||
"""删除存储中的文件"""
|
"""删除存储中的文件"""
|
||||||
try:
|
try:
|
||||||
|
storage = get_storage()
|
||||||
# 删除文件
|
# 删除文件
|
||||||
success = storage.delete_file(file_path)
|
success = storage.delete_file(file_path)
|
||||||
|
|
||||||
@@ -319,6 +313,7 @@ def delete(file_path):
|
|||||||
def rename(old_key):
|
def rename(old_key):
|
||||||
"""重命名存储中的文件"""
|
"""重命名存储中的文件"""
|
||||||
try:
|
try:
|
||||||
|
storage = get_storage()
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
new_name = data.get("newName")
|
new_name = data.get("newName")
|
||||||
|
|
||||||
@@ -326,9 +321,9 @@ def rename(old_key):
|
|||||||
return jsonify({"success": False, "error": "New name not provided"}), 400
|
return jsonify({"success": False, "error": "New name not provided"}), 400
|
||||||
|
|
||||||
# 构建新的文件路径
|
# 构建新的文件路径
|
||||||
prefix = os.path.dirname(old_key)
|
old_key_parts = old_key.rsplit("/", 1)
|
||||||
if prefix:
|
if len(old_key_parts) > 1:
|
||||||
new_key = f"{prefix}/{new_name}"
|
new_key = f"{old_key_parts[0]}/{new_name}"
|
||||||
else:
|
else:
|
||||||
new_key = new_name
|
new_key = new_name
|
||||||
|
|
||||||
@@ -354,6 +349,7 @@ def rename(old_key):
|
|||||||
def delete_folder_route(prefix):
|
def delete_folder_route(prefix):
|
||||||
"""删除存储中的文件夹"""
|
"""删除存储中的文件夹"""
|
||||||
try:
|
try:
|
||||||
|
storage = get_storage()
|
||||||
if not prefix.endswith("/"):
|
if not prefix.endswith("/"):
|
||||||
prefix += "/"
|
prefix += "/"
|
||||||
success = storage.delete_folder(prefix)
|
success = storage.delete_folder(prefix)
|
||||||
@@ -369,6 +365,7 @@ def delete_folder_route(prefix):
|
|||||||
def rename_folder_route(old_prefix):
|
def rename_folder_route(old_prefix):
|
||||||
"""重命名存储中的文件夹"""
|
"""重命名存储中的文件夹"""
|
||||||
try:
|
try:
|
||||||
|
storage = get_storage()
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
new_name = data.get("newName")
|
new_name = data.get("newName")
|
||||||
|
|
||||||
@@ -379,9 +376,9 @@ def rename_folder_route(old_prefix):
|
|||||||
old_prefix += "/"
|
old_prefix += "/"
|
||||||
|
|
||||||
# 构建新的文件夹路径
|
# 构建新的文件夹路径
|
||||||
parent_prefix = os.path.dirname(os.path.dirname(old_prefix))
|
prefix_parts = old_prefix.rstrip("/").rsplit("/", 1)
|
||||||
if parent_prefix:
|
if len(prefix_parts) > 1:
|
||||||
new_prefix = f"{parent_prefix}/{new_name}/"
|
new_prefix = f"{prefix_parts[0]}/{new_name}/"
|
||||||
else:
|
else:
|
||||||
new_prefix = f"{new_name}/"
|
new_prefix = f"{new_name}/"
|
||||||
|
|
||||||
@@ -405,6 +402,7 @@ def rename_folder_route(old_prefix):
|
|||||||
def copy_item():
|
def copy_item():
|
||||||
"""复制文件或文件夹"""
|
"""复制文件或文件夹"""
|
||||||
try:
|
try:
|
||||||
|
storage = get_storage()
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
source = data.get("source")
|
source = data.get("source")
|
||||||
destination = data.get("destination")
|
destination = data.get("destination")
|
||||||
@@ -437,6 +435,7 @@ def copy_item():
|
|||||||
def move_item():
|
def move_item():
|
||||||
"""移动文件或文件夹"""
|
"""移动文件或文件夹"""
|
||||||
try:
|
try:
|
||||||
|
storage = get_storage()
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
source = data.get("source")
|
source = data.get("source")
|
||||||
destination = data.get("destination")
|
destination = data.get("destination")
|
||||||
@@ -469,6 +468,7 @@ def move_item():
|
|||||||
def create_folder_route():
|
def create_folder_route():
|
||||||
"""创建文件夹"""
|
"""创建文件夹"""
|
||||||
try:
|
try:
|
||||||
|
storage = get_storage()
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
path = data.get("path")
|
path = data.get("path")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "Cloud-Index"
|
name = "Cloud-Index"
|
||||||
version = "0.9.0"
|
version = "0.11.2"
|
||||||
description = "A cloud storage index system based on a number of cloud storage platforms"
|
description = "A cloud storage index system based on a number of cloud storage platforms"
|
||||||
authors = [{ name = "RhenCloud", email = "i@rhen.cloud" }]
|
authors = [{ name = "RhenCloud", email = "i@rhen.cloud" }]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|||||||
149
static/css/buttons.css
Normal file
149
static/css/buttons.css
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* 按钮样式
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 通用按钮基类 */
|
||||||
|
.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;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 删除按钮 */
|
||||||
|
.delete-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--danger-color);
|
||||||
|
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: var(--danger-hover-color);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 下载按钮 */
|
||||||
|
.download-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--success-color);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作链接按钮 */
|
||||||
|
.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.action-link {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 重命名按钮 */
|
||||||
|
.rename-btn,
|
||||||
|
.grid-action-btn.rename {
|
||||||
|
color: var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rename-btn:hover,
|
||||||
|
.grid-action-btn.rename:hover {
|
||||||
|
color: var(--warning-hover-color);
|
||||||
|
}
|
||||||
169
static/css/grid.css
Normal file
169
static/css/grid.css
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* 网格视图样式
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Grid view 容器 */
|
||||||
|
.grid-container {
|
||||||
|
display: none;
|
||||||
|
margin-top: 20px;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-view="grid"] .files-table {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-view="grid"] .grid-container {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 网格卡片 */
|
||||||
|
.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: var(--icon-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-thumb:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
box-shadow: 0 2px 8px var(--shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 网格图标容器 */
|
||||||
|
.grid-icon {
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: var(--icon-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-icon:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
background-color: var(--hover-bg);
|
||||||
|
box-shadow: 0 2px 8px var(--shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-icon i {
|
||||||
|
font-size: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 网格卡片文件名 */
|
||||||
|
.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: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-action-btn.download:hover {
|
||||||
|
background-color: rgba(40, 167, 69, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-action-btn.delete {
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-action-btn.delete:hover {
|
||||||
|
background-color: rgba(220, 53, 69, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.grid-container {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-icon i {
|
||||||
|
font-size: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.grid-container {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-card {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-icon i {
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-name {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-actions {
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
static/css/index.css
Normal file
22
static/css/index.css
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Cloud-Index 主样式表
|
||||||
|
* 这个文件导入所有模块化的 CSS 文件
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 1. 变量和基础样式 */
|
||||||
|
@import url("variables.css");
|
||||||
|
|
||||||
|
/* 2. 按钮样式 */
|
||||||
|
@import url("buttons.css");
|
||||||
|
|
||||||
|
/* 3. 表格视图 */
|
||||||
|
@import url("table.css");
|
||||||
|
|
||||||
|
/* 4. 网格视图 */
|
||||||
|
@import url("grid.css");
|
||||||
|
|
||||||
|
/* 5. 模态框和对话框 */
|
||||||
|
@import url("modals.css");
|
||||||
|
|
||||||
|
/* 6. 实用工具样式 */
|
||||||
|
@import url("utilities.css");
|
||||||
@@ -1,999 +0,0 @@
|
|||||||
: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);
|
|
||||||
--icon-bg: #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[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);
|
|
||||||
--icon-bg: #3a3a3a;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
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;
|
|
||||||
width: calc(100% - 40px);
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 > div {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
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: var(--icon-bg);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-thumb:hover {
|
|
||||||
transform: scale(1.02);
|
|
||||||
box-shadow: 0 2px 8px var(--shadow-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 网格卡片中的图标容器 */
|
|
||||||
.grid-icon {
|
|
||||||
width: 100%;
|
|
||||||
height: 100px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background-color: var(--icon-bg);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-icon:hover {
|
|
||||||
transform: scale(1.02);
|
|
||||||
background-color: var(--hover-bg);
|
|
||||||
box-shadow: 0 2px 8px var(--shadow-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-icon i {
|
|
||||||
font-size: 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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-name-col {
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-name-col:hover {
|
|
||||||
background-color: var(--hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-icon {
|
|
||||||
margin-right: 8px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 音频预览样式 */
|
|
||||||
.preview-audio-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 40px;
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
border-radius: 12px;
|
|
||||||
min-width: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-audio {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 500px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 文本预览样式 */
|
|
||||||
.preview-text {
|
|
||||||
background-color: rgba(255, 255, 255, 0.95);
|
|
||||||
color: #2c3e50;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
max-width: 90vw;
|
|
||||||
max-height: 85vh;
|
|
||||||
overflow: auto;
|
|
||||||
font-family: "Courier New", Courier, monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .preview-text {
|
|
||||||
background-color: rgba(45, 45, 45, 0.95);
|
|
||||||
color: #e1e1e1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 不支持预览的文件样式 */
|
|
||||||
.preview-unsupported {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 60px 40px;
|
|
||||||
background-color: rgba(0, 0, 0, 0.7);
|
|
||||||
border-radius: 12px;
|
|
||||||
color: white;
|
|
||||||
text-align: center;
|
|
||||||
min-width: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-unsupported p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-unsupported .action-link {
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-unsupported .action-link:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
|
||||||
border-color: rgba(255, 255, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
@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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-audio-wrapper {
|
|
||||||
min-width: 300px;
|
|
||||||
padding: 30px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-unsupported {
|
|
||||||
min-width: 300px;
|
|
||||||
padding: 40px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-text {
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-table .action-text {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-table .action-link {
|
|
||||||
padding: 6px;
|
|
||||||
font-size: 1.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-table td,
|
|
||||||
.files-table th {
|
|
||||||
padding: 8px 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 上传进度提示 */
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-name-col {
|
|
||||||
width: auto;
|
|
||||||
max-width: 300px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-name-col a {
|
|
||||||
display: inline-block;
|
|
||||||
max-width: calc(100% - 30px);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-size-col {
|
|
||||||
width: 100px;
|
|
||||||
min-width: 100px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.last-modified-col {
|
|
||||||
width: 180px;
|
|
||||||
min-width: 180px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-col {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-col > * {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.files-table .action-text {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.files-table .action-link {
|
|
||||||
padding: 6px 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 992px) {
|
|
||||||
.last-modified-col {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
.file-name-col {
|
|
||||||
max-width: 200px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.file-size-col {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
.file-name-col {
|
|
||||||
max-width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-table .action-text {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-table .action-link,
|
|
||||||
.files-table button.action-link {
|
|
||||||
padding: 6px;
|
|
||||||
font-size: 1em;
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-table td,
|
|
||||||
.files-table th {
|
|
||||||
padding: 8px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-col > * {
|
|
||||||
margin-left: 2px;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
body {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
padding: 12px;
|
|
||||||
width: calc(100% - 20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 > div {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-table td,
|
|
||||||
.files-table th {
|
|
||||||
padding: 6px 2px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-col {
|
|
||||||
width: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-name-col {
|
|
||||||
max-width: 120px;
|
|
||||||
font-size: 0.85em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-table .action-link,
|
|
||||||
.files-table button.action-link {
|
|
||||||
padding: 4px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
margin-left: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-col > * {
|
|
||||||
margin-left: 1px;
|
|
||||||
gap: 1px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
font-size: 1.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-table td,
|
|
||||||
.files-table th {
|
|
||||||
padding: 8px 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
339
static/css/modals.css
Normal file
339
static/css/modals.css
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
/**
|
||||||
|
* 预览模态框和对话框样式
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 预览模态框 */
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
video.preview-content {
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 音频预览样式 */
|
||||||
|
.preview-audio-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: 12px;
|
||||||
|
min-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-audio {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文本预览样式 */
|
||||||
|
.preview-text {
|
||||||
|
background-color: rgba(255, 255, 255, 0.95);
|
||||||
|
color: #2c3e50;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow: auto;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .preview-text {
|
||||||
|
background-color: rgba(45, 45, 45, 0.95);
|
||||||
|
color: #e1e1e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 不支持预览的文件样式 */
|
||||||
|
.preview-unsupported {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 40px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-unsupported p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-unsupported .action-link {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-unsupported .action-link:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-audio-wrapper {
|
||||||
|
min-width: 300px;
|
||||||
|
padding: 30px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-unsupported {
|
||||||
|
min-width: 300px;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-text {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 自定义对话框 */
|
||||||
|
.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: var(--danger-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dialog__confirm:hover {
|
||||||
|
background-color: var(--danger-hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.app-dialog__panel {
|
||||||
|
width: calc(100vw - 40px);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
197
static/css/table.css
Normal file
197
static/css/table.css
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* 表格和列表视图样式
|
||||||
|
*/
|
||||||
|
|
||||||
|
.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-name-col {
|
||||||
|
width: auto;
|
||||||
|
max-width: 300px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name-col a {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: calc(100% - 30px);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name-col:hover {
|
||||||
|
background-color: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
margin-right: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder {
|
||||||
|
color: var(--folder-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file {
|
||||||
|
color: var(--file-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
color: var(--secondary-text);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size-col {
|
||||||
|
width: 100px;
|
||||||
|
min-width: 100px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-modified {
|
||||||
|
color: var(--secondary-text);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-modified-col {
|
||||||
|
width: 180px;
|
||||||
|
min-width: 180px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-col {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-col > * {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 0.95em;
|
||||||
|
color: var(--secondary-text);
|
||||||
|
background-color: var(--container-bg);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-message {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: var(--secondary-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.files-table .action-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.files-table .action-link {
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.last-modified-col {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.file-name-col {
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.file-size-col {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.file-name-col {
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-table .action-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-table .action-link,
|
||||||
|
.files-table button.action-link {
|
||||||
|
padding: 6px;
|
||||||
|
font-size: 1em;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-table td,
|
||||||
|
.files-table th {
|
||||||
|
padding: 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-col > * {
|
||||||
|
margin-left: 2px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.files-table td,
|
||||||
|
.files-table th {
|
||||||
|
padding: 6px 2px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-col {
|
||||||
|
width: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name-col {
|
||||||
|
max-width: 120px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-table .action-link,
|
||||||
|
.files-table button.action-link {
|
||||||
|
padding: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-left: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-col > * {
|
||||||
|
margin-left: 1px;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
85
static/css/utilities.css
Normal file
85
static/css/utilities.css
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* 状态消息和其他实用样式
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 上传进度提示 */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页脚样式 */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 上传输入框 */
|
||||||
|
.upload-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 禁用状态 */
|
||||||
|
.is-disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.site-footer {
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 12px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
body {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 12px;
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 > div {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
static/css/variables.css
Normal file
95
static/css/variables.css
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* CSS 变量和主题定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
: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);
|
||||||
|
--icon-bg: #f0f0f0;
|
||||||
|
--danger-color: #dc3545;
|
||||||
|
--danger-hover-color: #a0202d;
|
||||||
|
--warning-color: #ffc107;
|
||||||
|
--warning-hover-color: #e0a800;
|
||||||
|
--success-color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
[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);
|
||||||
|
--icon-bg: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基础样式
|
||||||
|
*/
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background-color: var(--container-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px var(--shadow-color);
|
||||||
|
padding: 20px;
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 > div {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--link-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
235
static/js/dialog.js
Normal file
235
static/js/dialog.js
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* 对话框(Dialog)相关功能
|
||||||
|
* 包括确认、输入、提示等对话框
|
||||||
|
*/
|
||||||
|
|
||||||
|
const dialogState = {
|
||||||
|
container: null,
|
||||||
|
title: null,
|
||||||
|
message: null,
|
||||||
|
inputWrapper: null,
|
||||||
|
input: null,
|
||||||
|
confirmBtn: null,
|
||||||
|
cancelBtn: null,
|
||||||
|
resolve: null,
|
||||||
|
options: null,
|
||||||
|
previousActiveElement: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查对话框是否打开
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isDialogOpen() {
|
||||||
|
return Boolean(dialogState.container && !dialogState.container.hasAttribute("hidden"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭对话框
|
||||||
|
* @param {boolean} confirmed - 是否确认
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开对话框
|
||||||
|
* @param {object} options - 对话框选项
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示确认对话框
|
||||||
|
* @param {string} message - 消息
|
||||||
|
* @param {object} options - 选项
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示输入对话框
|
||||||
|
* @param {string} message - 消息
|
||||||
|
* @param {object} options - 选项
|
||||||
|
* @returns {Promise<string|null>}
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出到全局作用域
|
||||||
|
*/
|
||||||
|
window.DialogUtils = {
|
||||||
|
isDialogOpen,
|
||||||
|
closeDialog,
|
||||||
|
openDialog,
|
||||||
|
initDialog,
|
||||||
|
showConfirm,
|
||||||
|
showPrompt,
|
||||||
|
};
|
||||||
32
static/js/download.js
Normal file
32
static/js/download.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 下载按钮事件处理
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定下载按钮监听器
|
||||||
|
*/
|
||||||
|
function attachDownloadButtonListeners() {
|
||||||
|
const downloadButtons = document.querySelectorAll("[data-download-key]");
|
||||||
|
downloadButtons.forEach((button) => {
|
||||||
|
if (!button.dataset.listenerAttached) {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const key = button.dataset.downloadKey;
|
||||||
|
const name = button.dataset.downloadName;
|
||||||
|
// 对路径分段编码,保留路径分隔符,避免 # ? 等字符破坏 URL
|
||||||
|
const encoded = key
|
||||||
|
.split("/")
|
||||||
|
.map((seg) => encodeURIComponent(seg))
|
||||||
|
.join("/");
|
||||||
|
downloadFile(`/download/${encoded}`, name);
|
||||||
|
});
|
||||||
|
button.dataset.listenerAttached = "true";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出到全局作用域
|
||||||
|
*/
|
||||||
|
window.DownloadUtils = {
|
||||||
|
attachDownloadButtonListeners,
|
||||||
|
};
|
||||||
444
static/js/file-operations.js
Normal file
444
static/js/file-operations.js
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
/**
|
||||||
|
* 文件操作相关功能
|
||||||
|
* 包括上传、下载、删除、重命名等
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件
|
||||||
|
* @param {FileList} files - 文件列表
|
||||||
|
*/
|
||||||
|
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 promptCreateFolder() {
|
||||||
|
const currentPrefix = document.body.dataset.currentPrefix || "";
|
||||||
|
const name = await showPrompt("请输入新文件夹名称:", {
|
||||||
|
title: "新建文件夹",
|
||||||
|
confirmLabel: "创建",
|
||||||
|
placeholder: "例如:photos 或 nested/folder",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
// 规范化路径:允许嵌套,移除多余斜杠
|
||||||
|
let normalized = name.trim().replace(/\\/g, "/");
|
||||||
|
normalized = normalized.replace(/^\/+|\/+$/g, "");
|
||||||
|
if (!normalized) {
|
||||||
|
updateStatus("✗ 文件夹名称不能为空", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = (currentPrefix || "") + normalized + "/";
|
||||||
|
updateStatus(`正在创建文件夹: ${fullPath}...`, null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/create_folder", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ path: fullPath }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
const statusDiv = updateStatus("✓ 文件夹创建成功!", "success");
|
||||||
|
hideStatusLater(statusDiv);
|
||||||
|
setTimeout(() => {
|
||||||
|
// 进入新建的文件夹
|
||||||
|
window.location.href = `/${(currentPrefix + normalized).replace(/\/+$/, "")}`;
|
||||||
|
}, 1200);
|
||||||
|
} else {
|
||||||
|
updateStatus(`✗ 创建失败: ${result.error}`, "error");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
updateStatus(`✗ 创建失败: ${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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文件夹
|
||||||
|
* @param {string} prefix - 文件夹前缀
|
||||||
|
*/
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除单个文件
|
||||||
|
* @param {string} filePath - 文件路径
|
||||||
|
* @param {object} options - 选项
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载文件
|
||||||
|
* @param {string} url - 下载 URL
|
||||||
|
* @param {string} filename - 文件名
|
||||||
|
*/
|
||||||
|
function downloadFile(url, filename) {
|
||||||
|
if (!url) {
|
||||||
|
updateStatus("✗ 无法下载:缺少下载链接", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith("/download/") || url.startsWith("/file/")) {
|
||||||
|
// 让浏览器原生跟随服务器重定向(OneDrive 直链/共享链接),避免 fetch 对 3xx 的处理差异
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener";
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
const statusDiv = updateStatus(`✓ 开始下载: ${filename || ""}`, "success");
|
||||||
|
hideStatusLater(statusDiv);
|
||||||
|
} else {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重命名文件
|
||||||
|
* @param {string} oldKey - 旧的文件键
|
||||||
|
* @param {string} 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重命名文件夹
|
||||||
|
* @param {string} oldPrefix - 旧的文件夹前缀
|
||||||
|
* @param {string} newName - 新名称
|
||||||
|
*/
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提示重命名
|
||||||
|
* @param {string} oldKey - 旧键
|
||||||
|
* @param {string} oldName - 旧名称
|
||||||
|
* @param {boolean} isFolder - 是否是文件夹
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制项目
|
||||||
|
* @param {string} source - 源路径
|
||||||
|
* @param {string} destination - 目标路径
|
||||||
|
* @param {boolean} isFolder - 是否是文件夹
|
||||||
|
*/
|
||||||
|
async function copyItem(source, destination, isFolder) {
|
||||||
|
updateStatus(`正在复制...`, null);
|
||||||
|
await performOperation("/copy", "复制", { source, destination, is_folder: isFolder });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动项目
|
||||||
|
* @param {string} source - 源路径
|
||||||
|
* @param {string} destination - 目标路径
|
||||||
|
* @param {boolean} isFolder - 是否是文件夹
|
||||||
|
*/
|
||||||
|
async function moveItem(source, destination, isFolder) {
|
||||||
|
updateStatus(`正在移动...`, null);
|
||||||
|
await performOperation("/move", "移动", { source, destination, is_folder: isFolder });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提示复制或移动
|
||||||
|
* @param {string} source - 源路径
|
||||||
|
* @param {boolean} isFolder - 是否是文件夹
|
||||||
|
* @param {string} operation - 操作类型 ('copy' 或 'move')
|
||||||
|
*/
|
||||||
|
async function promptCopyOrMove(source, isFolder, operation) {
|
||||||
|
const opText = operation === "copy" ? "复制" : "移动";
|
||||||
|
const itemText = isFolder ? "文件夹" : "文件";
|
||||||
|
const dest = await showPrompt(`请输入目标目录路径:`, {
|
||||||
|
title: `${opText}${itemText}`,
|
||||||
|
confirmLabel: opText,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行复制/移动操作
|
||||||
|
* @param {string} endpoint - API 端点
|
||||||
|
* @param {string} opText - 操作文本
|
||||||
|
* @param {object} body - 请求体
|
||||||
|
*/
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出到全局作用域
|
||||||
|
*/
|
||||||
|
window.FileOps = {
|
||||||
|
uploadFiles,
|
||||||
|
promptDelete,
|
||||||
|
deleteFolder,
|
||||||
|
deleteFile,
|
||||||
|
downloadFile,
|
||||||
|
renameFile,
|
||||||
|
renameFolder,
|
||||||
|
promptRename,
|
||||||
|
promptCreateFolder,
|
||||||
|
copyItem,
|
||||||
|
moveItem,
|
||||||
|
promptCopyOrMove,
|
||||||
|
performOperation,
|
||||||
|
};
|
||||||
1099
static/js/main.js
1099
static/js/main.js
File diff suppressed because it is too large
Load Diff
189
static/js/preview.js
Normal file
189
static/js/preview.js
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* 文件预览功能
|
||||||
|
* 包括图片、视频、音频、PDF、文本等预览
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件类型
|
||||||
|
* @param {string} filename - 文件名
|
||||||
|
* @returns {string} 文件类型
|
||||||
|
*/
|
||||||
|
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"];
|
||||||
|
const audioExtensions = ["mp3", "wav", "ogg", "m4a", "aac", "flac", "opus", "weba"];
|
||||||
|
const pdfExtensions = ["pdf"];
|
||||||
|
const textExtensions = [
|
||||||
|
"txt",
|
||||||
|
"log",
|
||||||
|
"md",
|
||||||
|
"json",
|
||||||
|
"xml",
|
||||||
|
"csv",
|
||||||
|
"js",
|
||||||
|
"css",
|
||||||
|
"html",
|
||||||
|
"py",
|
||||||
|
"java",
|
||||||
|
"c",
|
||||||
|
"cpp",
|
||||||
|
"h",
|
||||||
|
"hpp",
|
||||||
|
"sh",
|
||||||
|
"bat",
|
||||||
|
"yaml",
|
||||||
|
"yml",
|
||||||
|
"toml",
|
||||||
|
"ini",
|
||||||
|
"conf",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (imageExtensions.includes(extension)) {
|
||||||
|
return "image";
|
||||||
|
}
|
||||||
|
if (videoExtensions.includes(extension)) {
|
||||||
|
return "video";
|
||||||
|
}
|
||||||
|
if (audioExtensions.includes(extension)) {
|
||||||
|
return "audio";
|
||||||
|
}
|
||||||
|
if (pdfExtensions.includes(extension)) {
|
||||||
|
return "pdf";
|
||||||
|
}
|
||||||
|
if (textExtensions.includes(extension)) {
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
return "unsupported";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭预览
|
||||||
|
*/
|
||||||
|
function closePreview() {
|
||||||
|
const modal = document.getElementById("previewModal");
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove("show");
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开预览
|
||||||
|
* @param {string} url - 文件 URL
|
||||||
|
* @param {string} filename - 文件名
|
||||||
|
*/
|
||||||
|
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 === "unsupported") {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="preview-unsupported">
|
||||||
|
<i class="fas fa-file-alt" style="font-size: 64px; color: var(--text-secondary); margin-bottom: 16px;"></i>
|
||||||
|
<p style="font-size: 18px; margin-bottom: 8px;">该文件不支持预览</p>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 24px;">文件名: ${filename}</p>
|
||||||
|
<div style="display: flex; gap: 12px; justify-content: center;">
|
||||||
|
<button class="action-link" onclick="downloadFile('${url}', '${filename}'); closePreview();" style="padding: 8px 16px;">
|
||||||
|
<i class="fas fa-download"></i> 下载文件
|
||||||
|
</button>
|
||||||
|
<button class="action-link" onclick="window.open('${url}', '_blank'); closePreview();" style="padding: 8px 16px;">
|
||||||
|
<i class="fas fa-external-link-alt"></i> 新窗口打开
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
info.textContent = filename;
|
||||||
|
modal.classList.add("show");
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
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.style.maxWidth = "100%";
|
||||||
|
image.style.maxHeight = "100%";
|
||||||
|
image.onerror = () => {
|
||||||
|
container.innerHTML = '<div class="preview-error">加载失败</div>';
|
||||||
|
};
|
||||||
|
container.innerHTML = "";
|
||||||
|
container.appendChild(image);
|
||||||
|
} else if (fileType === "video") {
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.className = "preview-content";
|
||||||
|
video.controls = true;
|
||||||
|
video.style.maxWidth = "100%";
|
||||||
|
video.style.maxHeight = "100%";
|
||||||
|
const source = document.createElement("source");
|
||||||
|
source.src = url;
|
||||||
|
source.type = `video/${url.split(".").pop()}`;
|
||||||
|
video.appendChild(source);
|
||||||
|
container.innerHTML = "";
|
||||||
|
container.appendChild(video);
|
||||||
|
} else if (fileType === "audio") {
|
||||||
|
const audio = document.createElement("audio");
|
||||||
|
audio.className = "preview-content";
|
||||||
|
audio.controls = true;
|
||||||
|
audio.style.width = "100%";
|
||||||
|
const source = document.createElement("source");
|
||||||
|
source.src = url;
|
||||||
|
source.type = `audio/${url.split(".").pop()}`;
|
||||||
|
audio.appendChild(source);
|
||||||
|
container.innerHTML = "";
|
||||||
|
container.appendChild(audio);
|
||||||
|
} else if (fileType === "pdf") {
|
||||||
|
container.innerHTML = `
|
||||||
|
<iframe
|
||||||
|
src="${url}#toolbar=0"
|
||||||
|
style="width: 100%; height: 100%; border: none;"
|
||||||
|
title="${filename}"
|
||||||
|
></iframe>
|
||||||
|
`;
|
||||||
|
} else if (fileType === "text") {
|
||||||
|
fetch(url)
|
||||||
|
.then((response) => response.text())
|
||||||
|
.then((text) => {
|
||||||
|
const pre = document.createElement("pre");
|
||||||
|
pre.className = "preview-text";
|
||||||
|
pre.textContent = text;
|
||||||
|
pre.style.margin = "0";
|
||||||
|
pre.style.padding = "16px";
|
||||||
|
pre.style.overflow = "auto";
|
||||||
|
pre.style.whiteSpace = "pre-wrap";
|
||||||
|
pre.style.wordWrap = "break-word";
|
||||||
|
container.innerHTML = "";
|
||||||
|
container.appendChild(pre);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
container.innerHTML = '<div class="preview-error">加载失败</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出到全局作用域
|
||||||
|
*/
|
||||||
|
window.PreviewUtils = {
|
||||||
|
getFileType,
|
||||||
|
closePreview,
|
||||||
|
openPreview,
|
||||||
|
};
|
||||||
178
static/js/selection.js
Normal file
178
static/js/selection.js
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* 文件选择和批量操作相关功能
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有条目复选框
|
||||||
|
* @returns {NodeListOf<Element>}
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换全选
|
||||||
|
* @param {HTMLInputElement} master - 主复选框
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除选定的条目
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出到全局作用域
|
||||||
|
*/
|
||||||
|
window.SelectionUtils = {
|
||||||
|
getEntryCheckboxes,
|
||||||
|
updateSelectAllState,
|
||||||
|
toggleSelectAll,
|
||||||
|
attachEntryCheckboxListeners,
|
||||||
|
deleteSelectedEntries,
|
||||||
|
};
|
||||||
98
static/js/theme.js
Normal file
98
static/js/theme.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* 主题和视图切换功能
|
||||||
|
* 包括深色/浅色主题切换、列表/网格视图切换
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化主题和视图
|
||||||
|
*/
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出到全局作用域
|
||||||
|
*/
|
||||||
|
window.ThemeUtils = {
|
||||||
|
initThemeAndView,
|
||||||
|
};
|
||||||
44
static/js/ui-utils.js
Normal file
44
static/js/ui-utils.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* UI 相关的全局工具函数
|
||||||
|
* 包括状态提示、对话框等
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新状态消息
|
||||||
|
* @param {string} message - 消息内容
|
||||||
|
* @param {string} state - 状态类型 ('success', 'error', 'warning', 或空)
|
||||||
|
* @returns {HTMLElement|null} 状态元素
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 延迟隐藏状态消息
|
||||||
|
* @param {HTMLElement} statusDiv - 状态元素
|
||||||
|
* @param {number} delay - 延迟时间(毫秒)
|
||||||
|
*/
|
||||||
|
function hideStatusLater(statusDiv, delay = 2000) {
|
||||||
|
if (!statusDiv) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
statusDiv.style.display = "none";
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出到全局作用域
|
||||||
|
*/
|
||||||
|
window.UIUtils = {
|
||||||
|
updateStatus,
|
||||||
|
hideStatusLater,
|
||||||
|
};
|
||||||
68
static/js/utilities.js
Normal file
68
static/js/utilities.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Service Worker 和其他工具函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注销 Service Worker
|
||||||
|
*/
|
||||||
|
function unregisterServiceWorker() {
|
||||||
|
if (!("serviceWorker" in navigator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.getRegistrations()
|
||||||
|
.then((registrations) => {
|
||||||
|
registrations.forEach((registration) => {
|
||||||
|
registration.unregister().then(() => {
|
||||||
|
console.log("Service Worker unregistered");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log("Error unregistering Service Worker:", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清理 Service Worker 相关的缓存
|
||||||
|
if ("caches" in window) {
|
||||||
|
caches.keys().then((cacheNames) => {
|
||||||
|
cacheNames.forEach((cacheName) => {
|
||||||
|
caches.delete(cacheName).then(() => {
|
||||||
|
console.log("Cache deleted:", cacheName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册模态框处理程序
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出到全局作用域
|
||||||
|
*/
|
||||||
|
window.UtilityFuncs = {
|
||||||
|
unregisterServiceWorker,
|
||||||
|
registerModalHandlers,
|
||||||
|
};
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
import os
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import dotenv
|
from config import Config
|
||||||
|
|
||||||
from .base import BaseStorage
|
from .base import BaseStorage
|
||||||
from .github import GitHubStorage
|
from .github import GitHubStorage
|
||||||
|
from .onedrive import OnedriveStorage
|
||||||
from .r2 import R2Storage
|
from .r2 import R2Storage
|
||||||
|
|
||||||
dotenv.load_dotenv()
|
|
||||||
|
|
||||||
|
|
||||||
class StorageFactory:
|
class StorageFactory:
|
||||||
"""存储工厂类,根据配置创建对应的存储实例"""
|
"""存储工厂类,根据配置创建对应的存储实例"""
|
||||||
@@ -29,24 +27,19 @@ class StorageFactory:
|
|||||||
if cls._instance is not None:
|
if cls._instance is not None:
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
storage_type = os.getenv("STORAGE_TYPE")
|
storage_type = Config.STORAGE_TYPE
|
||||||
|
|
||||||
if not storage_type:
|
if not storage_type:
|
||||||
raise RuntimeError(
|
raise RuntimeError("STORAGE_TYPE environment variable is not set. Supported types: r2, github, onedrive")
|
||||||
"STORAGE_TYPE environment variable is not set. "
|
|
||||||
"Supported types: r2, github"
|
|
||||||
)
|
|
||||||
|
|
||||||
storage_type = storage_type.lower()
|
|
||||||
|
|
||||||
if storage_type == "r2":
|
if storage_type == "r2":
|
||||||
cls._instance = R2Storage()
|
cls._instance = R2Storage()
|
||||||
elif storage_type == "github":
|
elif storage_type == "github":
|
||||||
cls._instance = GitHubStorage()
|
cls._instance = GitHubStorage()
|
||||||
|
elif storage_type == "onedrive":
|
||||||
|
cls._instance = OnedriveStorage()
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(
|
raise RuntimeError(f"Unsupported storage type: {storage_type}. Supported types: r2, github, onedrive")
|
||||||
f"Unsupported storage type: {storage_type}. Supported types: r2, github"
|
|
||||||
)
|
|
||||||
|
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import base64
|
import base64
|
||||||
import os
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
import dotenv
|
|
||||||
import requests
|
import requests
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from .base import BaseStorage
|
from config import Config
|
||||||
|
|
||||||
dotenv.load_dotenv()
|
from .base import BaseStorage
|
||||||
|
|
||||||
|
|
||||||
class StreamWrapper:
|
class StreamWrapper:
|
||||||
@@ -50,29 +48,30 @@ class GitHubStorage(BaseStorage):
|
|||||||
"""基于 GitHub 仓库的存储实现"""
|
"""基于 GitHub 仓库的存储实现"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.repo_owner = os.getenv("GITHUB_REPO_OWNER")
|
"""初始化 GitHub 存储客户端"""
|
||||||
self.repo_name = os.getenv("GITHUB_REPO_NAME")
|
self.token = Config.GITHUB_TOKEN
|
||||||
self.access_token = os.getenv("GITHUB_ACCESS_TOKEN")
|
repo_full = Config.GITHUB_REPO # 格式: owner/repo
|
||||||
self.branch = os.getenv("GITHUB_BRANCH", "main")
|
self.branch = Config.GITHUB_BRANCH
|
||||||
# 反向代理 URL,用于加速 GitHub raw 文件访问
|
|
||||||
# 例如:https://raw. githubusercontent.com/ 的反向代理 URL
|
|
||||||
self.raw_proxy_url = os.getenv("GITHUB_RAW_PROXY_URL", "").rstrip("/")
|
|
||||||
|
|
||||||
if not all([self.repo_owner, self.repo_name, self.access_token]):
|
if not self.token or not repo_full:
|
||||||
raise RuntimeError("GITHUB_REPO_OWNER, GITHUB_REPO_NAME, GITHUB_ACCESS_TOKEN must be set")
|
raise RuntimeError("GITHUB_TOKEN and GITHUB_REPO must be set")
|
||||||
|
|
||||||
|
# 解析 owner/repo
|
||||||
|
repo_parts = repo_full.split("/")
|
||||||
|
if len(repo_parts) != 2:
|
||||||
|
raise RuntimeError(f"GITHUB_REPO must be in format 'owner/repo', got: {repo_full}")
|
||||||
|
|
||||||
|
self.repo_owner = repo_parts[0]
|
||||||
|
self.repo_name = repo_parts[1]
|
||||||
|
self.repo = repo_full
|
||||||
|
|
||||||
self.api_base_url = f"https://api.github.com/repos/{self.repo_owner}/{self.repo_name}"
|
self.api_base_url = f"https://api.github.com/repos/{self.repo_owner}/{self.repo_name}"
|
||||||
|
self.raw_content_url = f"https://raw.githubusercontent.com/{self.repo_owner}/{self.repo_name}/{self.branch}"
|
||||||
# 如果配置了代理 URL,则使用代理 URL;否则使用官方 raw.githubusercontent.com
|
|
||||||
if self.raw_proxy_url:
|
|
||||||
self.raw_content_url = f"{self.raw_proxy_url}/https://raw.githubusercontent.com/{self.repo_owner}/{self.repo_name}/{self.branch}"
|
|
||||||
else:
|
|
||||||
self.raw_content_url = f"https://raw.githubusercontent.com/{self.repo_owner}/{self.repo_name}/{self.branch}"
|
|
||||||
|
|
||||||
def _headers(self) -> Dict[str, str]:
|
def _headers(self) -> Dict[str, str]:
|
||||||
"""返回 API 请求的公共头部信息"""
|
"""返回 API 请求的公共头部信息"""
|
||||||
return {
|
return {
|
||||||
"Authorization": f"token {self.access_token}",
|
"Authorization": f"token {self.token}",
|
||||||
"Accept": "application/vnd.github.v3+json",
|
"Accept": "application/vnd.github.v3+json",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
735
storages/onedrive.py
Normal file
735
storages/onedrive.py
Normal file
@@ -0,0 +1,735 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
from .base import BaseStorage
|
||||||
|
|
||||||
|
|
||||||
|
class _InvalidGrant(Exception):
|
||||||
|
"""内部异常:用于标记 invalid_grant 以便进行下一次尝试"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OnedriveStorage(BaseStorage):
|
||||||
|
"""基于 OneDrive 的存储实现,支持自动令牌刷新"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""初始化 OneDrive 存储客户端"""
|
||||||
|
self.client_id = Config.ONEDRIVE_CLIENT_ID
|
||||||
|
self.client_secret = Config.ONEDRIVE_CLIENT_SECRET
|
||||||
|
self.refresh_token = Config.ONEDRIVE_REFRESH_TOKEN
|
||||||
|
self.folder_id = Config.ONEDRIVE_FOLDER_ID
|
||||||
|
self.graph_api_url = "https://graph.microsoft.com/v1.0"
|
||||||
|
|
||||||
|
if not (self.client_id and self.client_secret and self.refresh_token):
|
||||||
|
raise RuntimeError("ONEDRIVE_CLIENT_ID, ONEDRIVE_CLIENT_SECRET, and ONEDRIVE_REFRESH_TOKEN must be set")
|
||||||
|
|
||||||
|
# 如果没有指定 folder_id,使用 /me/drive/root
|
||||||
|
self.folder_item_id = self.folder_id or "root"
|
||||||
|
|
||||||
|
# 初始化 access_token 并刷新
|
||||||
|
self.access_token = None
|
||||||
|
self._refresh_token()
|
||||||
|
|
||||||
|
def _item_path_url(self, key: str, action: str | None = None) -> str:
|
||||||
|
"""根据 folder_item_id 构造基于路径的 DriveItem URL,可附带动作后缀。
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- action=None => ...:/path:
|
||||||
|
- action='content' => ...:/path:/content
|
||||||
|
- action='createLink' => ...:/path:/createLink
|
||||||
|
- action='thumbnails' => ...:/path:/thumbnails
|
||||||
|
"""
|
||||||
|
key = key.strip("/")
|
||||||
|
# 对路径进行 URL 编码,但保留路径分隔符 '/'
|
||||||
|
key_quoted = quote(key, safe="/")
|
||||||
|
base = (
|
||||||
|
f"{self.graph_api_url}/me/drive/root:/{key_quoted}:"
|
||||||
|
if self.folder_item_id == "root"
|
||||||
|
else f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{key_quoted}:"
|
||||||
|
)
|
||||||
|
if action:
|
||||||
|
return base + f"/{action}"
|
||||||
|
return base
|
||||||
|
|
||||||
|
def _refresh_token(self) -> None:
|
||||||
|
"""刷新 OneDrive 访问令牌"""
|
||||||
|
configured_scopes = getattr(Config, "ONEDRIVE_SCOPES", None)
|
||||||
|
attempts = [None] + ([configured_scopes] if configured_scopes else []) + ["Files.ReadWrite.All offline_access"]
|
||||||
|
token_json = self._perform_refresh_attempts(attempts)
|
||||||
|
self.access_token = token_json["access_token"]
|
||||||
|
new_refresh = token_json.get("refresh_token")
|
||||||
|
if new_refresh:
|
||||||
|
self.refresh_token = new_refresh
|
||||||
|
self._access_token_expires_in = token_json.get("expires_in")
|
||||||
|
|
||||||
|
def _perform_refresh_attempts(self, attempts: list) -> dict:
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
for idx, scope in enumerate(attempts, start=1):
|
||||||
|
try:
|
||||||
|
token_json = self._do_refresh_attempt(scope)
|
||||||
|
return token_json
|
||||||
|
except _InvalidGrant as e:
|
||||||
|
errors.append(f"Attempt {idx} invalid_grant: {str(e)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise RuntimeError("Failed to refresh OneDrive token: " + " | ".join(errors))
|
||||||
|
|
||||||
|
def _do_refresh_attempt(self, scope: str | None) -> dict:
|
||||||
|
url = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
|
||||||
|
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": self.refresh_token,
|
||||||
|
}
|
||||||
|
if scope:
|
||||||
|
payload["scope"] = scope
|
||||||
|
if self.client_secret:
|
||||||
|
payload["client_secret"] = self.client_secret
|
||||||
|
redirect_uri = getattr(Config, "ONEDRIVE_REDIRECT_URI", None)
|
||||||
|
if redirect_uri:
|
||||||
|
payload["redirect_uri"] = redirect_uri
|
||||||
|
|
||||||
|
resp = requests.post(url, data=payload, headers=headers, timeout=20)
|
||||||
|
try:
|
||||||
|
detail = resp.json()
|
||||||
|
except Exception:
|
||||||
|
detail = resp.text
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
# 如果是 invalid_grant,抛出内部异常以触发下一次尝试
|
||||||
|
if isinstance(detail, dict) and detail.get("error") == "invalid_grant":
|
||||||
|
raise _InvalidGrant(detail)
|
||||||
|
raise RuntimeError(f"Token endpoint error {resp.status_code}: {detail}")
|
||||||
|
|
||||||
|
if isinstance(detail, dict) and detail.get("error"):
|
||||||
|
if detail.get("error") == "invalid_grant":
|
||||||
|
raise _InvalidGrant(detail)
|
||||||
|
raise RuntimeError(f"Token response error: {detail}")
|
||||||
|
|
||||||
|
token_json = detail if isinstance(detail, dict) else {}
|
||||||
|
access_token = token_json.get("access_token")
|
||||||
|
if not access_token:
|
||||||
|
# 视为无效,交给下一次尝试
|
||||||
|
raise _InvalidGrant({"error": "missing_access_token", "detail": detail})
|
||||||
|
return token_json
|
||||||
|
|
||||||
|
def _try_refresh(self, func):
|
||||||
|
"""尝试执行函数,如果失败则刷新令牌后重试(处理令牌过期)"""
|
||||||
|
try:
|
||||||
|
return func()
|
||||||
|
except Exception as e:
|
||||||
|
if "401" in str(e) or "Unauthorized" in str(e):
|
||||||
|
# 令牌过期,刷新并重试
|
||||||
|
self._refresh_token()
|
||||||
|
return func()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _api_request(self, method: str, url: str, **kwargs):
|
||||||
|
"""
|
||||||
|
执行 API 请求,自动处理令牌过期
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: HTTP 方法 (GET, POST, PUT, DELETE, PATCH)
|
||||||
|
url: API URL
|
||||||
|
**kwargs: 其他请求参数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
响应对象
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _do_request():
|
||||||
|
response = requests.request(method, url, headers=self._headers(), **kwargs)
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise RuntimeError("Unauthorized - OneDrive access token expired")
|
||||||
|
return response
|
||||||
|
|
||||||
|
return self._try_refresh(_do_request)
|
||||||
|
|
||||||
|
def _headers(self) -> Dict[str, str]:
|
||||||
|
"""返回 API 请求的公共头部信息"""
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {self.access_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _verify_connection(self) -> None:
|
||||||
|
"""验证 OneDrive 连接是否有效"""
|
||||||
|
try:
|
||||||
|
url = f"{self.graph_api_url}/me/drive"
|
||||||
|
response = requests.get(url, headers=self._headers(), timeout=10)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise RuntimeError(f"OneDrive connection failed: {response.text}")
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Failed to connect to OneDrive: {str(e)}") from None
|
||||||
|
|
||||||
|
def _get_folder_id(self, path: str) -> Optional[str]:
|
||||||
|
"""获取指定路径的文件夹 ID"""
|
||||||
|
if not path or path == "/":
|
||||||
|
return self.folder_item_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 直接获取该路径对应项的元数据(而不是其子项),以拿到该文件夹自身的 id
|
||||||
|
path = path.strip("/")
|
||||||
|
if self.folder_item_id == "root":
|
||||||
|
url = f"{self.graph_api_url}/me/drive/root:/{path}:"
|
||||||
|
else:
|
||||||
|
url = f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{path}:"
|
||||||
|
response = requests.get(url, headers=self._headers(), timeout=10)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
item = response.json()
|
||||||
|
if item.get("folder"):
|
||||||
|
return item.get("id")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_objects(self, prefix: str = "") -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
列出存储桶中的对象
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prefix: 对象前缀(用于目录浏览)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含对象列表的字典
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
prefix = prefix.rstrip("/") if prefix else ""
|
||||||
|
|
||||||
|
# 直接基于路径列出,避免先查 ID 再列出造成的额外往返
|
||||||
|
select = "$select=name,size,lastModifiedDateTime,id,folder,file"
|
||||||
|
if prefix:
|
||||||
|
# 对前缀进行 URL 编码,保留路径分隔符
|
||||||
|
from urllib.parse import quote as _quote
|
||||||
|
|
||||||
|
quoted_prefix = _quote(prefix.strip("/"), safe="/")
|
||||||
|
if self.folder_item_id == "root":
|
||||||
|
url = f"{self.graph_api_url}/me/drive/root:/{quoted_prefix}:/children?{select}"
|
||||||
|
else:
|
||||||
|
url = (
|
||||||
|
f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{quoted_prefix}:/children?{select}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if self.folder_item_id == "root":
|
||||||
|
url = f"{self.graph_api_url}/me/drive/root/children?{select}"
|
||||||
|
else:
|
||||||
|
url = f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}/children?{select}"
|
||||||
|
response = self._api_request("GET", url)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
items = response.json().get("value", [])
|
||||||
|
files = []
|
||||||
|
folders = []
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
# 跳过特殊文件
|
||||||
|
if item.get("name", "").startswith("."):
|
||||||
|
continue
|
||||||
|
|
||||||
|
item_path = f"{prefix}/{item.get('name')}" if prefix else item.get("name")
|
||||||
|
|
||||||
|
if "folder" in item:
|
||||||
|
# 这是一个文件夹
|
||||||
|
folders.append({"Prefix": f"{item_path}/"})
|
||||||
|
else:
|
||||||
|
# 这是一个文件
|
||||||
|
size = item.get("size", 0)
|
||||||
|
modified_time = item.get("lastModifiedDateTime", datetime.now().isoformat())
|
||||||
|
|
||||||
|
# 解析 ISO 格式时间
|
||||||
|
try:
|
||||||
|
if isinstance(modified_time, str):
|
||||||
|
modified_time = datetime.fromisoformat(modified_time.replace("Z", "+00:00"))
|
||||||
|
except Exception:
|
||||||
|
modified_time = datetime.now()
|
||||||
|
|
||||||
|
files.append(
|
||||||
|
{
|
||||||
|
"Key": item_path,
|
||||||
|
"Size": size,
|
||||||
|
"LastModified": modified_time,
|
||||||
|
"ETag": item.get("id", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"Contents": sorted(files, key=lambda x: x["Key"]),
|
||||||
|
"CommonPrefixes": sorted(folders, key=lambda x: x["Prefix"]),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
raise RuntimeError(f"Failed to list OneDrive objects: {str(e)}") from None
|
||||||
|
|
||||||
|
def get_object_info(self, key: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取对象基本信息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 对象键名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
对象元数据
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = self._item_path_url(key)
|
||||||
|
response = self._api_request("GET", url)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
item = response.json()
|
||||||
|
return {
|
||||||
|
"Key": key,
|
||||||
|
"Size": item.get("size", 0),
|
||||||
|
"LastModified": item.get("lastModifiedDateTime", datetime.now().isoformat()),
|
||||||
|
"ETag": item.get("id", ""),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Failed to get OneDrive object info: {str(e)}") from None
|
||||||
|
|
||||||
|
def get_object(self, key: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取对象内容
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 对象键名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含对象内容的字典
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = self._item_path_url(key, "content")
|
||||||
|
response = self._api_request("GET", url)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"Body": response.content,
|
||||||
|
"ContentType": response.headers.get("Content-Type", "application/octet-stream"),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Failed to get OneDrive object: {str(e)}") from None
|
||||||
|
|
||||||
|
def generate_presigned_url(self, key: str, expires: int = None) -> str:
|
||||||
|
"""
|
||||||
|
为指定对象生成预签名 URL
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 对象键名
|
||||||
|
expires: 过期时间(秒)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
预签名 URL,失败返回 None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
expires = expires or Config.PRESIGNED_URL_EXPIRES
|
||||||
|
url = self._item_path_url(key, "createLink")
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"type": "view",
|
||||||
|
"scope": "anonymous",
|
||||||
|
"expirationDateTime": (datetime.utcnow() + timedelta(seconds=expires)).isoformat() + "Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(url, headers=self._headers(), json=body, timeout=15)
|
||||||
|
|
||||||
|
if response.status_code in (200, 201):
|
||||||
|
data = response.json() or {}
|
||||||
|
link = data.get("link") or {}
|
||||||
|
web = link.get("webUrl")
|
||||||
|
if web:
|
||||||
|
# 强制下载提示(视 OneDrive 行为而定)
|
||||||
|
sep = "&" if "?" in web else "?"
|
||||||
|
return f"{web}{sep}download=1"
|
||||||
|
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_direct_download_url(self, key: str) -> Optional[str]:
|
||||||
|
"""从 DriveItem 元数据中获取临时直链(@microsoft.graph.downloadUrl)。"""
|
||||||
|
try:
|
||||||
|
url = self._item_path_url(key)
|
||||||
|
resp = self._api_request("GET", url)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json() or {}
|
||||||
|
# Graph 返回的预签名直链属性
|
||||||
|
return data.get("@microsoft.graph.downloadUrl") or data.get("@microsoft.graph.downloadurl")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def generate_download_response(self, key: str) -> Dict[str, Any]:
|
||||||
|
"""优先返回 OneDrive 的临时直链(直接文件内容),避免跳转到预览页。"""
|
||||||
|
# 1) 直链(最佳体验:直接获取文件内容,不经过 OneDrive 预览页)
|
||||||
|
direct = self._get_direct_download_url(key)
|
||||||
|
if direct:
|
||||||
|
return {"type": "redirect", "url": direct}
|
||||||
|
|
||||||
|
# 2) 匿名分享链接(添加 download=1 提示下载)
|
||||||
|
presigned = self.generate_presigned_url(key)
|
||||||
|
if presigned:
|
||||||
|
return {"type": "redirect", "url": presigned}
|
||||||
|
|
||||||
|
# 3) 回退到 webUrl(可能需要登录)
|
||||||
|
public_url = self.get_public_url(key)
|
||||||
|
if public_url:
|
||||||
|
return {"type": "redirect", "url": public_url}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_public_url(self, key: str) -> str:
|
||||||
|
"""
|
||||||
|
生成对象的公共访问 URL
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 对象键名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
公共 URL,未配置返回 None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 直接获取 DriveItem 元数据并读取 webUrl 字段
|
||||||
|
url = self._item_path_url(key)
|
||||||
|
response = self._api_request("GET", url)
|
||||||
|
if response.status_code == 200:
|
||||||
|
return (response.json() or {}).get("webUrl")
|
||||||
|
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = self._item_path_url(key, "content")
|
||||||
|
|
||||||
|
# 自定义头部(需要指定 Content-Type)
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.access_token}",
|
||||||
|
"Content-Type": content_type or "application/octet-stream",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.put(url, headers=headers, data=file_data, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
return response.status_code == 200 or response.status_code == 201
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to upload file to OneDrive: {str(e)}" if isinstance(e, Exception) else str(e)
|
||||||
|
) from None
|
||||||
|
|
||||||
|
def delete_file(self, key: str) -> bool:
|
||||||
|
"""
|
||||||
|
删除文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 对象键名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
删除成功返回 True,失败返回 False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = self._item_path_url(key)
|
||||||
|
response = self._api_request("DELETE", url)
|
||||||
|
return response.status_code == 204
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Failed to delete file from OneDrive: {str(e)}") from None
|
||||||
|
|
||||||
|
def copy_file(self, source_key: str, dest_key: str) -> bool:
|
||||||
|
"""
|
||||||
|
复制文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_key: 源文件键名
|
||||||
|
dest_key: 目标文件键名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
复制成功返回 True,失败返回 False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
source = source_key.strip("/")
|
||||||
|
destination = dest_key.strip("/")
|
||||||
|
|
||||||
|
# 获取源文件 ID
|
||||||
|
source_url = f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{source}:"
|
||||||
|
source_response = self._api_request("GET", source_url)
|
||||||
|
source_response.raise_for_status()
|
||||||
|
source_item = source_response.json()
|
||||||
|
|
||||||
|
# 复制文件
|
||||||
|
copy_url = f"{self.graph_api_url}/me/drive/items/{source_item.get('id')}/copy"
|
||||||
|
copy_body = {
|
||||||
|
"parentReference": {"id": self.folder_item_id},
|
||||||
|
"name": destination.split("/")[-1],
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_response = self._api_request("POST", copy_url, json=copy_body)
|
||||||
|
copy_response.raise_for_status()
|
||||||
|
|
||||||
|
return copy_response.status_code == 202 or copy_response.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Failed to copy file in OneDrive: {str(e)}") from None
|
||||||
|
|
||||||
|
def generate_thumbnail(self, file_path: str) -> bytes:
|
||||||
|
"""
|
||||||
|
生成图片缩略图
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 文件路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
缩略图字节数据
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = self._item_path_url(file_path, "thumbnails")
|
||||||
|
response = requests.get(url, headers=self._headers(), timeout=15)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
thumbnails = response.json().get("value", [])
|
||||||
|
if thumbnails:
|
||||||
|
thumb_set = thumbnails[0]
|
||||||
|
# 获取中等大小的缩略图
|
||||||
|
thumb_url = thumb_set.get("c", {}).get("url") or thumb_set.get("m", {}).get("url")
|
||||||
|
|
||||||
|
if thumb_url:
|
||||||
|
thumb_response = requests.get(thumb_url)
|
||||||
|
if thumb_response.status_code == 200:
|
||||||
|
return thumb_response.content
|
||||||
|
|
||||||
|
# 如果没有缩略图,尝试生成一个
|
||||||
|
return self._generate_fallback_thumbnail(file_path)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _generate_fallback_thumbnail(self, file_path: str) -> bytes:
|
||||||
|
"""
|
||||||
|
生成备用缩略图(当 OneDrive 没有缩略图时)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 文件路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
缩略图字节数据
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
file_obj = self.get_object(file_path)
|
||||||
|
img = Image.open(BytesIO(file_obj["Body"]))
|
||||||
|
|
||||||
|
# 调整大小
|
||||||
|
img.thumbnail(Config.THUMB_SIZE, Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
# 保存为 PNG
|
||||||
|
thumb_buffer = BytesIO()
|
||||||
|
img.save(thumb_buffer, format="PNG")
|
||||||
|
return thumb_buffer.getvalue()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def rename_file(self, old_key: str, new_key: str) -> bool:
|
||||||
|
"""
|
||||||
|
重命名文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_key: 旧的文件键名
|
||||||
|
new_key: 新的文件键名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
重命名成功返回 True,失败返回 False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
old_key = old_key.strip("/")
|
||||||
|
new_key = new_key.strip("/")
|
||||||
|
|
||||||
|
# 获取旧文件的 ID
|
||||||
|
url = f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{old_key}:"
|
||||||
|
response = self._api_request("GET", url)
|
||||||
|
response.raise_for_status()
|
||||||
|
item_id = response.json().get("id")
|
||||||
|
|
||||||
|
# 更新文件名
|
||||||
|
update_url = f"{self.graph_api_url}/me/drive/items/{item_id}"
|
||||||
|
update_body = {"name": new_key.split("/")[-1]}
|
||||||
|
|
||||||
|
response = self._api_request("PATCH", update_url, json=update_body)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to rename file in OneDrive: {str(e)}" if isinstance(e, Exception) else str(e)
|
||||||
|
) from None
|
||||||
|
|
||||||
|
def delete_folder(self, prefix: str) -> bool:
|
||||||
|
"""
|
||||||
|
删除文件夹及其中的所有文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prefix: 文件夹前缀
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
删除成功返回 True,失败返回 False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
prefix = prefix.rstrip("/")
|
||||||
|
|
||||||
|
# 获取文件夹中的所有项目
|
||||||
|
items = self.list_objects(prefix)
|
||||||
|
|
||||||
|
# 删除所有文件
|
||||||
|
for file in items.get("Contents", []):
|
||||||
|
self.delete_file(file["Key"])
|
||||||
|
|
||||||
|
# 删除所有子文件夹
|
||||||
|
for folder in items.get("CommonPrefixes", []):
|
||||||
|
self.delete_folder(folder["Prefix"])
|
||||||
|
|
||||||
|
# 删除文件夹本身
|
||||||
|
url = f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{prefix}:"
|
||||||
|
response = self._api_request("DELETE", url)
|
||||||
|
|
||||||
|
return response.status_code == 204
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to delete folder from OneDrive: {str(e)}" if isinstance(e, Exception) else str(e)
|
||||||
|
) from None
|
||||||
|
|
||||||
|
def rename_folder(self, old_prefix: str, new_prefix: str) -> bool:
|
||||||
|
"""
|
||||||
|
重命名文件夹
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_prefix: 旧的文件夹前缀
|
||||||
|
new_prefix: 新的文件夹前缀
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
重命名成功返回 True,失败返回 False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
old_prefix = old_prefix.rstrip("/")
|
||||||
|
new_prefix = new_prefix.rstrip("/")
|
||||||
|
|
||||||
|
# 获取旧文件夹的 ID
|
||||||
|
url = f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{old_prefix}:"
|
||||||
|
response = self._api_request("GET", url)
|
||||||
|
response.raise_for_status()
|
||||||
|
item_id = response.json().get("id")
|
||||||
|
|
||||||
|
# 更新文件夹名
|
||||||
|
update_url = f"{self.graph_api_url}/me/drive/items/{item_id}"
|
||||||
|
update_body = {"name": new_prefix.split("/")[-1]}
|
||||||
|
|
||||||
|
response = self._api_request("PATCH", update_url, json=update_body)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to rename folder in OneDrive: {str(e)}" if isinstance(e, Exception) else str(e)
|
||||||
|
) from None
|
||||||
|
|
||||||
|
def copy_folder(self, source_prefix: str, dest_prefix: str) -> bool:
|
||||||
|
"""
|
||||||
|
复制文件夹及其中的所有文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_prefix: 源文件夹前缀
|
||||||
|
dest_prefix: 目标文件夹前缀
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
复制成功返回 True,失败返回 False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
source_prefix = source_prefix.rstrip("/")
|
||||||
|
dest_prefix = dest_prefix.rstrip("/")
|
||||||
|
|
||||||
|
# 创建目标文件夹
|
||||||
|
self.create_folder(dest_prefix + "/")
|
||||||
|
|
||||||
|
# 获取源文件夹中的所有项目
|
||||||
|
items = self.list_objects(source_prefix)
|
||||||
|
|
||||||
|
# 复制所有文件
|
||||||
|
for file in items.get("Contents", []):
|
||||||
|
source_key = file["Key"]
|
||||||
|
# 保持相对路径
|
||||||
|
relative_path = source_key[len(source_prefix) + 1 :]
|
||||||
|
dest_key = f"{dest_prefix}/{relative_path}"
|
||||||
|
self.copy_file(source_key, dest_key)
|
||||||
|
|
||||||
|
# 递归复制子文件夹
|
||||||
|
for folder in items.get("CommonPrefixes", []):
|
||||||
|
source_folder = folder["Prefix"].rstrip("/")
|
||||||
|
relative_folder = source_folder[len(source_prefix) + 1 :]
|
||||||
|
dest_folder = f"{dest_prefix}/{relative_folder}"
|
||||||
|
self.copy_folder(source_folder, dest_folder)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to copy folder in OneDrive: {str(e)}" if isinstance(e, Exception) else str(e)
|
||||||
|
) from None
|
||||||
|
|
||||||
|
def create_folder(self, key: str) -> bool:
|
||||||
|
"""
|
||||||
|
创建文件夹
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 文件夹路径(以 / 结尾)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
创建成功返回 True,失败返回 False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
key = key.rstrip("/")
|
||||||
|
|
||||||
|
# 获取父文件夹 ID
|
||||||
|
parts = key.split("/")
|
||||||
|
parent_id = self.folder_item_id
|
||||||
|
|
||||||
|
# 创建每一层文件夹
|
||||||
|
for _, folder_name in enumerate(parts):
|
||||||
|
# 检查文件夹是否已存在
|
||||||
|
existing_url = f"{self.graph_api_url}/me/drive/items/{parent_id}:/{folder_name}:"
|
||||||
|
existing_response = self._api_request("GET", existing_url)
|
||||||
|
|
||||||
|
if existing_response.status_code == 200:
|
||||||
|
parent_id = existing_response.json().get("id")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 创建新文件夹
|
||||||
|
create_url = f"{self.graph_api_url}/me/drive/items/{parent_id}/children"
|
||||||
|
create_body = {"name": folder_name, "folder": {}}
|
||||||
|
|
||||||
|
create_response = self._api_request("POST", create_url, json=create_body)
|
||||||
|
create_response.raise_for_status()
|
||||||
|
|
||||||
|
parent_id = create_response.json().get("id")
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to create folder in OneDrive: {str(e)}" if isinstance(e, Exception) else str(e)
|
||||||
|
) from None
|
||||||
101
storages/r2.py
101
storages/r2.py
@@ -3,31 +3,34 @@ from io import BytesIO
|
|||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
import dotenv
|
from botocore.config import Config as BotocoreConfig
|
||||||
from botocore.config import Config
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
|
||||||
from .base import BaseStorage
|
from .base import BaseStorage
|
||||||
|
|
||||||
dotenv.load_dotenv()
|
|
||||||
|
|
||||||
|
|
||||||
class R2Storage(BaseStorage):
|
class R2Storage(BaseStorage):
|
||||||
def __init__(self):
|
"""Cloudflare R2 存储后端实现"""
|
||||||
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")
|
def __init__(self):
|
||||||
self.secret_key = os.getenv("SECRET_ACCESS_KEY") or os.getenv(
|
"""初始化 R2 存储客户端"""
|
||||||
"SECRET_ACCESS_KEY"
|
# 从统一配置中读取
|
||||||
)
|
account_id = Config.R2_ACCOUNT_ID
|
||||||
|
if not account_id:
|
||||||
|
raise RuntimeError("R2_ACCOUNT_ID environment variable is not set")
|
||||||
|
|
||||||
|
self.endpoint = f"https://{account_id}.r2.cloudflarestorage.com"
|
||||||
|
self.access_key = Config.R2_ACCESS_KEY_ID
|
||||||
|
self.secret_key = Config.R2_SECRET_ACCESS_KEY
|
||||||
|
|
||||||
if not self.access_key or not self.secret_key:
|
if not self.access_key or not self.secret_key:
|
||||||
raise RuntimeError("ACCESS_KEY_ID and SECRET_ACCESS_KEY must be set")
|
raise RuntimeError("R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY must be set")
|
||||||
|
|
||||||
self.region_name = os.getenv("R2_REGION", "auto")
|
self.region_name = "auto"
|
||||||
self.bucket_name = os.getenv("R2_BUCKET_NAME")
|
self.bucket_name = Config.R2_BUCKET_NAME
|
||||||
|
self.public_domain = Config.R2_PUBLIC_DOMAIN
|
||||||
|
|
||||||
def get_s3_client(self):
|
def get_s3_client(self):
|
||||||
"""
|
"""
|
||||||
@@ -38,7 +41,7 @@ class R2Storage(BaseStorage):
|
|||||||
endpoint_url=self.endpoint,
|
endpoint_url=self.endpoint,
|
||||||
aws_access_key_id=self.access_key,
|
aws_access_key_id=self.access_key,
|
||||||
aws_secret_access_key=self.secret_key,
|
aws_secret_access_key=self.secret_key,
|
||||||
config=Config(signature_version="s3v4"),
|
config=BotocoreConfig(signature_version="s3v4"),
|
||||||
region_name=self.region_name,
|
region_name=self.region_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -95,10 +98,9 @@ class R2Storage(BaseStorage):
|
|||||||
"""
|
"""
|
||||||
生成对象的公共访问 URL
|
生成对象的公共访问 URL
|
||||||
"""
|
"""
|
||||||
base_url = os.getenv("R2_PUBLIC_URL")
|
if not self.public_domain:
|
||||||
if not base_url:
|
|
||||||
return None
|
return None
|
||||||
return f"{base_url.rstrip('/')}/{key}"
|
return f"{self.public_domain.rstrip('/')}/{key}"
|
||||||
|
|
||||||
def generate_thumbnail(self, file_path: str) -> bytes:
|
def generate_thumbnail(self, file_path: str) -> bytes:
|
||||||
"""
|
"""
|
||||||
@@ -162,9 +164,7 @@ class R2Storage(BaseStorage):
|
|||||||
|
|
||||||
# 复制对象到新路径
|
# 复制对象到新路径
|
||||||
copy_source = {"Bucket": self.bucket_name, "Key": old_key}
|
copy_source = {"Bucket": self.bucket_name, "Key": old_key}
|
||||||
s3_client.copy_object(
|
s3_client.copy_object(CopySource=copy_source, Bucket=self.bucket_name, Key=new_key)
|
||||||
CopySource=copy_source, Bucket=self.bucket_name, Key=new_key
|
|
||||||
)
|
|
||||||
|
|
||||||
# 删除原对象
|
# 删除原对象
|
||||||
s3_client.delete_object(Bucket=self.bucket_name, Key=old_key)
|
s3_client.delete_object(Bucket=self.bucket_name, Key=old_key)
|
||||||
@@ -195,9 +195,7 @@ class R2Storage(BaseStorage):
|
|||||||
# 分批次删除,S3/R2 一次最多删除 1000 个
|
# 分批次删除,S3/R2 一次最多删除 1000 个
|
||||||
for i in range(0, len(objects_to_delete), 1000):
|
for i in range(0, len(objects_to_delete), 1000):
|
||||||
chunk = objects_to_delete[i : i + 1000]
|
chunk = objects_to_delete[i : i + 1000]
|
||||||
s3_client.delete_objects(
|
s3_client.delete_objects(Bucket=self.bucket_name, Delete={"Objects": chunk})
|
||||||
Bucket=self.bucket_name, Delete={"Objects": chunk}
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -225,9 +223,7 @@ class R2Storage(BaseStorage):
|
|||||||
for old_key in objects_to_rename:
|
for old_key in objects_to_rename:
|
||||||
new_key = old_key.replace(old_prefix, new_prefix, 1)
|
new_key = old_key.replace(old_prefix, new_prefix, 1)
|
||||||
copy_source = {"Bucket": self.bucket_name, "Key": old_key}
|
copy_source = {"Bucket": self.bucket_name, "Key": old_key}
|
||||||
s3_client.copy_object(
|
s3_client.copy_object(CopySource=copy_source, Bucket=self.bucket_name, Key=new_key)
|
||||||
CopySource=copy_source, Bucket=self.bucket_name, Key=new_key
|
|
||||||
)
|
|
||||||
|
|
||||||
# 删除旧文件夹下的所有对象
|
# 删除旧文件夹下的所有对象
|
||||||
self.delete_folder(old_prefix)
|
self.delete_folder(old_prefix)
|
||||||
@@ -244,9 +240,7 @@ class R2Storage(BaseStorage):
|
|||||||
try:
|
try:
|
||||||
s3_client = self.get_s3_client()
|
s3_client = self.get_s3_client()
|
||||||
copy_source = {"Bucket": self.bucket_name, "Key": source_key}
|
copy_source = {"Bucket": self.bucket_name, "Key": source_key}
|
||||||
s3_client.copy_object(
|
s3_client.copy_object(CopySource=copy_source, Bucket=self.bucket_name, Key=dest_key)
|
||||||
CopySource=copy_source, Bucket=self.bucket_name, Key=dest_key
|
|
||||||
)
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"File copy failed: {str(e)}")
|
print(f"File copy failed: {str(e)}")
|
||||||
@@ -322,3 +316,48 @@ class R2Storage(BaseStorage):
|
|||||||
}
|
}
|
||||||
|
|
||||||
return content_types.get(ext, "application/octet-stream")
|
return content_types.get(ext, "application/octet-stream")
|
||||||
|
|
||||||
|
def generate_download_response(self, key: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
生成文件下载响应(R2 实现)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 对象键名(文件路径)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含下载信息的字典
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
s3_client = self.get_s3_client()
|
||||||
|
file_name = key.split("/")[-1] if "/" in key else key
|
||||||
|
|
||||||
|
# 使用 RFC 5987 编码处理文件名
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
encoded_filename = quote(file_name.encode("utf-8"), safe="")
|
||||||
|
|
||||||
|
# 生成带有 Content-Disposition 的预签名 URL
|
||||||
|
expires = int(os.getenv("R2_PRESIGN_EXPIRES", "3600"))
|
||||||
|
|
||||||
|
url = s3_client.generate_presigned_url(
|
||||||
|
"get_object",
|
||||||
|
Params={
|
||||||
|
"Bucket": self.bucket_name,
|
||||||
|
"Key": key,
|
||||||
|
"ResponseContentDisposition": f"attachment; filename=\"{file_name}\"; filename*=UTF-8''{encoded_filename}",
|
||||||
|
},
|
||||||
|
ExpiresIn=expires,
|
||||||
|
)
|
||||||
|
|
||||||
|
if url:
|
||||||
|
return {"type": "redirect", "url": url}
|
||||||
|
|
||||||
|
# 如果预签名 URL 失败,尝试公共 URL
|
||||||
|
public_url = self.get_public_url(key)
|
||||||
|
if public_url:
|
||||||
|
return {"type": "redirect", "url": public_url}
|
||||||
|
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"R2 download response generation failed: {str(e)}")
|
||||||
|
return None
|
||||||
|
|||||||
@@ -5,8 +5,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{% block title %}Cloud Index{% endblock %}</title>
|
<title>{% block title %}Cloud Index{% endblock %}</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}" />
|
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}" />
|
||||||
|
<!-- 资源性能优化:预连接第三方 CDN,减少 TLS 握手耗时 -->
|
||||||
|
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin />
|
||||||
|
<link rel="dns-prefetch" href="//cdnjs.cloudflare.com" />
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" />
|
<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') }}" />
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}" />
|
||||||
{% block head_extra %}{% endblock %}
|
{% block head_extra %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body {% block body_attrs %}{% endblock %}>
|
<body {% block body_attrs %}{% endblock %}>
|
||||||
@@ -27,7 +30,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script defer src="{{ url_for('static', filename='js/main.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/ui-utils.js') }}"></script>
|
||||||
|
<script defer src="{{ url_for('static', filename='js/dialog.js') }}"></script>
|
||||||
|
<script defer src="{{ url_for('static', filename='js/theme.js') }}"></script>
|
||||||
|
<script defer src="{{ url_for('static', filename='js/utilities.js') }}"></script>
|
||||||
|
<script defer src="{{ url_for('static', filename='js/file-operations.js') }}"></script>
|
||||||
|
<script defer src="{{ url_for('static', filename='js/selection.js') }}"></script>
|
||||||
|
<script defer src="{{ url_for('static', filename='js/download.js') }}"></script>
|
||||||
|
<script defer src="{{ url_for('static', filename='js/preview.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
window.DialogUtils.initDialog();
|
||||||
|
window.ThemeUtils.initThemeAndView();
|
||||||
|
window.UtilityFuncs.unregisterServiceWorker();
|
||||||
|
window.UtilityFuncs.registerModalHandlers();
|
||||||
|
window.SelectionUtils.attachEntryCheckboxListeners();
|
||||||
|
window.DownloadUtils.attachDownloadButtonListeners();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -195,7 +195,10 @@ current_prefix }}"{% endblock %} {% block content %}
|
|||||||
<div class="grid-thumb" onclick="openPreview('{{ entry.file_url }}', '{{ entry.name }}')">
|
<div class="grid-thumb" onclick="openPreview('{{ entry.file_url }}', '{{ entry.name }}')">
|
||||||
<img
|
<img
|
||||||
style="width: 100%; height: 100%; object-fit: cover; border-radius: 6px"
|
style="width: 100%; height: 100%; object-fit: cover; border-radius: 6px"
|
||||||
src="{{ entry.file_url }}"
|
src="/thumb/{{ entry.key }}"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
fetchpriority="low"
|
||||||
alt="{{ entry.name }}"
|
alt="{{ entry.name }}"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,4 +284,74 @@ current_prefix }}"{% endblock %} {% block content %}
|
|||||||
<div id="previewInfo" class="preview-info"></div>
|
<div id="previewInfo" class="preview-info"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<script>
|
||||||
|
// 客户端分页:初始只渲染部分条目,点击“加载更多”逐步显示,减少首屏 DOM 与渲染压力
|
||||||
|
(function () {
|
||||||
|
const PAGE_CHUNK = 200; // 每次显示的条目数(表格与网格分别按该步长展开)
|
||||||
|
function hideBeyond(nodeList) {
|
||||||
|
let hidden = 0;
|
||||||
|
for (let i = PAGE_CHUNK; i < nodeList.length; i++) {
|
||||||
|
const el = nodeList[i];
|
||||||
|
if (!el.hasAttribute("hidden")) {
|
||||||
|
el.setAttribute("hidden", "");
|
||||||
|
hidden++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hidden;
|
||||||
|
}
|
||||||
|
function revealNext(nodeList, count) {
|
||||||
|
let revealed = 0;
|
||||||
|
for (let i = 0; i < nodeList.length && revealed < count; i++) {
|
||||||
|
const el = nodeList[i];
|
||||||
|
if (el.hasAttribute("hidden")) {
|
||||||
|
el.removeAttribute("hidden");
|
||||||
|
revealed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return revealed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupLoadMore() {
|
||||||
|
const tableRows = document.querySelectorAll("table.files-table tbody tr");
|
||||||
|
const gridCards = document.querySelectorAll("#gridContainer .grid-card");
|
||||||
|
let hiddenCount = 0;
|
||||||
|
hiddenCount += hideBeyond(tableRows);
|
||||||
|
hiddenCount += hideBeyond(gridCards);
|
||||||
|
|
||||||
|
if (hiddenCount === 0) return;
|
||||||
|
|
||||||
|
// 创建“加载更多”按钮
|
||||||
|
const moreWrap = document.createElement("div");
|
||||||
|
moreWrap.style.display = "flex";
|
||||||
|
moreWrap.style.justifyContent = "center";
|
||||||
|
moreWrap.style.margin = "16px 0 24px";
|
||||||
|
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = "view-toggle";
|
||||||
|
btn.type = "button";
|
||||||
|
const info = document.createElement("span");
|
||||||
|
info.style.marginLeft = "8px";
|
||||||
|
info.textContent = hiddenCount;
|
||||||
|
btn.textContent = "加载更多";
|
||||||
|
btn.appendChild(info);
|
||||||
|
moreWrap.appendChild(btn);
|
||||||
|
|
||||||
|
const container = document.querySelector(".container");
|
||||||
|
container && container.appendChild(moreWrap);
|
||||||
|
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const shown = revealNext(tableRows, PAGE_CHUNK) + revealNext(gridCards, PAGE_CHUNK);
|
||||||
|
hiddenCount = Math.max(0, hiddenCount - shown);
|
||||||
|
if (hiddenCount <= 0) {
|
||||||
|
moreWrap.remove();
|
||||||
|
} else {
|
||||||
|
info.textContent = hiddenCount;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", setupLoadMore);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
160
utils.py
Normal file
160
utils.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"""
|
||||||
|
工具函数模块
|
||||||
|
集中管理常用的辅助函数
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def format_timestamp(timestamp) -> str:
|
||||||
|
"""
|
||||||
|
格式化时间戳为人类可读的格式
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timestamp: 时间戳对象(datetime 或其他)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
格式化后的时间字符串
|
||||||
|
"""
|
||||||
|
if isinstance(timestamp, datetime):
|
||||||
|
return timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
return str(timestamp)
|
||||||
|
|
||||||
|
|
||||||
|
def format_file_size(size_bytes: Optional[int]) -> str:
|
||||||
|
"""
|
||||||
|
格式化文件大小为人类可读的格式
|
||||||
|
|
||||||
|
Args:
|
||||||
|
size_bytes: 文件大小(字节)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
格式化后的大小字符串(如 "1.23MB")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if size_bytes is None:
|
||||||
|
return "-"
|
||||||
|
num = float(size_bytes)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_icon(filename: Optional[str]) -> str:
|
||||||
|
"""
|
||||||
|
根据文件名返回对应的 Font Awesome 图标类名
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: 文件名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Font Awesome 图标类名
|
||||||
|
"""
|
||||||
|
if not filename:
|
||||||
|
return "fas fa-file"
|
||||||
|
|
||||||
|
ext = filename.lower().split(".")[-1] if "." in filename else ""
|
||||||
|
|
||||||
|
# 定义文件类型映射
|
||||||
|
icon_map = {
|
||||||
|
"fas fa-image": ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"],
|
||||||
|
"fas fa-music": ["mp3", "wav", "ogg", "flac", "m4a", "aac"],
|
||||||
|
"fas fa-video": ["mp4", "webm", "avi", "mov", "wmv", "flv", "mkv"],
|
||||||
|
"fas fa-file-alt": ["pdf", "doc", "docx", "txt", "md", "rtf"],
|
||||||
|
"fas fa-file-archive": ["zip", "rar", "7z", "tar", "gz"],
|
||||||
|
"fas fa-file-code": ["py", "js", "html", "css", "java", "cpp", "c", "php"],
|
||||||
|
"fas fa-file-excel": ["xls", "xlsx", "csv"],
|
||||||
|
"fas fa-file-powerpoint": ["ppt", "pptx"],
|
||||||
|
}
|
||||||
|
|
||||||
|
for icon, extensions in icon_map.items():
|
||||||
|
if ext in extensions:
|
||||||
|
return icon
|
||||||
|
|
||||||
|
return "fas fa-file"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_path(path: str, is_folder: bool = False) -> str:
|
||||||
|
"""
|
||||||
|
规范化路径格式
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: 原始路径
|
||||||
|
is_folder: 是否为文件夹
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
规范化后的路径(文件夹以 / 结尾)
|
||||||
|
"""
|
||||||
|
path = path.strip()
|
||||||
|
if is_folder and not path.endswith("/"):
|
||||||
|
return path + "/"
|
||||||
|
if not is_folder and path.endswith("/"):
|
||||||
|
return path.rstrip("/")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_extension(filename: str) -> str:
|
||||||
|
"""
|
||||||
|
获取文件扩展名
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: 文件名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
小写的文件扩展名(不含点)
|
||||||
|
"""
|
||||||
|
if not filename or "." not in filename:
|
||||||
|
return ""
|
||||||
|
return filename.lower().split(".")[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def is_image_file(filename: str) -> bool:
|
||||||
|
"""
|
||||||
|
判断文件是否为图片
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: 文件名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
如果是图片文件返回 True
|
||||||
|
"""
|
||||||
|
image_extensions = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "ico"]
|
||||||
|
return get_file_extension(filename) in image_extensions
|
||||||
|
|
||||||
|
|
||||||
|
def is_video_file(filename: str) -> bool:
|
||||||
|
"""
|
||||||
|
判断文件是否为视频
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: 文件名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
如果是视频文件返回 True
|
||||||
|
"""
|
||||||
|
video_extensions = ["mp4", "webm", "ogg", "mov", "avi", "mkv", "m4v"]
|
||||||
|
return get_file_extension(filename) in video_extensions
|
||||||
|
|
||||||
|
|
||||||
|
def is_audio_file(filename: str) -> bool:
|
||||||
|
"""
|
||||||
|
判断文件是否为音频
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: 文件名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
如果是音频文件返回 True
|
||||||
|
"""
|
||||||
|
audio_extensions = ["mp3", "wav", "ogg", "m4a", "aac", "flac", "opus", "weba"]
|
||||||
|
return get_file_extension(filename) in audio_extensions
|
||||||
Reference in New Issue
Block a user