Compare commits

...

6 Commits

Author SHA1 Message Date
f528483713 feat: 修复新建文件夹功能 2025-11-15 18:11:26 +08:00
9d5f753621 feat: 优化下载功能,支持路径编码和直接下载链接 2025-11-15 14:19:07 +08:00
d0bd44c526 feat: 添加 OneDrive 存储支持 2025-11-15 13:54:19 +08:00
730ee20048 refactor: 模块化前端代码 - 将 main.js 和 main.css 拆分为专用模块
- 移除 main.js 并替换为8个 JS 模块
- 移除 main.css 并替换为7个 CSS 模块
- 更新 base.html 以加载模块化文件
- 通过 index.css 保持完全向后兼容
- 改进代码组织、可维护性和可复用性
2025-11-15 12:45:19 +08:00
724351a551 docs: 更新贡献指南,简化提交步骤并调整分支命名建议 2025-11-15 11:24:14 +08:00
97bcf978cf fix: 更新代码时间徽章链接,修复显示问题 2025-11-15 11:20:40 +08:00
26 changed files with 3263 additions and 2149 deletions

View File

@@ -1,46 +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 账户 ID # 仅当 STORAGE_TYPE=r2 时需要配置
# R2 账户 ID (Cloudflare 账户 ID)
R2_ACCOUNT_ID=your-account-id R2_ACCOUNT_ID=your-account-id
# R2 访问凭证 # R2 访问密钥 ID
R2_ACCESS_KEY_ID=YOUR_R2_ACCESS_KEY_ID R2_ACCESS_KEY_ID=YOUR_R2_ACCESS_KEY_ID
# R2 访问密钥密码
R2_SECRET_ACCESS_KEY=YOUR_R2_SECRET_ACCESS_KEY R2_SECRET_ACCESS_KEY=YOUR_R2_SECRET_ACCESS_KEY
# R2 存储桶名称 # R2 存储桶名称
R2_BUCKET_NAME=drive R2_BUCKET_NAME=drive
# R2 公共访问域名 (可选,例如: https://pub-<bucket-name>.r2.dev) # R2 公共访问域名 (可选,用于生成公共 URL)
# 示例: https://pub-<bucket-name>.r2.dev
R2_PUBLIC_DOMAIN=https://pub-<bucket-name>.r2.dev R2_PUBLIC_DOMAIN=https://pub-<bucket-name>.r2.dev
# ==================== GitHub 存储配置 ==================== # ==================== GitHub 存储配置 ====================
# GitHub 仓库 (格式: owner/repo) # 仅当 STORAGE_TYPE=github 时需要配置
# GitHub 仓库地址 (格式: owner/repo)
# 示例: RhenCloud/Cloud-Index
GITHUB_REPO=your-username/your-repo GITHUB_REPO=your-username/your-repo
# GitHub 访问令牌 (需要 repo 权限) # GitHub 访问令牌 (需要 repo 权限)
# 生成: https://github.com/settings/tokens
GITHUB_TOKEN=your-access-token GITHUB_TOKEN=your-access-token
# GitHub 分支名称 (默认: main) # GitHub 分支名称 (默认: main)
GITHUB_BRANCH=main GITHUB_BRANCH=main
# ==================== Microsoft OneDrive 配置 ====================
# 仅当 STORAGE_TYPE=onedrive 时需要配置
# 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
# ==================== 应用配置 ==================== # ==================== 应用配置 ====================
# 服务器配置
# 服务器监听地址
# 0.0.0.0: 监听所有网卡
# 127.0.0.1: 仅本地访问
HOST=0.0.0.0 HOST=0.0.0.0
# 服务器监听端口 (默认: 5000)
PORT=5000 PORT=5000
# 调试模式 (true/false默认: false)
# 生产环境应设置为 false
DEBUG=false DEBUG=false
# 缩略图缓存时间(秒,默认: 3600 # ==================== 缓存和 URL 配置 ====================
# 缩略图缓存时间 (秒,默认: 3600)
# 用于浏览器缓存缩略图,减少服务器负担
THUMB_TTL_SECONDS=3600 THUMB_TTL_SECONDS=3600
# 预签名 URL 过期时间秒,默认: 3600 # 预签名 URL 过期时间 (秒,默认: 3600)
# 用于生成临时访问链接3600 秒 = 1 小时
PRESIGNED_URL_EXPIRES=3600 PRESIGNED_URL_EXPIRES=3600
THUMB_TTL_SECONDS=3600

View File

@@ -6,8 +6,8 @@
我在这个项目上至少花费了: 我在这个项目上至少花费了:
[![CodeTime Badge](https://shields.jannchie.com/endpoint?style=social&color=222&url=https%3A%2F%2Fapi.codetime.dev%2Fv3%2Fusers%2Fshield%3Fuid%3D34631%26project%3Dr2-index)](https://codetime.dev) [![Wakapi Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fwakapi.rhen.cloud%2Fapi%2Fcompat%2Fshields%2Fv1%2FRhenCloud%2Finterval%3ACloud-Index&query=%24.message&style=flat&label=Cloud-Index&color=%24.color
[![CodeTime Badge](https://shields.jannchie.com/endpoint?style=social&color=222&url=https%3A%2F%2Fapi.codetime.dev%2Fv3%2Fusers%2Fshield%3Fuid%3D34631%26project%3DCloudIndexDocs)](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 的存储服务 -->
## 快速开始 ## 快速开始
@@ -276,19 +277,12 @@ A: 参考项目结构中的"添加新的存储后端"部分,继承 `BaseStorag
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 .
@@ -296,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 描述,说明你的更改内容和原因

View File

@@ -30,6 +30,13 @@ class Config:
GITHUB_REPO: Optional[str] = os.getenv("GITHUB_REPO") # 格式: owner/repo GITHUB_REPO: Optional[str] = os.getenv("GITHUB_REPO") # 格式: owner/repo
GITHUB_BRANCH: str = os.getenv("GITHUB_BRANCH", "main") 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") HOST: str = os.getenv("HOST", "0.0.0.0")
PORT: int = int(os.getenv("PORT", "5000")) PORT: int = int(os.getenv("PORT", "5000"))
@@ -46,7 +53,7 @@ class Config:
def validate(cls) -> None: def validate(cls) -> None:
"""验证必需的配置项是否已设置""" """验证必需的配置项是否已设置"""
if not cls.STORAGE_TYPE: if not cls.STORAGE_TYPE:
raise ValueError("STORAGE_TYPE environment variable is not set. Supported types: r2, github") raise ValueError("STORAGE_TYPE environment variable is not set. Supported types: r2, github, onedrive")
if cls.STORAGE_TYPE == "r2": if cls.STORAGE_TYPE == "r2":
required = ["R2_ACCOUNT_ID", "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "R2_BUCKET_NAME"] required = ["R2_ACCOUNT_ID", "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "R2_BUCKET_NAME"]
@@ -60,8 +67,14 @@ class Config:
if missing: if missing:
raise ValueError(f"Missing required GitHub configuration: {', '.join(missing)}") raise ValueError(f"Missing required GitHub configuration: {', '.join(missing)}")
elif cls.STORAGE_TYPE not in ["r2", "github"]: elif cls.STORAGE_TYPE == "onedrive":
raise ValueError(f"Unsupported storage type: {cls.STORAGE_TYPE}. Supported types: r2, github") 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 @classmethod
def get_storage_config(cls) -> dict: def get_storage_config(cls) -> dict:
@@ -80,4 +93,12 @@ class Config:
"repo": cls.GITHUB_REPO, "repo": cls.GITHUB_REPO,
"branch": cls.GITHUB_BRANCH, "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 {} return {}

View File

@@ -50,13 +50,8 @@ def build_file_entry(obj: Dict[str, Any], prefix: str) -> Dict[str, Any] | None:
"file_url": get_file_url(key), "file_url": get_file_url(key),
} }
public_url = storage.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

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "Cloud-Index" name = "Cloud-Index"
version = "0.10.1" 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
View 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
View 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
View 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");

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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,
};

View 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,
};

File diff suppressed because it is too large Load Diff

189
static/js/preview.js Normal file
View 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
View 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
View 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
View 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
View 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,
};

View File

@@ -4,6 +4,7 @@ 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
@@ -29,16 +30,16 @@ class StorageFactory:
storage_type = Config.STORAGE_TYPE storage_type = Config.STORAGE_TYPE
if not storage_type: if not storage_type:
raise RuntimeError("STORAGE_TYPE environment variable is not set. Supported types: r2, github") raise RuntimeError("STORAGE_TYPE environment variable is not set. Supported types: r2, github, onedrive")
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(f"Unsupported storage type: {storage_type}. Supported types: r2, github") raise RuntimeError(f"Unsupported storage type: {storage_type}. Supported types: r2, github, onedrive")
return cls._instance
return cls._instance return cls._instance

735
storages/onedrive.py Normal file
View 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

View File

@@ -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>

View File

@@ -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 %}