mirror of
https://github.com/RhenCloud/Cloud-Index.git
synced 2025-12-06 15:26:10 +08:00
Compare commits
6 Commits
0a640a91fc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f528483713 | |||
| 9d5f753621 | |||
| d0bd44c526 | |||
| 730ee20048 | |||
| 724351a551 | |||
| 97bcf978cf |
61
.env.example
61
.env.example
@@ -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
|
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
我在这个项目上至少花费了:
|
我在这个项目上至少花费了:
|
||||||
|
|
||||||
[](https://codetime.dev)
|
[](https://codetime.dev)
|
)](https://wakapi.rhen.cloud)
|
||||||
|
|
||||||
## 特性
|
## 特性
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
- [x] Github Repo 储存支持
|
- [x] Github Repo 储存支持
|
||||||
- [ ] Github Release 储存支持
|
- [ ] Github Release 储存支持
|
||||||
- [ ] Microsoft Onedrive 储存支持
|
- [x] Microsoft Onedrive 储存支持
|
||||||
- [ ] 基于数据库的用户/权限管理
|
- [ ] 基于数据库的用户/权限管理
|
||||||
- [ ] 操作日志记录
|
- [ ] 操作日志记录
|
||||||
- [ ] Office Documents 预览支持
|
- [ ] Office Documents 预览支持
|
||||||
@@ -48,6 +48,7 @@
|
|||||||
- **Cloudflare R2** - Cloudflare 的对象存储服务(S3 兼容)
|
- **Cloudflare R2** - Cloudflare 的对象存储服务(S3 兼容)
|
||||||
- **Amazon S3** - Amazon S3 对象存储服务
|
- **Amazon S3** - Amazon S3 对象存储服务
|
||||||
- **GitHub Repository** - 基于 GitHub Repository 的存储服务
|
- **GitHub Repository** - 基于 GitHub Repository 的存储服务
|
||||||
|
- **Microsoft Onedrive** - Microsoft Onedrive 云端硬盘
|
||||||
<!-- - **Github Release** - 基于 GitHub Release 的存储服务 -->
|
<!-- - **Github Release** - 基于 GitHub Release 的存储服务 -->
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
@@ -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 描述,说明你的更改内容和原因
|
||||||
|
|||||||
27
config.py
27
config.py
@@ -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 {}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
149
static/css/buttons.css
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* 按钮样式
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 通用按钮基类 */
|
||||||
|
.btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1em;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background-color: var(--hover-bg);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 导航栏按钮 */
|
||||||
|
.nav-btn {
|
||||||
|
color: var(--secondary-text);
|
||||||
|
font-size: 1.1em;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:hover {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主题/视图切换按钮 */
|
||||||
|
.theme-toggle,
|
||||||
|
.view-toggle {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--secondary-text);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:hover,
|
||||||
|
.view-toggle:hover {
|
||||||
|
background-color: var(--hover-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:active,
|
||||||
|
.view-toggle:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 删除按钮 */
|
||||||
|
.delete-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--danger-color);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn:hover {
|
||||||
|
background-color: rgba(220, 53, 69, 0.1);
|
||||||
|
color: var(--danger-hover-color);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 下载按钮 */
|
||||||
|
.download-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--success-color);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn:hover {
|
||||||
|
background-color: rgba(40, 167, 69, 0.1);
|
||||||
|
color: #218838;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作链接按钮 */
|
||||||
|
.action-link {
|
||||||
|
color: var(--link-color);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-link:hover {
|
||||||
|
background-color: var(--hover-bg);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.action-link {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 重命名按钮 */
|
||||||
|
.rename-btn,
|
||||||
|
.grid-action-btn.rename {
|
||||||
|
color: var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rename-btn:hover,
|
||||||
|
.grid-action-btn.rename:hover {
|
||||||
|
color: var(--warning-hover-color);
|
||||||
|
}
|
||||||
169
static/css/grid.css
Normal file
169
static/css/grid.css
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* 网格视图样式
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Grid view 容器 */
|
||||||
|
.grid-container {
|
||||||
|
display: none;
|
||||||
|
margin-top: 20px;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-view="grid"] .files-table {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-view="grid"] .grid-container {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 网格卡片 */
|
||||||
|
.grid-card {
|
||||||
|
background: var(--container-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 1px 2px var(--shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-checkbox {
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-checkbox input {
|
||||||
|
transform: scale(1.05);
|
||||||
|
accent-color: var(--link-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 网格缩略图 */
|
||||||
|
.grid-thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background-color: var(--icon-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-thumb:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
box-shadow: 0 2px 8px var(--shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 网格图标容器 */
|
||||||
|
.grid-icon {
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: var(--icon-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-icon:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
background-color: var(--hover-bg);
|
||||||
|
box-shadow: 0 2px 8px var(--shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-icon i {
|
||||||
|
font-size: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 网格卡片文件名 */
|
||||||
|
.grid-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.95em;
|
||||||
|
color: var(--text-color);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 网格卡片操作按钮 */
|
||||||
|
.grid-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-action-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-action-btn.preview {
|
||||||
|
color: var(--link-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-action-btn.preview:hover {
|
||||||
|
background-color: rgba(0, 123, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-action-btn.download {
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-action-btn.download:hover {
|
||||||
|
background-color: rgba(40, 167, 69, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-action-btn.delete {
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-action-btn.delete:hover {
|
||||||
|
background-color: rgba(220, 53, 69, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.grid-container {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-icon i {
|
||||||
|
font-size: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.grid-container {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-card {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-icon i {
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-name {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-actions {
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
static/css/index.css
Normal file
22
static/css/index.css
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Cloud-Index 主样式表
|
||||||
|
* 这个文件导入所有模块化的 CSS 文件
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 1. 变量和基础样式 */
|
||||||
|
@import url("variables.css");
|
||||||
|
|
||||||
|
/* 2. 按钮样式 */
|
||||||
|
@import url("buttons.css");
|
||||||
|
|
||||||
|
/* 3. 表格视图 */
|
||||||
|
@import url("table.css");
|
||||||
|
|
||||||
|
/* 4. 网格视图 */
|
||||||
|
@import url("grid.css");
|
||||||
|
|
||||||
|
/* 5. 模态框和对话框 */
|
||||||
|
@import url("modals.css");
|
||||||
|
|
||||||
|
/* 6. 实用工具样式 */
|
||||||
|
@import url("utilities.css");
|
||||||
@@ -1,999 +0,0 @@
|
|||||||
:root {
|
|
||||||
--bg-color: #f5f5f5;
|
|
||||||
--container-bg: white;
|
|
||||||
--text-color: #2c3e50;
|
|
||||||
--border-color: #eee;
|
|
||||||
--hover-bg: #f8f9fa;
|
|
||||||
--secondary-text: #6c757d;
|
|
||||||
--link-color: #007bff;
|
|
||||||
--folder-color: #ffc107;
|
|
||||||
--file-color: #6c757d;
|
|
||||||
--shadow-color: rgba(0, 0, 0, 0.1);
|
|
||||||
--icon-bg: #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] {
|
|
||||||
--bg-color: #1a1a1a;
|
|
||||||
--container-bg: #2d2d2d;
|
|
||||||
--text-color: #e1e1e1;
|
|
||||||
--border-color: #404040;
|
|
||||||
--hover-bg: #363636;
|
|
||||||
--secondary-text: #a0a0a0;
|
|
||||||
--link-color: #66b3ff;
|
|
||||||
--folder-color: #ffd54f;
|
|
||||||
--file-color: #b0b0b0;
|
|
||||||
--shadow-color: rgba(0, 0, 0, 0.3);
|
|
||||||
--icon-bg: #3a3a3a;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
padding: 20px;
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
background-color: var(--container-bg);
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 4px var(--shadow-color);
|
|
||||||
padding: 20px;
|
|
||||||
width: calc(100% - 40px);
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: var(--text-color);
|
|
||||||
margin-bottom: 30px;
|
|
||||||
border-bottom: 2px solid var(--border-color);
|
|
||||||
padding-bottom: 10px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 > div {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 通用按钮样式 */
|
|
||||||
.btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 1em;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
background-color: var(--hover-bg);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 导航栏按钮 */
|
|
||||||
.nav-btn {
|
|
||||||
color: var(--secondary-text);
|
|
||||||
font-size: 1.1em;
|
|
||||||
padding: 8px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn:hover {
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 兼容旧的类名 */
|
|
||||||
.theme-toggle,
|
|
||||||
.view-toggle {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--secondary-text);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 1.1em;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-toggle:hover,
|
|
||||||
.view-toggle:hover {
|
|
||||||
background-color: var(--hover-bg);
|
|
||||||
color: var(--text-color);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-toggle:active,
|
|
||||||
.view-toggle:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 导航栏删除按钮特殊样式 */
|
|
||||||
.view-toggle[style*="color: #dc3545"]:hover {
|
|
||||||
color: #c82333 !important;
|
|
||||||
background-color: rgba(220, 53, 69, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Grid view styles */
|
|
||||||
.grid-container {
|
|
||||||
display: none;
|
|
||||||
margin-top: 20px;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-card {
|
|
||||||
background: var(--container-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 10px;
|
|
||||||
text-align: center;
|
|
||||||
box-shadow: 0 1px 2px var(--shadow-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-checkbox {
|
|
||||||
text-align: left;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-checkbox input {
|
|
||||||
transform: scale(1.05);
|
|
||||||
accent-color: var(--link-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-thumb {
|
|
||||||
width: 100%;
|
|
||||||
height: 100px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
background-color: var(--icon-bg);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-thumb:hover {
|
|
||||||
transform: scale(1.02);
|
|
||||||
box-shadow: 0 2px 8px var(--shadow-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 网格卡片中的图标容器 */
|
|
||||||
.grid-icon {
|
|
||||||
width: 100%;
|
|
||||||
height: 100px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background-color: var(--icon-bg);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-icon:hover {
|
|
||||||
transform: scale(1.02);
|
|
||||||
background-color: var(--hover-bg);
|
|
||||||
box-shadow: 0 2px 8px var(--shadow-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-icon i {
|
|
||||||
font-size: 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-name {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.95em;
|
|
||||||
color: var(--text-color);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 网格卡片操作按钮 */
|
|
||||||
.grid-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-action-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.85em;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-action-btn.preview {
|
|
||||||
color: var(--link-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-action-btn.preview:hover {
|
|
||||||
background-color: rgba(0, 123, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-action-btn.download {
|
|
||||||
color: #28a745;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-action-btn.download:hover {
|
|
||||||
background-color: rgba(40, 167, 69, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-action-btn.delete {
|
|
||||||
color: #dc3545;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-action-btn.delete:hover {
|
|
||||||
background-color: rgba(220, 53, 69, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toggle visibility based on data-view on root */
|
|
||||||
:root[data-view="grid"] .files-table {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-view="grid"] .grid-container {
|
|
||||||
display: grid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-col {
|
|
||||||
width: 48px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-col input {
|
|
||||||
transform: scale(1.05);
|
|
||||||
accent-color: var(--link-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-table th,
|
|
||||||
.files-table td {
|
|
||||||
padding: 12px;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-table th {
|
|
||||||
background-color: var(--hover-bg);
|
|
||||||
color: var(--text-color);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-table tr:hover {
|
|
||||||
background-color: var(--hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-name-col {
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-name-col:hover {
|
|
||||||
background-color: var(--hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-icon {
|
|
||||||
margin-right: 8px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder {
|
|
||||||
color: var(--folder-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file {
|
|
||||||
color: var(--file-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--link-color);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-size {
|
|
||||||
color: var(--secondary-text);
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.last-modified {
|
|
||||||
color: var(--secondary-text);
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-message {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px;
|
|
||||||
color: var(--secondary-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
font-size: 0.95em;
|
|
||||||
color: var(--secondary-text);
|
|
||||||
background-color: var(--container-bg);
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 页脚样式 */
|
|
||||||
.site-footer {
|
|
||||||
margin-top: 20px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--secondary-text);
|
|
||||||
font-size: 0.9em;
|
|
||||||
padding: 14px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-footer a {
|
|
||||||
color: var(--link-color);
|
|
||||||
text-decoration: none;
|
|
||||||
margin: 0 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-footer a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.site-footer {
|
|
||||||
font-size: 0.85em;
|
|
||||||
padding: 12px 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 文件上传输入框 */
|
|
||||||
.upload-input {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 删除按钮 */
|
|
||||||
.delete-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #dc3545;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-btn:hover {
|
|
||||||
background-color: rgba(220, 53, 69, 0.1);
|
|
||||||
color: #c82333;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-btn:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 操作链接按钮 */
|
|
||||||
.action-link {
|
|
||||||
color: var(--link-color);
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-link:hover {
|
|
||||||
background-color: var(--hover-bg);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 预览按钮(button标签样式) */
|
|
||||||
button.action-link {
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9em;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 下载按钮 */
|
|
||||||
.download-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #28a745;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-btn:hover {
|
|
||||||
background-color: rgba(40, 167, 69, 0.1);
|
|
||||||
color: #218838;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-btn:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 预览模态框 */
|
|
||||||
.preview-modal {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-color: rgba(0, 0, 0, 0.9);
|
|
||||||
z-index: 1000;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-modal.show {
|
|
||||||
display: flex;
|
|
||||||
opacity: 1;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-container {
|
|
||||||
position: relative;
|
|
||||||
max-width: 90%;
|
|
||||||
max-height: 90%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-content {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 85vh;
|
|
||||||
object-fit: contain;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-controls {
|
|
||||||
position: absolute;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
z-index: 1001;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-btn {
|
|
||||||
background-color: rgba(255, 255, 255, 0.9);
|
|
||||||
border: none;
|
|
||||||
color: #333;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1.2em;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-btn:hover {
|
|
||||||
background-color: white;
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-info {
|
|
||||||
margin-top: 15px;
|
|
||||||
color: white;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.95em;
|
|
||||||
background-color: rgba(0, 0, 0, 0.7);
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 20px;
|
|
||||||
max-width: 80%;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-loading {
|
|
||||||
color: white;
|
|
||||||
font-size: 1.2em;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-error {
|
|
||||||
color: #ff6b6b;
|
|
||||||
font-size: 1.1em;
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px;
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
video.preview-content {
|
|
||||||
background-color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 音频预览样式 */
|
|
||||||
.preview-audio-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 40px;
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
border-radius: 12px;
|
|
||||||
min-width: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-audio {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 500px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 文本预览样式 */
|
|
||||||
.preview-text {
|
|
||||||
background-color: rgba(255, 255, 255, 0.95);
|
|
||||||
color: #2c3e50;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
max-width: 90vw;
|
|
||||||
max-height: 85vh;
|
|
||||||
overflow: auto;
|
|
||||||
font-family: "Courier New", Courier, monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .preview-text {
|
|
||||||
background-color: rgba(45, 45, 45, 0.95);
|
|
||||||
color: #e1e1e1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 不支持预览的文件样式 */
|
|
||||||
.preview-unsupported {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 60px 40px;
|
|
||||||
background-color: rgba(0, 0, 0, 0.7);
|
|
||||||
border-radius: 12px;
|
|
||||||
color: white;
|
|
||||||
text-align: center;
|
|
||||||
min-width: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-unsupported p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-unsupported .action-link {
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-unsupported .action-link:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
|
||||||
border-color: rgba(255, 255, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.preview-container {
|
|
||||||
max-width: 95%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-controls {
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-btn {
|
|
||||||
width: 35px;
|
|
||||||
height: 35px;
|
|
||||||
font-size: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-info {
|
|
||||||
font-size: 0.85em;
|
|
||||||
padding: 8px 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-audio-wrapper {
|
|
||||||
min-width: 300px;
|
|
||||||
padding: 30px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-unsupported {
|
|
||||||
min-width: 300px;
|
|
||||||
padding: 40px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-text {
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-table .action-text {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-table .action-link {
|
|
||||||
padding: 6px;
|
|
||||||
font-size: 1.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-table td,
|
|
||||||
.files-table th {
|
|
||||||
padding: 8px 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 上传进度提示 */
|
|
||||||
.upload-status {
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-status.success {
|
|
||||||
background-color: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
border: 1px solid #c3e6cb;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-status.error {
|
|
||||||
background-color: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
border: 1px solid #f5c6cb;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 自定义对话框样式 */
|
|
||||||
.app-dialog[hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-dialog {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background-color: rgba(0, 0, 0, 0.35);
|
|
||||||
backdrop-filter: blur(2px);
|
|
||||||
z-index: 1100;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-dialog.is-visible {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-dialog__backdrop {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-dialog__panel {
|
|
||||||
position: relative;
|
|
||||||
background-color: var(--container-bg);
|
|
||||||
color: var(--text-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.25);
|
|
||||||
padding: 24px;
|
|
||||||
min-width: 320px;
|
|
||||||
max-width: min(420px, 90vw);
|
|
||||||
transform: translateY(20px);
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-dialog.is-visible .app-dialog__panel {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-dialog__title {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
font-size: 1.15em;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-dialog__message {
|
|
||||||
margin-bottom: 18px;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--text-color);
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-dialog__input {
|
|
||||||
margin-bottom: 18px;
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-dialog__input input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
background-color: var(--container-bg);
|
|
||||||
color: var(--text-color);
|
|
||||||
font-size: 0.95em;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-dialog__input input::placeholder {
|
|
||||||
color: var(--secondary-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-dialog__input input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--link-color);
|
|
||||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-dialog__actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-dialog__btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.95em;
|
|
||||||
transition: transform 0.15s ease, background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-dialog__btn:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-dialog__cancel {
|
|
||||||
background-color: var(--hover-bg);
|
|
||||||
color: var(--secondary-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-dialog__cancel:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-dialog__confirm {
|
|
||||||
background-color: #dc3545;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-dialog__confirm:hover {
|
|
||||||
background-color: #c82333;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.app-dialog__panel {
|
|
||||||
width: calc(100vw - 40px);
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-link:hover,
|
|
||||||
.grid-action-btn:hover {
|
|
||||||
color: var(--link-hover-color);
|
|
||||||
background-color: var(--hover-bg-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
#deleteTrigger,
|
|
||||||
.delete-btn,
|
|
||||||
.grid-action-btn.delete {
|
|
||||||
color: var(--danger-color, #dc3545);
|
|
||||||
}
|
|
||||||
|
|
||||||
#deleteTrigger:hover,
|
|
||||||
.delete-btn:hover,
|
|
||||||
.grid-action-btn.delete:hover {
|
|
||||||
color: var(--danger-hover-color, #a0202d);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rename-btn,
|
|
||||||
.grid-action-btn.rename {
|
|
||||||
color: var(--warning-color, #ffc107);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rename-btn:hover,
|
|
||||||
.grid-action-btn.rename:hover {
|
|
||||||
color: var(--warning-hover-color, #e0a800);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-name-col {
|
|
||||||
width: auto;
|
|
||||||
max-width: 300px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-name-col a {
|
|
||||||
display: inline-block;
|
|
||||||
max-width: calc(100% - 30px);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-size-col {
|
|
||||||
width: 100px;
|
|
||||||
min-width: 100px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.last-modified-col {
|
|
||||||
width: 180px;
|
|
||||||
min-width: 180px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-col {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-col > * {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.files-table .action-text {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.files-table .action-link {
|
|
||||||
padding: 6px 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 992px) {
|
|
||||||
.last-modified-col {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
.file-name-col {
|
|
||||||
max-width: 200px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.file-size-col {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
.file-name-col {
|
|
||||||
max-width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-table .action-text {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-table .action-link,
|
|
||||||
.files-table button.action-link {
|
|
||||||
padding: 6px;
|
|
||||||
font-size: 1em;
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-table td,
|
|
||||||
.files-table th {
|
|
||||||
padding: 8px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-col > * {
|
|
||||||
margin-left: 2px;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
body {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
padding: 12px;
|
|
||||||
width: calc(100% - 20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 > div {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-table td,
|
|
||||||
.files-table th {
|
|
||||||
padding: 6px 2px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-col {
|
|
||||||
width: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-name-col {
|
|
||||||
max-width: 120px;
|
|
||||||
font-size: 0.85em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-table .action-link,
|
|
||||||
.files-table button.action-link {
|
|
||||||
padding: 4px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
margin-left: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-col > * {
|
|
||||||
margin-left: 1px;
|
|
||||||
gap: 1px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
font-size: 1.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-table td,
|
|
||||||
.files-table th {
|
|
||||||
padding: 8px 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
339
static/css/modals.css
Normal file
339
static/css/modals.css
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
/**
|
||||||
|
* 预览模态框和对话框样式
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 预览模态框 */
|
||||||
|
.preview-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
|
z-index: 1000;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-modal.show {
|
||||||
|
display: flex;
|
||||||
|
opacity: 1;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container {
|
||||||
|
position: relative;
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 90%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-content {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 85vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
video.preview-content {
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-btn {
|
||||||
|
background-color: rgba(255, 255, 255, 0.9);
|
||||||
|
border: none;
|
||||||
|
color: #333;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-btn:hover {
|
||||||
|
background-color: white;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-info {
|
||||||
|
margin-top: 15px;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.95em;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
max-width: 80%;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-loading {
|
||||||
|
color: white;
|
||||||
|
font-size: 1.2em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 1.1em;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 音频预览样式 */
|
||||||
|
.preview-audio-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: 12px;
|
||||||
|
min-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-audio {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文本预览样式 */
|
||||||
|
.preview-text {
|
||||||
|
background-color: rgba(255, 255, 255, 0.95);
|
||||||
|
color: #2c3e50;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow: auto;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .preview-text {
|
||||||
|
background-color: rgba(45, 45, 45, 0.95);
|
||||||
|
color: #e1e1e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 不支持预览的文件样式 */
|
||||||
|
.preview-unsupported {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 40px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-unsupported p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-unsupported .action-link {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-unsupported .action-link:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.preview-container {
|
||||||
|
max-width: 95%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-controls {
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-btn {
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-info {
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 8px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-audio-wrapper {
|
||||||
|
min-width: 300px;
|
||||||
|
padding: 30px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-unsupported {
|
||||||
|
min-width: 300px;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-text {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 自定义对话框 */
|
||||||
|
.app-dialog[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dialog {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: rgba(0, 0, 0, 0.35);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
z-index: 1100;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dialog.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dialog__backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dialog__panel {
|
||||||
|
position: relative;
|
||||||
|
background-color: var(--container-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.25);
|
||||||
|
padding: 24px;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: min(420px, 90vw);
|
||||||
|
transform: translateY(20px);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dialog.is-visible .app-dialog__panel {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dialog__title {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 1.15em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dialog__message {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-color);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dialog__input {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dialog__input input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background-color: var(--container-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.95em;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dialog__input input::placeholder {
|
||||||
|
color: var(--secondary-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dialog__input input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--link-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dialog__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dialog__btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95em;
|
||||||
|
transition: transform 0.15s ease, background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dialog__btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dialog__cancel {
|
||||||
|
background-color: var(--hover-bg);
|
||||||
|
color: var(--secondary-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dialog__cancel:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dialog__confirm {
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dialog__confirm:hover {
|
||||||
|
background-color: var(--danger-hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.app-dialog__panel {
|
||||||
|
width: calc(100vw - 40px);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
197
static/css/table.css
Normal file
197
static/css/table.css
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* 表格和列表视图样式
|
||||||
|
*/
|
||||||
|
|
||||||
|
.files-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-col {
|
||||||
|
width: 48px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-col input {
|
||||||
|
transform: scale(1.05);
|
||||||
|
accent-color: var(--link-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-table th,
|
||||||
|
.files-table td {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-table th {
|
||||||
|
background-color: var(--hover-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-table tr:hover {
|
||||||
|
background-color: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name-col {
|
||||||
|
width: auto;
|
||||||
|
max-width: 300px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name-col a {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: calc(100% - 30px);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name-col:hover {
|
||||||
|
background-color: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
margin-right: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder {
|
||||||
|
color: var(--folder-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file {
|
||||||
|
color: var(--file-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
color: var(--secondary-text);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size-col {
|
||||||
|
width: 100px;
|
||||||
|
min-width: 100px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-modified {
|
||||||
|
color: var(--secondary-text);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-modified-col {
|
||||||
|
width: 180px;
|
||||||
|
min-width: 180px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-col {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-col > * {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 0.95em;
|
||||||
|
color: var(--secondary-text);
|
||||||
|
background-color: var(--container-bg);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-message {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: var(--secondary-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.files-table .action-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.files-table .action-link {
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.last-modified-col {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.file-name-col {
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.file-size-col {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.file-name-col {
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-table .action-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-table .action-link,
|
||||||
|
.files-table button.action-link {
|
||||||
|
padding: 6px;
|
||||||
|
font-size: 1em;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-table td,
|
||||||
|
.files-table th {
|
||||||
|
padding: 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-col > * {
|
||||||
|
margin-left: 2px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.files-table td,
|
||||||
|
.files-table th {
|
||||||
|
padding: 6px 2px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-col {
|
||||||
|
width: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name-col {
|
||||||
|
max-width: 120px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-table .action-link,
|
||||||
|
.files-table button.action-link {
|
||||||
|
padding: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-left: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-col > * {
|
||||||
|
margin-left: 1px;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
85
static/css/utilities.css
Normal file
85
static/css/utilities.css
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* 状态消息和其他实用样式
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 上传进度提示 */
|
||||||
|
.upload-status {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status.success {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status.error {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页脚样式 */
|
||||||
|
.site-footer {
|
||||||
|
margin-top: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--secondary-text);
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding: 14px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer a {
|
||||||
|
color: var(--link-color);
|
||||||
|
text-decoration: none;
|
||||||
|
margin: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 上传输入框 */
|
||||||
|
.upload-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 禁用状态 */
|
||||||
|
.is-disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.site-footer {
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 12px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
body {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 12px;
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 > div {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
static/css/variables.css
Normal file
95
static/css/variables.css
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* CSS 变量和主题定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-color: #f5f5f5;
|
||||||
|
--container-bg: white;
|
||||||
|
--text-color: #2c3e50;
|
||||||
|
--border-color: #eee;
|
||||||
|
--hover-bg: #f8f9fa;
|
||||||
|
--secondary-text: #6c757d;
|
||||||
|
--link-color: #007bff;
|
||||||
|
--folder-color: #ffc107;
|
||||||
|
--file-color: #6c757d;
|
||||||
|
--shadow-color: rgba(0, 0, 0, 0.1);
|
||||||
|
--icon-bg: #f0f0f0;
|
||||||
|
--danger-color: #dc3545;
|
||||||
|
--danger-hover-color: #a0202d;
|
||||||
|
--warning-color: #ffc107;
|
||||||
|
--warning-hover-color: #e0a800;
|
||||||
|
--success-color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg-color: #1a1a1a;
|
||||||
|
--container-bg: #2d2d2d;
|
||||||
|
--text-color: #e1e1e1;
|
||||||
|
--border-color: #404040;
|
||||||
|
--hover-bg: #363636;
|
||||||
|
--secondary-text: #a0a0a0;
|
||||||
|
--link-color: #66b3ff;
|
||||||
|
--folder-color: #ffd54f;
|
||||||
|
--file-color: #b0b0b0;
|
||||||
|
--shadow-color: rgba(0, 0, 0, 0.3);
|
||||||
|
--icon-bg: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基础样式
|
||||||
|
*/
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background-color: var(--container-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px var(--shadow-color);
|
||||||
|
padding: 20px;
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: var(--text-color);
|
||||||
|
margin-bottom: 30px;
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 > div {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--link-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
235
static/js/dialog.js
Normal file
235
static/js/dialog.js
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* 对话框(Dialog)相关功能
|
||||||
|
* 包括确认、输入、提示等对话框
|
||||||
|
*/
|
||||||
|
|
||||||
|
const dialogState = {
|
||||||
|
container: null,
|
||||||
|
title: null,
|
||||||
|
message: null,
|
||||||
|
inputWrapper: null,
|
||||||
|
input: null,
|
||||||
|
confirmBtn: null,
|
||||||
|
cancelBtn: null,
|
||||||
|
resolve: null,
|
||||||
|
options: null,
|
||||||
|
previousActiveElement: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查对话框是否打开
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isDialogOpen() {
|
||||||
|
return Boolean(dialogState.container && !dialogState.container.hasAttribute("hidden"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭对话框
|
||||||
|
* @param {boolean} confirmed - 是否确认
|
||||||
|
*/
|
||||||
|
function closeDialog(confirmed) {
|
||||||
|
if (!dialogState.resolve || !dialogState.container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showInput = dialogState.options?.showInput;
|
||||||
|
const value = confirmed && showInput && dialogState.input ? dialogState.input.value : undefined;
|
||||||
|
|
||||||
|
dialogState.container.classList.remove("is-visible");
|
||||||
|
dialogState.container.setAttribute("aria-hidden", "true");
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!dialogState.container.classList.contains("is-visible")) {
|
||||||
|
dialogState.container.setAttribute("hidden", "");
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
if (dialogState.inputWrapper) {
|
||||||
|
dialogState.inputWrapper.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolve = dialogState.resolve;
|
||||||
|
dialogState.resolve = null;
|
||||||
|
const options = dialogState.options || {};
|
||||||
|
dialogState.options = null;
|
||||||
|
|
||||||
|
if (dialogState.previousActiveElement && typeof dialogState.previousActiveElement.focus === "function") {
|
||||||
|
dialogState.previousActiveElement.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
dialogState.previousActiveElement = null;
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
confirmed,
|
||||||
|
value: value !== undefined ? value : undefined,
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开对话框
|
||||||
|
* @param {object} options - 对话框选项
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
function openDialog(options) {
|
||||||
|
if (!dialogState.container) {
|
||||||
|
return Promise.resolve({ confirmed: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
dialogState.resolve = resolve;
|
||||||
|
dialogState.options = options;
|
||||||
|
dialogState.previousActiveElement =
|
||||||
|
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
|
||||||
|
dialogState.container.removeAttribute("hidden");
|
||||||
|
dialogState.container.setAttribute("aria-hidden", "false");
|
||||||
|
|
||||||
|
if (dialogState.title) {
|
||||||
|
dialogState.title.textContent = options.title || "";
|
||||||
|
dialogState.title.hidden = !options.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialogState.message) {
|
||||||
|
dialogState.message.textContent = options.message || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialogState.inputWrapper && dialogState.input) {
|
||||||
|
dialogState.inputWrapper.hidden = !options.showInput;
|
||||||
|
dialogState.input.value = options.defaultValue || "";
|
||||||
|
dialogState.input.placeholder = options.placeholder || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialogState.confirmBtn) {
|
||||||
|
dialogState.confirmBtn.textContent = options.confirmLabel || "确定";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialogState.cancelBtn) {
|
||||||
|
dialogState.cancelBtn.textContent = options.cancelLabel || "取消";
|
||||||
|
dialogState.cancelBtn.hidden = options.hideCancel || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
if (!dialogState.container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dialogState.container.classList.add("is-visible");
|
||||||
|
|
||||||
|
if (options.showInput && dialogState.input) {
|
||||||
|
dialogState.input.focus();
|
||||||
|
dialogState.input.select();
|
||||||
|
} else if (dialogState.confirmBtn) {
|
||||||
|
dialogState.confirmBtn.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化对话框
|
||||||
|
*/
|
||||||
|
function initDialog() {
|
||||||
|
const container = document.getElementById("appDialog");
|
||||||
|
if (!container || container.dataset.initialized === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.dataset.initialized = "true";
|
||||||
|
dialogState.container = container;
|
||||||
|
dialogState.title = document.getElementById("appDialogTitle");
|
||||||
|
dialogState.message = document.getElementById("appDialogMessage");
|
||||||
|
dialogState.inputWrapper = document.getElementById("appDialogInputWrapper");
|
||||||
|
dialogState.input = document.getElementById("appDialogInput");
|
||||||
|
dialogState.confirmBtn = document.getElementById("appDialogConfirm");
|
||||||
|
dialogState.cancelBtn = document.getElementById("appDialogCancel");
|
||||||
|
|
||||||
|
if (dialogState.confirmBtn) {
|
||||||
|
dialogState.confirmBtn.addEventListener("click", () => closeDialog(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialogState.cancelBtn) {
|
||||||
|
dialogState.cancelBtn.addEventListener("click", () => closeDialog(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener("click", (event) => {
|
||||||
|
if (
|
||||||
|
event.target === container ||
|
||||||
|
(event.target instanceof HTMLElement && event.target.dataset.dialogDismiss === "true")
|
||||||
|
) {
|
||||||
|
closeDialog(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (!isDialogOpen()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
closeDialog(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Enter" && dialogState.options?.showInput) {
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (active === dialogState.input) {
|
||||||
|
event.preventDefault();
|
||||||
|
closeDialog(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示确认对话框
|
||||||
|
* @param {string} message - 消息
|
||||||
|
* @param {object} options - 选项
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
function showConfirm(message, options = {}) {
|
||||||
|
return openDialog({
|
||||||
|
title: options.title || "确认操作",
|
||||||
|
message,
|
||||||
|
confirmLabel: options.confirmLabel || "确定",
|
||||||
|
cancelLabel: options.cancelLabel || "取消",
|
||||||
|
hideCancel: options.hideCancel || false,
|
||||||
|
}).then((result) => Boolean(result.confirmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示输入对话框
|
||||||
|
* @param {string} message - 消息
|
||||||
|
* @param {object} options - 选项
|
||||||
|
* @returns {Promise<string|null>}
|
||||||
|
*/
|
||||||
|
async function showPrompt(message, options = {}) {
|
||||||
|
const result = await openDialog({
|
||||||
|
title: options.title || "请输入内容",
|
||||||
|
message,
|
||||||
|
confirmLabel: options.confirmLabel || "确定",
|
||||||
|
cancelLabel: options.cancelLabel || "取消",
|
||||||
|
showInput: true,
|
||||||
|
defaultValue: options.defaultValue || "",
|
||||||
|
placeholder: options.placeholder || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.confirmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = typeof result.value === "string" ? result.value.trim() : "";
|
||||||
|
return value === "" ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出到全局作用域
|
||||||
|
*/
|
||||||
|
window.DialogUtils = {
|
||||||
|
isDialogOpen,
|
||||||
|
closeDialog,
|
||||||
|
openDialog,
|
||||||
|
initDialog,
|
||||||
|
showConfirm,
|
||||||
|
showPrompt,
|
||||||
|
};
|
||||||
32
static/js/download.js
Normal file
32
static/js/download.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 下载按钮事件处理
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定下载按钮监听器
|
||||||
|
*/
|
||||||
|
function attachDownloadButtonListeners() {
|
||||||
|
const downloadButtons = document.querySelectorAll("[data-download-key]");
|
||||||
|
downloadButtons.forEach((button) => {
|
||||||
|
if (!button.dataset.listenerAttached) {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const key = button.dataset.downloadKey;
|
||||||
|
const name = button.dataset.downloadName;
|
||||||
|
// 对路径分段编码,保留路径分隔符,避免 # ? 等字符破坏 URL
|
||||||
|
const encoded = key
|
||||||
|
.split("/")
|
||||||
|
.map((seg) => encodeURIComponent(seg))
|
||||||
|
.join("/");
|
||||||
|
downloadFile(`/download/${encoded}`, name);
|
||||||
|
});
|
||||||
|
button.dataset.listenerAttached = "true";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出到全局作用域
|
||||||
|
*/
|
||||||
|
window.DownloadUtils = {
|
||||||
|
attachDownloadButtonListeners,
|
||||||
|
};
|
||||||
444
static/js/file-operations.js
Normal file
444
static/js/file-operations.js
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
/**
|
||||||
|
* 文件操作相关功能
|
||||||
|
* 包括上传、下载、删除、重命名等
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件
|
||||||
|
* @param {FileList} files - 文件列表
|
||||||
|
*/
|
||||||
|
async function uploadFiles(files) {
|
||||||
|
const currentPrefix = document.body.dataset.currentPrefix || "";
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("prefix", currentPrefix);
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateStatus(`正在上传: ${file.name}...`, null);
|
||||||
|
|
||||||
|
const response = await fetch("/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const statusDiv = updateStatus(`✓ ${file.name} 上传成功!`, "success");
|
||||||
|
hideStatusLater(statusDiv);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
updateStatus(`✗ ${file.name} 上传失败: ${result.error}`, "error");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
updateStatus(`✗ ${file.name} 上传失败: ${error.message}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新建文件夹(弹出输入框并调用后端创建)
|
||||||
|
*/
|
||||||
|
async function promptCreateFolder() {
|
||||||
|
const currentPrefix = document.body.dataset.currentPrefix || "";
|
||||||
|
const name = await showPrompt("请输入新文件夹名称:", {
|
||||||
|
title: "新建文件夹",
|
||||||
|
confirmLabel: "创建",
|
||||||
|
placeholder: "例如:photos 或 nested/folder",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
// 规范化路径:允许嵌套,移除多余斜杠
|
||||||
|
let normalized = name.trim().replace(/\\/g, "/");
|
||||||
|
normalized = normalized.replace(/^\/+|\/+$/g, "");
|
||||||
|
if (!normalized) {
|
||||||
|
updateStatus("✗ 文件夹名称不能为空", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = (currentPrefix || "") + normalized + "/";
|
||||||
|
updateStatus(`正在创建文件夹: ${fullPath}...`, null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/create_folder", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ path: fullPath }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
const statusDiv = updateStatus("✓ 文件夹创建成功!", "success");
|
||||||
|
hideStatusLater(statusDiv);
|
||||||
|
setTimeout(() => {
|
||||||
|
// 进入新建的文件夹
|
||||||
|
window.location.href = `/${(currentPrefix + normalized).replace(/\/+$/, "")}`;
|
||||||
|
}, 1200);
|
||||||
|
} else {
|
||||||
|
updateStatus(`✗ 创建失败: ${result.error}`, "error");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
updateStatus(`✗ 创建失败: ${error.message}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提示删除文件
|
||||||
|
*/
|
||||||
|
async function promptDelete() {
|
||||||
|
const suggested = "";
|
||||||
|
const path = await showPrompt("请输入要删除的文件路径(相对于存储桶),例如:folder/file.jpg", {
|
||||||
|
title: "删除文件",
|
||||||
|
defaultValue: suggested,
|
||||||
|
placeholder: "folder/file.jpg",
|
||||||
|
confirmLabel: "删除",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (path) {
|
||||||
|
await deleteFile(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文件夹
|
||||||
|
* @param {string} prefix - 文件夹前缀
|
||||||
|
*/
|
||||||
|
async function deleteFolder(prefix) {
|
||||||
|
const confirmed = await showConfirm(`确定要删除文件夹 "${prefix}" 及其所有内容吗?此操作不可逆!`, {
|
||||||
|
title: "删除文件夹",
|
||||||
|
confirmLabel: "确认删除",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus(`正在删除文件夹: ${prefix}...`, null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/delete_folder/${prefix}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const statusDiv = updateStatus("✓ 文件夹删除成功!", "success");
|
||||||
|
hideStatusLater(statusDiv);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const parentPath = prefix.split("/").slice(0, -2).join("/");
|
||||||
|
window.location.href = parentPath ? `/${parentPath}` : "/";
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
updateStatus(`✗ 删除失败: ${result.error}`, "error");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
updateStatus(`✗ 删除失败: ${error.message}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除单个文件
|
||||||
|
* @param {string} filePath - 文件路径
|
||||||
|
* @param {object} options - 选项
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async function deleteFile(filePath, options = {}) {
|
||||||
|
const { skipConfirm = false, suppressReload = false, suppressStatus = false } = options;
|
||||||
|
|
||||||
|
if (!skipConfirm) {
|
||||||
|
const confirmed = await showConfirm(`确定要删除 "${filePath}" 吗?`, {
|
||||||
|
title: "删除文件",
|
||||||
|
confirmLabel: "删除",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!suppressStatus) {
|
||||||
|
updateStatus(`正在删除: ${filePath}...`, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/delete/${filePath}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
if (!suppressStatus) {
|
||||||
|
const statusDiv = updateStatus("✓ 文件删除成功!", "success");
|
||||||
|
hideStatusLater(statusDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!suppressReload) {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!suppressStatus) {
|
||||||
|
updateStatus(`✗ 删除失败: ${result.error}`, "error");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
if (!suppressStatus) {
|
||||||
|
updateStatus(`✗ 删除失败: ${error.message}`, "error");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载文件
|
||||||
|
* @param {string} url - 下载 URL
|
||||||
|
* @param {string} filename - 文件名
|
||||||
|
*/
|
||||||
|
function downloadFile(url, filename) {
|
||||||
|
if (!url) {
|
||||||
|
updateStatus("✗ 无法下载:缺少下载链接", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith("/download/") || url.startsWith("/file/")) {
|
||||||
|
// 让浏览器原生跟随服务器重定向(OneDrive 直链/共享链接),避免 fetch 对 3xx 的处理差异
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener";
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
const statusDiv = updateStatus(`✓ 开始下载: ${filename || ""}`, "success");
|
||||||
|
hideStatusLater(statusDiv);
|
||||||
|
} else {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename || "";
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener";
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
const statusDiv = updateStatus(`✓ 开始下载: ${filename || ""}`, "success");
|
||||||
|
hideStatusLater(statusDiv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重命名文件
|
||||||
|
* @param {string} oldKey - 旧的文件键
|
||||||
|
* @param {string} newName - 新名称
|
||||||
|
*/
|
||||||
|
async function renameFile(oldKey, newName) {
|
||||||
|
updateStatus(`正在重命名: ${oldKey}...`, null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/rename/${oldKey}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ newName: newName }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const statusDiv = updateStatus("✓ 文件重命名成功!", "success");
|
||||||
|
hideStatusLater(statusDiv);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
updateStatus(`✗ 重命名失败: ${result.error}`, "error");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
updateStatus(`✗ 重命名失败: ${error.message}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重命名文件夹
|
||||||
|
* @param {string} oldPrefix - 旧的文件夹前缀
|
||||||
|
* @param {string} newName - 新名称
|
||||||
|
*/
|
||||||
|
async function renameFolder(oldPrefix, newName) {
|
||||||
|
updateStatus(`正在重命名文件夹: ${oldPrefix}...`, null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/rename_folder/${oldPrefix}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ newName: newName }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const statusDiv = updateStatus("✓ 文件夹重命名成功!", "success");
|
||||||
|
hideStatusLater(statusDiv);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
updateStatus(`✗ 重命名失败: ${result.error}`, "error");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
updateStatus(`✗ 重命名失败: ${error.message}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提示重命名
|
||||||
|
* @param {string} oldKey - 旧键
|
||||||
|
* @param {string} oldName - 旧名称
|
||||||
|
* @param {boolean} isFolder - 是否是文件夹
|
||||||
|
*/
|
||||||
|
async function promptRename(oldKey, oldName, isFolder = false) {
|
||||||
|
const title = isFolder ? "重命名文件夹" : "重命名文件";
|
||||||
|
const newName = await showPrompt(`请输入新的名称:`, {
|
||||||
|
title: title,
|
||||||
|
defaultValue: oldName,
|
||||||
|
confirmLabel: "重命名",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newName && newName !== oldName) {
|
||||||
|
if (isFolder) {
|
||||||
|
await renameFolder(oldKey, newName);
|
||||||
|
} else {
|
||||||
|
await renameFile(oldKey, newName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制项目
|
||||||
|
* @param {string} source - 源路径
|
||||||
|
* @param {string} destination - 目标路径
|
||||||
|
* @param {boolean} isFolder - 是否是文件夹
|
||||||
|
*/
|
||||||
|
async function copyItem(source, destination, isFolder) {
|
||||||
|
updateStatus(`正在复制...`, null);
|
||||||
|
await performOperation("/copy", "复制", { source, destination, is_folder: isFolder });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动项目
|
||||||
|
* @param {string} source - 源路径
|
||||||
|
* @param {string} destination - 目标路径
|
||||||
|
* @param {boolean} isFolder - 是否是文件夹
|
||||||
|
*/
|
||||||
|
async function moveItem(source, destination, isFolder) {
|
||||||
|
updateStatus(`正在移动...`, null);
|
||||||
|
await performOperation("/move", "移动", { source, destination, is_folder: isFolder });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提示复制或移动
|
||||||
|
* @param {string} source - 源路径
|
||||||
|
* @param {boolean} isFolder - 是否是文件夹
|
||||||
|
* @param {string} operation - 操作类型 ('copy' 或 'move')
|
||||||
|
*/
|
||||||
|
async function promptCopyOrMove(source, isFolder, operation) {
|
||||||
|
const opText = operation === "copy" ? "复制" : "移动";
|
||||||
|
const itemText = isFolder ? "文件夹" : "文件";
|
||||||
|
const dest = await showPrompt(`请输入目标目录路径:`, {
|
||||||
|
title: `${opText}${itemText}`,
|
||||||
|
confirmLabel: opText,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dest) {
|
||||||
|
let normalizedDest = dest;
|
||||||
|
if (!normalizedDest.endsWith("/")) {
|
||||||
|
normalizedDest += "/";
|
||||||
|
}
|
||||||
|
let name = source
|
||||||
|
.split("/")
|
||||||
|
.filter((p) => p)
|
||||||
|
.pop();
|
||||||
|
if (isFolder) {
|
||||||
|
name += "/";
|
||||||
|
}
|
||||||
|
const fullDest = normalizedDest + name;
|
||||||
|
|
||||||
|
if (operation === "copy") {
|
||||||
|
await copyItem(source, fullDest, isFolder);
|
||||||
|
} else {
|
||||||
|
await moveItem(source, fullDest, isFolder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行复制/移动操作
|
||||||
|
* @param {string} endpoint - API 端点
|
||||||
|
* @param {string} opText - 操作文本
|
||||||
|
* @param {object} body - 请求体
|
||||||
|
*/
|
||||||
|
async function performOperation(endpoint, opText, body) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const statusDiv = updateStatus(`✓ ${opText}成功!`, "success");
|
||||||
|
hideStatusLater(statusDiv);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
updateStatus(`✗ ${opText}失败: ${result.error}`, "error");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
updateStatus(`✗ ${opText}失败: ${error.message}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出到全局作用域
|
||||||
|
*/
|
||||||
|
window.FileOps = {
|
||||||
|
uploadFiles,
|
||||||
|
promptDelete,
|
||||||
|
deleteFolder,
|
||||||
|
deleteFile,
|
||||||
|
downloadFile,
|
||||||
|
renameFile,
|
||||||
|
renameFolder,
|
||||||
|
promptRename,
|
||||||
|
promptCreateFolder,
|
||||||
|
copyItem,
|
||||||
|
moveItem,
|
||||||
|
promptCopyOrMove,
|
||||||
|
performOperation,
|
||||||
|
};
|
||||||
1099
static/js/main.js
1099
static/js/main.js
File diff suppressed because it is too large
Load Diff
189
static/js/preview.js
Normal file
189
static/js/preview.js
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* 文件预览功能
|
||||||
|
* 包括图片、视频、音频、PDF、文本等预览
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件类型
|
||||||
|
* @param {string} filename - 文件名
|
||||||
|
* @returns {string} 文件类型
|
||||||
|
*/
|
||||||
|
function getFileType(filename) {
|
||||||
|
const extension = filename.toLowerCase().split(".").pop();
|
||||||
|
const imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "ico"];
|
||||||
|
const videoExtensions = ["mp4", "webm", "ogg", "mov", "avi", "mkv", "m4v"];
|
||||||
|
const audioExtensions = ["mp3", "wav", "ogg", "m4a", "aac", "flac", "opus", "weba"];
|
||||||
|
const pdfExtensions = ["pdf"];
|
||||||
|
const textExtensions = [
|
||||||
|
"txt",
|
||||||
|
"log",
|
||||||
|
"md",
|
||||||
|
"json",
|
||||||
|
"xml",
|
||||||
|
"csv",
|
||||||
|
"js",
|
||||||
|
"css",
|
||||||
|
"html",
|
||||||
|
"py",
|
||||||
|
"java",
|
||||||
|
"c",
|
||||||
|
"cpp",
|
||||||
|
"h",
|
||||||
|
"hpp",
|
||||||
|
"sh",
|
||||||
|
"bat",
|
||||||
|
"yaml",
|
||||||
|
"yml",
|
||||||
|
"toml",
|
||||||
|
"ini",
|
||||||
|
"conf",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (imageExtensions.includes(extension)) {
|
||||||
|
return "image";
|
||||||
|
}
|
||||||
|
if (videoExtensions.includes(extension)) {
|
||||||
|
return "video";
|
||||||
|
}
|
||||||
|
if (audioExtensions.includes(extension)) {
|
||||||
|
return "audio";
|
||||||
|
}
|
||||||
|
if (pdfExtensions.includes(extension)) {
|
||||||
|
return "pdf";
|
||||||
|
}
|
||||||
|
if (textExtensions.includes(extension)) {
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
return "unsupported";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭预览
|
||||||
|
*/
|
||||||
|
function closePreview() {
|
||||||
|
const modal = document.getElementById("previewModal");
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove("show");
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开预览
|
||||||
|
* @param {string} url - 文件 URL
|
||||||
|
* @param {string} filename - 文件名
|
||||||
|
*/
|
||||||
|
function openPreview(url, filename) {
|
||||||
|
const modal = document.getElementById("previewModal");
|
||||||
|
const container = document.getElementById("previewContainer");
|
||||||
|
const info = document.getElementById("previewInfo");
|
||||||
|
|
||||||
|
if (!modal || !container || !info) {
|
||||||
|
window.open(url, "_blank");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileType = getFileType(filename);
|
||||||
|
|
||||||
|
// 对于不支持预览的文件类型,显示提示信息
|
||||||
|
if (fileType === "unsupported") {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="preview-unsupported">
|
||||||
|
<i class="fas fa-file-alt" style="font-size: 64px; color: var(--text-secondary); margin-bottom: 16px;"></i>
|
||||||
|
<p style="font-size: 18px; margin-bottom: 8px;">该文件不支持预览</p>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 24px;">文件名: ${filename}</p>
|
||||||
|
<div style="display: flex; gap: 12px; justify-content: center;">
|
||||||
|
<button class="action-link" onclick="downloadFile('${url}', '${filename}'); closePreview();" style="padding: 8px 16px;">
|
||||||
|
<i class="fas fa-download"></i> 下载文件
|
||||||
|
</button>
|
||||||
|
<button class="action-link" onclick="window.open('${url}', '_blank'); closePreview();" style="padding: 8px 16px;">
|
||||||
|
<i class="fas fa-external-link-alt"></i> 新窗口打开
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
info.textContent = filename;
|
||||||
|
modal.classList.add("show");
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = '<div class="preview-loading">加载中...</div>';
|
||||||
|
info.textContent = filename;
|
||||||
|
modal.classList.add("show");
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (fileType === "image") {
|
||||||
|
const image = document.createElement("img");
|
||||||
|
image.className = "preview-content";
|
||||||
|
image.src = url;
|
||||||
|
image.alt = filename;
|
||||||
|
image.style.maxWidth = "100%";
|
||||||
|
image.style.maxHeight = "100%";
|
||||||
|
image.onerror = () => {
|
||||||
|
container.innerHTML = '<div class="preview-error">加载失败</div>';
|
||||||
|
};
|
||||||
|
container.innerHTML = "";
|
||||||
|
container.appendChild(image);
|
||||||
|
} else if (fileType === "video") {
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.className = "preview-content";
|
||||||
|
video.controls = true;
|
||||||
|
video.style.maxWidth = "100%";
|
||||||
|
video.style.maxHeight = "100%";
|
||||||
|
const source = document.createElement("source");
|
||||||
|
source.src = url;
|
||||||
|
source.type = `video/${url.split(".").pop()}`;
|
||||||
|
video.appendChild(source);
|
||||||
|
container.innerHTML = "";
|
||||||
|
container.appendChild(video);
|
||||||
|
} else if (fileType === "audio") {
|
||||||
|
const audio = document.createElement("audio");
|
||||||
|
audio.className = "preview-content";
|
||||||
|
audio.controls = true;
|
||||||
|
audio.style.width = "100%";
|
||||||
|
const source = document.createElement("source");
|
||||||
|
source.src = url;
|
||||||
|
source.type = `audio/${url.split(".").pop()}`;
|
||||||
|
audio.appendChild(source);
|
||||||
|
container.innerHTML = "";
|
||||||
|
container.appendChild(audio);
|
||||||
|
} else if (fileType === "pdf") {
|
||||||
|
container.innerHTML = `
|
||||||
|
<iframe
|
||||||
|
src="${url}#toolbar=0"
|
||||||
|
style="width: 100%; height: 100%; border: none;"
|
||||||
|
title="${filename}"
|
||||||
|
></iframe>
|
||||||
|
`;
|
||||||
|
} else if (fileType === "text") {
|
||||||
|
fetch(url)
|
||||||
|
.then((response) => response.text())
|
||||||
|
.then((text) => {
|
||||||
|
const pre = document.createElement("pre");
|
||||||
|
pre.className = "preview-text";
|
||||||
|
pre.textContent = text;
|
||||||
|
pre.style.margin = "0";
|
||||||
|
pre.style.padding = "16px";
|
||||||
|
pre.style.overflow = "auto";
|
||||||
|
pre.style.whiteSpace = "pre-wrap";
|
||||||
|
pre.style.wordWrap = "break-word";
|
||||||
|
container.innerHTML = "";
|
||||||
|
container.appendChild(pre);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
container.innerHTML = '<div class="preview-error">加载失败</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出到全局作用域
|
||||||
|
*/
|
||||||
|
window.PreviewUtils = {
|
||||||
|
getFileType,
|
||||||
|
closePreview,
|
||||||
|
openPreview,
|
||||||
|
};
|
||||||
178
static/js/selection.js
Normal file
178
static/js/selection.js
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* 文件选择和批量操作相关功能
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有条目复选框
|
||||||
|
* @returns {NodeListOf<Element>}
|
||||||
|
*/
|
||||||
|
function getEntryCheckboxes() {
|
||||||
|
return document.querySelectorAll(".entry-checkbox");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新全选状态
|
||||||
|
*/
|
||||||
|
function updateSelectAllState() {
|
||||||
|
const master = document.getElementById("selectAll");
|
||||||
|
if (!master) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkboxes = Array.from(getEntryCheckboxes());
|
||||||
|
if (checkboxes.length === 0) {
|
||||||
|
master.checked = false;
|
||||||
|
master.indeterminate = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkedCount = checkboxes.filter((checkbox) => checkbox.checked).length;
|
||||||
|
|
||||||
|
if (checkedCount === 0) {
|
||||||
|
master.checked = false;
|
||||||
|
master.indeterminate = false;
|
||||||
|
} else if (checkedCount === checkboxes.length) {
|
||||||
|
master.checked = true;
|
||||||
|
master.indeterminate = false;
|
||||||
|
} else {
|
||||||
|
master.checked = false;
|
||||||
|
master.indeterminate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换全选
|
||||||
|
* @param {HTMLInputElement} master - 主复选框
|
||||||
|
*/
|
||||||
|
function toggleSelectAll(master) {
|
||||||
|
const checkboxes = getEntryCheckboxes();
|
||||||
|
const desiredState = Boolean(master.checked);
|
||||||
|
|
||||||
|
checkboxes.forEach((checkbox) => {
|
||||||
|
checkbox.checked = desiredState;
|
||||||
|
});
|
||||||
|
|
||||||
|
master.indeterminate = false;
|
||||||
|
updateSelectAllState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定复选框监听器
|
||||||
|
*/
|
||||||
|
function attachEntryCheckboxListeners() {
|
||||||
|
const master = document.getElementById("selectAll");
|
||||||
|
if (master && !master.dataset.listenerAttached) {
|
||||||
|
master.addEventListener("change", () => toggleSelectAll(master));
|
||||||
|
master.dataset.listenerAttached = "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
getEntryCheckboxes().forEach((checkbox) => {
|
||||||
|
if (!checkbox.dataset.listenerAttached) {
|
||||||
|
checkbox.addEventListener("change", updateSelectAllState);
|
||||||
|
checkbox.dataset.listenerAttached = "true";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateSelectAllState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除选定的条目
|
||||||
|
*/
|
||||||
|
async function deleteSelectedEntries() {
|
||||||
|
const selected = Array.from(getEntryCheckboxes()).filter((checkbox) => checkbox.checked);
|
||||||
|
|
||||||
|
if (selected.length === 0) {
|
||||||
|
const statusDiv = updateStatus("✗ 请先选择要删除的项目", "error");
|
||||||
|
hideStatusLater(statusDiv);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directories = selected.filter((checkbox) => checkbox.dataset.type === "dir");
|
||||||
|
if (directories.length > 0) {
|
||||||
|
const statusDiv = updateStatus("✗ 暂不支持批量删除文件夹,请仅选择文件", "error");
|
||||||
|
hideStatusLater(statusDiv);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = selected.map((checkbox) => checkbox.value);
|
||||||
|
const confirmMessage =
|
||||||
|
files.length === 1 ? `确定要删除 "${files[0]}" 吗?` : `确定要删除选中的 ${files.length} 个文件吗?`;
|
||||||
|
|
||||||
|
const confirmed = await showConfirm(confirmMessage, {
|
||||||
|
title: "批量删除",
|
||||||
|
confirmLabel: "删除",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteButton = document.getElementById("deleteTrigger");
|
||||||
|
if (deleteButton) {
|
||||||
|
deleteButton.disabled = true;
|
||||||
|
deleteButton.classList.add("is-disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
const inProgressStatus = updateStatus(`正在删除 ${files.length} 个文件...`, null);
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
let successCount = 0;
|
||||||
|
|
||||||
|
for (const filePath of files) {
|
||||||
|
const result = await deleteFile(filePath, {
|
||||||
|
skipConfirm: true,
|
||||||
|
suppressReload: true,
|
||||||
|
suppressStatus: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
successCount += 1;
|
||||||
|
} else {
|
||||||
|
failures.push(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteButton) {
|
||||||
|
deleteButton.disabled = false;
|
||||||
|
deleteButton.classList.remove("is-disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inProgressStatus) {
|
||||||
|
inProgressStatus.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length === 0 && successCount > 0) {
|
||||||
|
const statusDiv = updateStatus(`✓ 已删除 ${successCount} 个文件`, "success");
|
||||||
|
hideStatusLater(statusDiv, 3000);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
const message =
|
||||||
|
failures.length === files.length ? "✗ 删除失败,请稍后重试" : `删除部分文件失败:${failures.join(", ")}`;
|
||||||
|
const statusDiv = updateStatus(message, "error");
|
||||||
|
hideStatusLater(statusDiv, 4000);
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出到全局作用域
|
||||||
|
*/
|
||||||
|
window.SelectionUtils = {
|
||||||
|
getEntryCheckboxes,
|
||||||
|
updateSelectAllState,
|
||||||
|
toggleSelectAll,
|
||||||
|
attachEntryCheckboxListeners,
|
||||||
|
deleteSelectedEntries,
|
||||||
|
};
|
||||||
98
static/js/theme.js
Normal file
98
static/js/theme.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* 主题和视图切换功能
|
||||||
|
* 包括深色/浅色主题切换、列表/网格视图切换
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化主题和视图
|
||||||
|
*/
|
||||||
|
function initThemeAndView() {
|
||||||
|
const themeToggle = document.getElementById("themeToggle");
|
||||||
|
const viewToggle = document.getElementById("viewToggle");
|
||||||
|
|
||||||
|
if (!themeToggle || !viewToggle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeIcon = themeToggle.querySelector("i");
|
||||||
|
const viewIcon = viewToggle.querySelector("i");
|
||||||
|
|
||||||
|
const savedTheme = localStorage.getItem("theme");
|
||||||
|
if (savedTheme === "dark") {
|
||||||
|
document.documentElement.setAttribute("data-theme", "dark");
|
||||||
|
if (themeIcon) {
|
||||||
|
themeIcon.classList.remove("fa-moon");
|
||||||
|
themeIcon.classList.add("fa-sun");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedView = localStorage.getItem("view") || "list";
|
||||||
|
document.documentElement.setAttribute("data-view", savedView);
|
||||||
|
if (viewIcon) {
|
||||||
|
if (savedView === "grid") {
|
||||||
|
viewIcon.classList.remove("fa-th-large");
|
||||||
|
viewIcon.classList.add("fa-th-list");
|
||||||
|
} else {
|
||||||
|
viewIcon.classList.remove("fa-th-list");
|
||||||
|
viewIcon.classList.add("fa-th-large");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewToggle.addEventListener("click", () => {
|
||||||
|
const current = document.documentElement.getAttribute("data-view") || "list";
|
||||||
|
const next = current === "grid" ? "list" : "grid";
|
||||||
|
document.documentElement.setAttribute("data-view", next);
|
||||||
|
localStorage.setItem("view", next);
|
||||||
|
|
||||||
|
if (!viewIcon) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === "grid") {
|
||||||
|
viewIcon.classList.remove("fa-th-large");
|
||||||
|
viewIcon.classList.add("fa-th-list");
|
||||||
|
} else {
|
||||||
|
viewIcon.classList.remove("fa-th-list");
|
||||||
|
viewIcon.classList.add("fa-th-large");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
themeToggle.addEventListener("click", () => {
|
||||||
|
const currentTheme = document.documentElement.getAttribute("data-theme");
|
||||||
|
const newTheme = currentTheme === "dark" ? "light" : "dark";
|
||||||
|
|
||||||
|
document.documentElement.setAttribute("data-theme", newTheme);
|
||||||
|
localStorage.setItem("theme", newTheme);
|
||||||
|
|
||||||
|
if (!themeIcon) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newTheme === "dark") {
|
||||||
|
themeIcon.classList.remove("fa-moon");
|
||||||
|
themeIcon.classList.add("fa-sun");
|
||||||
|
} else {
|
||||||
|
themeIcon.classList.remove("fa-sun");
|
||||||
|
themeIcon.classList.add("fa-moon");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!savedTheme) {
|
||||||
|
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
if (prefersDark) {
|
||||||
|
document.documentElement.setAttribute("data-theme", "dark");
|
||||||
|
if (themeIcon) {
|
||||||
|
themeIcon.classList.remove("fa-moon");
|
||||||
|
themeIcon.classList.add("fa-sun");
|
||||||
|
}
|
||||||
|
localStorage.setItem("theme", "dark");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出到全局作用域
|
||||||
|
*/
|
||||||
|
window.ThemeUtils = {
|
||||||
|
initThemeAndView,
|
||||||
|
};
|
||||||
44
static/js/ui-utils.js
Normal file
44
static/js/ui-utils.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* UI 相关的全局工具函数
|
||||||
|
* 包括状态提示、对话框等
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新状态消息
|
||||||
|
* @param {string} message - 消息内容
|
||||||
|
* @param {string} state - 状态类型 ('success', 'error', 'warning', 或空)
|
||||||
|
* @returns {HTMLElement|null} 状态元素
|
||||||
|
*/
|
||||||
|
function updateStatus(message, state) {
|
||||||
|
const statusDiv = document.getElementById("uploadStatus");
|
||||||
|
if (!statusDiv) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusDiv.textContent = message;
|
||||||
|
statusDiv.className = "upload-status" + (state ? ` ${state}` : "");
|
||||||
|
statusDiv.style.display = "block";
|
||||||
|
return statusDiv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 延迟隐藏状态消息
|
||||||
|
* @param {HTMLElement} statusDiv - 状态元素
|
||||||
|
* @param {number} delay - 延迟时间(毫秒)
|
||||||
|
*/
|
||||||
|
function hideStatusLater(statusDiv, delay = 2000) {
|
||||||
|
if (!statusDiv) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
statusDiv.style.display = "none";
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出到全局作用域
|
||||||
|
*/
|
||||||
|
window.UIUtils = {
|
||||||
|
updateStatus,
|
||||||
|
hideStatusLater,
|
||||||
|
};
|
||||||
68
static/js/utilities.js
Normal file
68
static/js/utilities.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Service Worker 和其他工具函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注销 Service Worker
|
||||||
|
*/
|
||||||
|
function unregisterServiceWorker() {
|
||||||
|
if (!("serviceWorker" in navigator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.getRegistrations()
|
||||||
|
.then((registrations) => {
|
||||||
|
registrations.forEach((registration) => {
|
||||||
|
registration.unregister().then(() => {
|
||||||
|
console.log("Service Worker unregistered");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log("Error unregistering Service Worker:", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清理 Service Worker 相关的缓存
|
||||||
|
if ("caches" in window) {
|
||||||
|
caches.keys().then((cacheNames) => {
|
||||||
|
cacheNames.forEach((cacheName) => {
|
||||||
|
caches.delete(cacheName).then(() => {
|
||||||
|
console.log("Cache deleted:", cacheName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册模态框处理程序
|
||||||
|
*/
|
||||||
|
function registerModalHandlers() {
|
||||||
|
const modal = document.getElementById("previewModal");
|
||||||
|
if (!modal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.addEventListener("click", (event) => {
|
||||||
|
if (event.target === modal) {
|
||||||
|
closePreview();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape" && !isDialogOpen()) {
|
||||||
|
closePreview();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出到全局作用域
|
||||||
|
*/
|
||||||
|
window.UtilityFuncs = {
|
||||||
|
unregisterServiceWorker,
|
||||||
|
registerModalHandlers,
|
||||||
|
};
|
||||||
@@ -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
735
storages/onedrive.py
Normal file
@@ -0,0 +1,735 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
from .base import BaseStorage
|
||||||
|
|
||||||
|
|
||||||
|
class _InvalidGrant(Exception):
|
||||||
|
"""内部异常:用于标记 invalid_grant 以便进行下一次尝试"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OnedriveStorage(BaseStorage):
|
||||||
|
"""基于 OneDrive 的存储实现,支持自动令牌刷新"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""初始化 OneDrive 存储客户端"""
|
||||||
|
self.client_id = Config.ONEDRIVE_CLIENT_ID
|
||||||
|
self.client_secret = Config.ONEDRIVE_CLIENT_SECRET
|
||||||
|
self.refresh_token = Config.ONEDRIVE_REFRESH_TOKEN
|
||||||
|
self.folder_id = Config.ONEDRIVE_FOLDER_ID
|
||||||
|
self.graph_api_url = "https://graph.microsoft.com/v1.0"
|
||||||
|
|
||||||
|
if not (self.client_id and self.client_secret and self.refresh_token):
|
||||||
|
raise RuntimeError("ONEDRIVE_CLIENT_ID, ONEDRIVE_CLIENT_SECRET, and ONEDRIVE_REFRESH_TOKEN must be set")
|
||||||
|
|
||||||
|
# 如果没有指定 folder_id,使用 /me/drive/root
|
||||||
|
self.folder_item_id = self.folder_id or "root"
|
||||||
|
|
||||||
|
# 初始化 access_token 并刷新
|
||||||
|
self.access_token = None
|
||||||
|
self._refresh_token()
|
||||||
|
|
||||||
|
def _item_path_url(self, key: str, action: str | None = None) -> str:
|
||||||
|
"""根据 folder_item_id 构造基于路径的 DriveItem URL,可附带动作后缀。
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- action=None => ...:/path:
|
||||||
|
- action='content' => ...:/path:/content
|
||||||
|
- action='createLink' => ...:/path:/createLink
|
||||||
|
- action='thumbnails' => ...:/path:/thumbnails
|
||||||
|
"""
|
||||||
|
key = key.strip("/")
|
||||||
|
# 对路径进行 URL 编码,但保留路径分隔符 '/'
|
||||||
|
key_quoted = quote(key, safe="/")
|
||||||
|
base = (
|
||||||
|
f"{self.graph_api_url}/me/drive/root:/{key_quoted}:"
|
||||||
|
if self.folder_item_id == "root"
|
||||||
|
else f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{key_quoted}:"
|
||||||
|
)
|
||||||
|
if action:
|
||||||
|
return base + f"/{action}"
|
||||||
|
return base
|
||||||
|
|
||||||
|
def _refresh_token(self) -> None:
|
||||||
|
"""刷新 OneDrive 访问令牌"""
|
||||||
|
configured_scopes = getattr(Config, "ONEDRIVE_SCOPES", None)
|
||||||
|
attempts = [None] + ([configured_scopes] if configured_scopes else []) + ["Files.ReadWrite.All offline_access"]
|
||||||
|
token_json = self._perform_refresh_attempts(attempts)
|
||||||
|
self.access_token = token_json["access_token"]
|
||||||
|
new_refresh = token_json.get("refresh_token")
|
||||||
|
if new_refresh:
|
||||||
|
self.refresh_token = new_refresh
|
||||||
|
self._access_token_expires_in = token_json.get("expires_in")
|
||||||
|
|
||||||
|
def _perform_refresh_attempts(self, attempts: list) -> dict:
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
for idx, scope in enumerate(attempts, start=1):
|
||||||
|
try:
|
||||||
|
token_json = self._do_refresh_attempt(scope)
|
||||||
|
return token_json
|
||||||
|
except _InvalidGrant as e:
|
||||||
|
errors.append(f"Attempt {idx} invalid_grant: {str(e)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise RuntimeError("Failed to refresh OneDrive token: " + " | ".join(errors))
|
||||||
|
|
||||||
|
def _do_refresh_attempt(self, scope: str | None) -> dict:
|
||||||
|
url = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
|
||||||
|
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": self.refresh_token,
|
||||||
|
}
|
||||||
|
if scope:
|
||||||
|
payload["scope"] = scope
|
||||||
|
if self.client_secret:
|
||||||
|
payload["client_secret"] = self.client_secret
|
||||||
|
redirect_uri = getattr(Config, "ONEDRIVE_REDIRECT_URI", None)
|
||||||
|
if redirect_uri:
|
||||||
|
payload["redirect_uri"] = redirect_uri
|
||||||
|
|
||||||
|
resp = requests.post(url, data=payload, headers=headers, timeout=20)
|
||||||
|
try:
|
||||||
|
detail = resp.json()
|
||||||
|
except Exception:
|
||||||
|
detail = resp.text
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
# 如果是 invalid_grant,抛出内部异常以触发下一次尝试
|
||||||
|
if isinstance(detail, dict) and detail.get("error") == "invalid_grant":
|
||||||
|
raise _InvalidGrant(detail)
|
||||||
|
raise RuntimeError(f"Token endpoint error {resp.status_code}: {detail}")
|
||||||
|
|
||||||
|
if isinstance(detail, dict) and detail.get("error"):
|
||||||
|
if detail.get("error") == "invalid_grant":
|
||||||
|
raise _InvalidGrant(detail)
|
||||||
|
raise RuntimeError(f"Token response error: {detail}")
|
||||||
|
|
||||||
|
token_json = detail if isinstance(detail, dict) else {}
|
||||||
|
access_token = token_json.get("access_token")
|
||||||
|
if not access_token:
|
||||||
|
# 视为无效,交给下一次尝试
|
||||||
|
raise _InvalidGrant({"error": "missing_access_token", "detail": detail})
|
||||||
|
return token_json
|
||||||
|
|
||||||
|
def _try_refresh(self, func):
|
||||||
|
"""尝试执行函数,如果失败则刷新令牌后重试(处理令牌过期)"""
|
||||||
|
try:
|
||||||
|
return func()
|
||||||
|
except Exception as e:
|
||||||
|
if "401" in str(e) or "Unauthorized" in str(e):
|
||||||
|
# 令牌过期,刷新并重试
|
||||||
|
self._refresh_token()
|
||||||
|
return func()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _api_request(self, method: str, url: str, **kwargs):
|
||||||
|
"""
|
||||||
|
执行 API 请求,自动处理令牌过期
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: HTTP 方法 (GET, POST, PUT, DELETE, PATCH)
|
||||||
|
url: API URL
|
||||||
|
**kwargs: 其他请求参数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
响应对象
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _do_request():
|
||||||
|
response = requests.request(method, url, headers=self._headers(), **kwargs)
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise RuntimeError("Unauthorized - OneDrive access token expired")
|
||||||
|
return response
|
||||||
|
|
||||||
|
return self._try_refresh(_do_request)
|
||||||
|
|
||||||
|
def _headers(self) -> Dict[str, str]:
|
||||||
|
"""返回 API 请求的公共头部信息"""
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {self.access_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _verify_connection(self) -> None:
|
||||||
|
"""验证 OneDrive 连接是否有效"""
|
||||||
|
try:
|
||||||
|
url = f"{self.graph_api_url}/me/drive"
|
||||||
|
response = requests.get(url, headers=self._headers(), timeout=10)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise RuntimeError(f"OneDrive connection failed: {response.text}")
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Failed to connect to OneDrive: {str(e)}") from None
|
||||||
|
|
||||||
|
def _get_folder_id(self, path: str) -> Optional[str]:
|
||||||
|
"""获取指定路径的文件夹 ID"""
|
||||||
|
if not path or path == "/":
|
||||||
|
return self.folder_item_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 直接获取该路径对应项的元数据(而不是其子项),以拿到该文件夹自身的 id
|
||||||
|
path = path.strip("/")
|
||||||
|
if self.folder_item_id == "root":
|
||||||
|
url = f"{self.graph_api_url}/me/drive/root:/{path}:"
|
||||||
|
else:
|
||||||
|
url = f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{path}:"
|
||||||
|
response = requests.get(url, headers=self._headers(), timeout=10)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
item = response.json()
|
||||||
|
if item.get("folder"):
|
||||||
|
return item.get("id")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_objects(self, prefix: str = "") -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
列出存储桶中的对象
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prefix: 对象前缀(用于目录浏览)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含对象列表的字典
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
prefix = prefix.rstrip("/") if prefix else ""
|
||||||
|
|
||||||
|
# 直接基于路径列出,避免先查 ID 再列出造成的额外往返
|
||||||
|
select = "$select=name,size,lastModifiedDateTime,id,folder,file"
|
||||||
|
if prefix:
|
||||||
|
# 对前缀进行 URL 编码,保留路径分隔符
|
||||||
|
from urllib.parse import quote as _quote
|
||||||
|
|
||||||
|
quoted_prefix = _quote(prefix.strip("/"), safe="/")
|
||||||
|
if self.folder_item_id == "root":
|
||||||
|
url = f"{self.graph_api_url}/me/drive/root:/{quoted_prefix}:/children?{select}"
|
||||||
|
else:
|
||||||
|
url = (
|
||||||
|
f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{quoted_prefix}:/children?{select}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if self.folder_item_id == "root":
|
||||||
|
url = f"{self.graph_api_url}/me/drive/root/children?{select}"
|
||||||
|
else:
|
||||||
|
url = f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}/children?{select}"
|
||||||
|
response = self._api_request("GET", url)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
items = response.json().get("value", [])
|
||||||
|
files = []
|
||||||
|
folders = []
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
# 跳过特殊文件
|
||||||
|
if item.get("name", "").startswith("."):
|
||||||
|
continue
|
||||||
|
|
||||||
|
item_path = f"{prefix}/{item.get('name')}" if prefix else item.get("name")
|
||||||
|
|
||||||
|
if "folder" in item:
|
||||||
|
# 这是一个文件夹
|
||||||
|
folders.append({"Prefix": f"{item_path}/"})
|
||||||
|
else:
|
||||||
|
# 这是一个文件
|
||||||
|
size = item.get("size", 0)
|
||||||
|
modified_time = item.get("lastModifiedDateTime", datetime.now().isoformat())
|
||||||
|
|
||||||
|
# 解析 ISO 格式时间
|
||||||
|
try:
|
||||||
|
if isinstance(modified_time, str):
|
||||||
|
modified_time = datetime.fromisoformat(modified_time.replace("Z", "+00:00"))
|
||||||
|
except Exception:
|
||||||
|
modified_time = datetime.now()
|
||||||
|
|
||||||
|
files.append(
|
||||||
|
{
|
||||||
|
"Key": item_path,
|
||||||
|
"Size": size,
|
||||||
|
"LastModified": modified_time,
|
||||||
|
"ETag": item.get("id", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"Contents": sorted(files, key=lambda x: x["Key"]),
|
||||||
|
"CommonPrefixes": sorted(folders, key=lambda x: x["Prefix"]),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
raise RuntimeError(f"Failed to list OneDrive objects: {str(e)}") from None
|
||||||
|
|
||||||
|
def get_object_info(self, key: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取对象基本信息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 对象键名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
对象元数据
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = self._item_path_url(key)
|
||||||
|
response = self._api_request("GET", url)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
item = response.json()
|
||||||
|
return {
|
||||||
|
"Key": key,
|
||||||
|
"Size": item.get("size", 0),
|
||||||
|
"LastModified": item.get("lastModifiedDateTime", datetime.now().isoformat()),
|
||||||
|
"ETag": item.get("id", ""),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Failed to get OneDrive object info: {str(e)}") from None
|
||||||
|
|
||||||
|
def get_object(self, key: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取对象内容
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 对象键名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含对象内容的字典
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = self._item_path_url(key, "content")
|
||||||
|
response = self._api_request("GET", url)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"Body": response.content,
|
||||||
|
"ContentType": response.headers.get("Content-Type", "application/octet-stream"),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Failed to get OneDrive object: {str(e)}") from None
|
||||||
|
|
||||||
|
def generate_presigned_url(self, key: str, expires: int = None) -> str:
|
||||||
|
"""
|
||||||
|
为指定对象生成预签名 URL
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 对象键名
|
||||||
|
expires: 过期时间(秒)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
预签名 URL,失败返回 None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
expires = expires or Config.PRESIGNED_URL_EXPIRES
|
||||||
|
url = self._item_path_url(key, "createLink")
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"type": "view",
|
||||||
|
"scope": "anonymous",
|
||||||
|
"expirationDateTime": (datetime.utcnow() + timedelta(seconds=expires)).isoformat() + "Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(url, headers=self._headers(), json=body, timeout=15)
|
||||||
|
|
||||||
|
if response.status_code in (200, 201):
|
||||||
|
data = response.json() or {}
|
||||||
|
link = data.get("link") or {}
|
||||||
|
web = link.get("webUrl")
|
||||||
|
if web:
|
||||||
|
# 强制下载提示(视 OneDrive 行为而定)
|
||||||
|
sep = "&" if "?" in web else "?"
|
||||||
|
return f"{web}{sep}download=1"
|
||||||
|
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_direct_download_url(self, key: str) -> Optional[str]:
|
||||||
|
"""从 DriveItem 元数据中获取临时直链(@microsoft.graph.downloadUrl)。"""
|
||||||
|
try:
|
||||||
|
url = self._item_path_url(key)
|
||||||
|
resp = self._api_request("GET", url)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json() or {}
|
||||||
|
# Graph 返回的预签名直链属性
|
||||||
|
return data.get("@microsoft.graph.downloadUrl") or data.get("@microsoft.graph.downloadurl")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def generate_download_response(self, key: str) -> Dict[str, Any]:
|
||||||
|
"""优先返回 OneDrive 的临时直链(直接文件内容),避免跳转到预览页。"""
|
||||||
|
# 1) 直链(最佳体验:直接获取文件内容,不经过 OneDrive 预览页)
|
||||||
|
direct = self._get_direct_download_url(key)
|
||||||
|
if direct:
|
||||||
|
return {"type": "redirect", "url": direct}
|
||||||
|
|
||||||
|
# 2) 匿名分享链接(添加 download=1 提示下载)
|
||||||
|
presigned = self.generate_presigned_url(key)
|
||||||
|
if presigned:
|
||||||
|
return {"type": "redirect", "url": presigned}
|
||||||
|
|
||||||
|
# 3) 回退到 webUrl(可能需要登录)
|
||||||
|
public_url = self.get_public_url(key)
|
||||||
|
if public_url:
|
||||||
|
return {"type": "redirect", "url": public_url}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_public_url(self, key: str) -> str:
|
||||||
|
"""
|
||||||
|
生成对象的公共访问 URL
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 对象键名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
公共 URL,未配置返回 None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 直接获取 DriveItem 元数据并读取 webUrl 字段
|
||||||
|
url = self._item_path_url(key)
|
||||||
|
response = self._api_request("GET", url)
|
||||||
|
if response.status_code == 200:
|
||||||
|
return (response.json() or {}).get("webUrl")
|
||||||
|
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def upload_file(self, key: str, file_data: bytes, content_type: str = None) -> bool:
|
||||||
|
"""
|
||||||
|
上传文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 对象键名
|
||||||
|
file_data: 文件内容
|
||||||
|
content_type: 文件类型(MIME type,可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
上传成功返回 True,失败返回 False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = self._item_path_url(key, "content")
|
||||||
|
|
||||||
|
# 自定义头部(需要指定 Content-Type)
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.access_token}",
|
||||||
|
"Content-Type": content_type or "application/octet-stream",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.put(url, headers=headers, data=file_data, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
return response.status_code == 200 or response.status_code == 201
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to upload file to OneDrive: {str(e)}" if isinstance(e, Exception) else str(e)
|
||||||
|
) from None
|
||||||
|
|
||||||
|
def delete_file(self, key: str) -> bool:
|
||||||
|
"""
|
||||||
|
删除文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 对象键名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
删除成功返回 True,失败返回 False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = self._item_path_url(key)
|
||||||
|
response = self._api_request("DELETE", url)
|
||||||
|
return response.status_code == 204
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Failed to delete file from OneDrive: {str(e)}") from None
|
||||||
|
|
||||||
|
def copy_file(self, source_key: str, dest_key: str) -> bool:
|
||||||
|
"""
|
||||||
|
复制文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_key: 源文件键名
|
||||||
|
dest_key: 目标文件键名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
复制成功返回 True,失败返回 False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
source = source_key.strip("/")
|
||||||
|
destination = dest_key.strip("/")
|
||||||
|
|
||||||
|
# 获取源文件 ID
|
||||||
|
source_url = f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{source}:"
|
||||||
|
source_response = self._api_request("GET", source_url)
|
||||||
|
source_response.raise_for_status()
|
||||||
|
source_item = source_response.json()
|
||||||
|
|
||||||
|
# 复制文件
|
||||||
|
copy_url = f"{self.graph_api_url}/me/drive/items/{source_item.get('id')}/copy"
|
||||||
|
copy_body = {
|
||||||
|
"parentReference": {"id": self.folder_item_id},
|
||||||
|
"name": destination.split("/")[-1],
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_response = self._api_request("POST", copy_url, json=copy_body)
|
||||||
|
copy_response.raise_for_status()
|
||||||
|
|
||||||
|
return copy_response.status_code == 202 or copy_response.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Failed to copy file in OneDrive: {str(e)}") from None
|
||||||
|
|
||||||
|
def generate_thumbnail(self, file_path: str) -> bytes:
|
||||||
|
"""
|
||||||
|
生成图片缩略图
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 文件路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
缩略图字节数据
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = self._item_path_url(file_path, "thumbnails")
|
||||||
|
response = requests.get(url, headers=self._headers(), timeout=15)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
thumbnails = response.json().get("value", [])
|
||||||
|
if thumbnails:
|
||||||
|
thumb_set = thumbnails[0]
|
||||||
|
# 获取中等大小的缩略图
|
||||||
|
thumb_url = thumb_set.get("c", {}).get("url") or thumb_set.get("m", {}).get("url")
|
||||||
|
|
||||||
|
if thumb_url:
|
||||||
|
thumb_response = requests.get(thumb_url)
|
||||||
|
if thumb_response.status_code == 200:
|
||||||
|
return thumb_response.content
|
||||||
|
|
||||||
|
# 如果没有缩略图,尝试生成一个
|
||||||
|
return self._generate_fallback_thumbnail(file_path)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _generate_fallback_thumbnail(self, file_path: str) -> bytes:
|
||||||
|
"""
|
||||||
|
生成备用缩略图(当 OneDrive 没有缩略图时)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 文件路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
缩略图字节数据
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
file_obj = self.get_object(file_path)
|
||||||
|
img = Image.open(BytesIO(file_obj["Body"]))
|
||||||
|
|
||||||
|
# 调整大小
|
||||||
|
img.thumbnail(Config.THUMB_SIZE, Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
# 保存为 PNG
|
||||||
|
thumb_buffer = BytesIO()
|
||||||
|
img.save(thumb_buffer, format="PNG")
|
||||||
|
return thumb_buffer.getvalue()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def rename_file(self, old_key: str, new_key: str) -> bool:
|
||||||
|
"""
|
||||||
|
重命名文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_key: 旧的文件键名
|
||||||
|
new_key: 新的文件键名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
重命名成功返回 True,失败返回 False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
old_key = old_key.strip("/")
|
||||||
|
new_key = new_key.strip("/")
|
||||||
|
|
||||||
|
# 获取旧文件的 ID
|
||||||
|
url = f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{old_key}:"
|
||||||
|
response = self._api_request("GET", url)
|
||||||
|
response.raise_for_status()
|
||||||
|
item_id = response.json().get("id")
|
||||||
|
|
||||||
|
# 更新文件名
|
||||||
|
update_url = f"{self.graph_api_url}/me/drive/items/{item_id}"
|
||||||
|
update_body = {"name": new_key.split("/")[-1]}
|
||||||
|
|
||||||
|
response = self._api_request("PATCH", update_url, json=update_body)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to rename file in OneDrive: {str(e)}" if isinstance(e, Exception) else str(e)
|
||||||
|
) from None
|
||||||
|
|
||||||
|
def delete_folder(self, prefix: str) -> bool:
|
||||||
|
"""
|
||||||
|
删除文件夹及其中的所有文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prefix: 文件夹前缀
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
删除成功返回 True,失败返回 False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
prefix = prefix.rstrip("/")
|
||||||
|
|
||||||
|
# 获取文件夹中的所有项目
|
||||||
|
items = self.list_objects(prefix)
|
||||||
|
|
||||||
|
# 删除所有文件
|
||||||
|
for file in items.get("Contents", []):
|
||||||
|
self.delete_file(file["Key"])
|
||||||
|
|
||||||
|
# 删除所有子文件夹
|
||||||
|
for folder in items.get("CommonPrefixes", []):
|
||||||
|
self.delete_folder(folder["Prefix"])
|
||||||
|
|
||||||
|
# 删除文件夹本身
|
||||||
|
url = f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{prefix}:"
|
||||||
|
response = self._api_request("DELETE", url)
|
||||||
|
|
||||||
|
return response.status_code == 204
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to delete folder from OneDrive: {str(e)}" if isinstance(e, Exception) else str(e)
|
||||||
|
) from None
|
||||||
|
|
||||||
|
def rename_folder(self, old_prefix: str, new_prefix: str) -> bool:
|
||||||
|
"""
|
||||||
|
重命名文件夹
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_prefix: 旧的文件夹前缀
|
||||||
|
new_prefix: 新的文件夹前缀
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
重命名成功返回 True,失败返回 False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
old_prefix = old_prefix.rstrip("/")
|
||||||
|
new_prefix = new_prefix.rstrip("/")
|
||||||
|
|
||||||
|
# 获取旧文件夹的 ID
|
||||||
|
url = f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{old_prefix}:"
|
||||||
|
response = self._api_request("GET", url)
|
||||||
|
response.raise_for_status()
|
||||||
|
item_id = response.json().get("id")
|
||||||
|
|
||||||
|
# 更新文件夹名
|
||||||
|
update_url = f"{self.graph_api_url}/me/drive/items/{item_id}"
|
||||||
|
update_body = {"name": new_prefix.split("/")[-1]}
|
||||||
|
|
||||||
|
response = self._api_request("PATCH", update_url, json=update_body)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to rename folder in OneDrive: {str(e)}" if isinstance(e, Exception) else str(e)
|
||||||
|
) from None
|
||||||
|
|
||||||
|
def copy_folder(self, source_prefix: str, dest_prefix: str) -> bool:
|
||||||
|
"""
|
||||||
|
复制文件夹及其中的所有文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_prefix: 源文件夹前缀
|
||||||
|
dest_prefix: 目标文件夹前缀
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
复制成功返回 True,失败返回 False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
source_prefix = source_prefix.rstrip("/")
|
||||||
|
dest_prefix = dest_prefix.rstrip("/")
|
||||||
|
|
||||||
|
# 创建目标文件夹
|
||||||
|
self.create_folder(dest_prefix + "/")
|
||||||
|
|
||||||
|
# 获取源文件夹中的所有项目
|
||||||
|
items = self.list_objects(source_prefix)
|
||||||
|
|
||||||
|
# 复制所有文件
|
||||||
|
for file in items.get("Contents", []):
|
||||||
|
source_key = file["Key"]
|
||||||
|
# 保持相对路径
|
||||||
|
relative_path = source_key[len(source_prefix) + 1 :]
|
||||||
|
dest_key = f"{dest_prefix}/{relative_path}"
|
||||||
|
self.copy_file(source_key, dest_key)
|
||||||
|
|
||||||
|
# 递归复制子文件夹
|
||||||
|
for folder in items.get("CommonPrefixes", []):
|
||||||
|
source_folder = folder["Prefix"].rstrip("/")
|
||||||
|
relative_folder = source_folder[len(source_prefix) + 1 :]
|
||||||
|
dest_folder = f"{dest_prefix}/{relative_folder}"
|
||||||
|
self.copy_folder(source_folder, dest_folder)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to copy folder in OneDrive: {str(e)}" if isinstance(e, Exception) else str(e)
|
||||||
|
) from None
|
||||||
|
|
||||||
|
def create_folder(self, key: str) -> bool:
|
||||||
|
"""
|
||||||
|
创建文件夹
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 文件夹路径(以 / 结尾)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
创建成功返回 True,失败返回 False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
key = key.rstrip("/")
|
||||||
|
|
||||||
|
# 获取父文件夹 ID
|
||||||
|
parts = key.split("/")
|
||||||
|
parent_id = self.folder_item_id
|
||||||
|
|
||||||
|
# 创建每一层文件夹
|
||||||
|
for _, folder_name in enumerate(parts):
|
||||||
|
# 检查文件夹是否已存在
|
||||||
|
existing_url = f"{self.graph_api_url}/me/drive/items/{parent_id}:/{folder_name}:"
|
||||||
|
existing_response = self._api_request("GET", existing_url)
|
||||||
|
|
||||||
|
if existing_response.status_code == 200:
|
||||||
|
parent_id = existing_response.json().get("id")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 创建新文件夹
|
||||||
|
create_url = f"{self.graph_api_url}/me/drive/items/{parent_id}/children"
|
||||||
|
create_body = {"name": folder_name, "folder": {}}
|
||||||
|
|
||||||
|
create_response = self._api_request("POST", create_url, json=create_body)
|
||||||
|
create_response.raise_for_status()
|
||||||
|
|
||||||
|
parent_id = create_response.json().get("id")
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to create folder in OneDrive: {str(e)}" if isinstance(e, Exception) else str(e)
|
||||||
|
) from None
|
||||||
@@ -5,8 +5,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{% block title %}Cloud Index{% endblock %}</title>
|
<title>{% block title %}Cloud Index{% endblock %}</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}" />
|
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}" />
|
||||||
|
<!-- 资源性能优化:预连接第三方 CDN,减少 TLS 握手耗时 -->
|
||||||
|
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin />
|
||||||
|
<link rel="dns-prefetch" href="//cdnjs.cloudflare.com" />
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" />
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" />
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}" />
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}" />
|
||||||
{% block head_extra %}{% endblock %}
|
{% block head_extra %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body {% block body_attrs %}{% endblock %}>
|
<body {% block body_attrs %}{% endblock %}>
|
||||||
@@ -27,7 +30,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script defer src="{{ url_for('static', filename='js/main.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/ui-utils.js') }}"></script>
|
||||||
|
<script defer src="{{ url_for('static', filename='js/dialog.js') }}"></script>
|
||||||
|
<script defer src="{{ url_for('static', filename='js/theme.js') }}"></script>
|
||||||
|
<script defer src="{{ url_for('static', filename='js/utilities.js') }}"></script>
|
||||||
|
<script defer src="{{ url_for('static', filename='js/file-operations.js') }}"></script>
|
||||||
|
<script defer src="{{ url_for('static', filename='js/selection.js') }}"></script>
|
||||||
|
<script defer src="{{ url_for('static', filename='js/download.js') }}"></script>
|
||||||
|
<script defer src="{{ url_for('static', filename='js/preview.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
window.DialogUtils.initDialog();
|
||||||
|
window.ThemeUtils.initThemeAndView();
|
||||||
|
window.UtilityFuncs.unregisterServiceWorker();
|
||||||
|
window.UtilityFuncs.registerModalHandlers();
|
||||||
|
window.SelectionUtils.attachEntryCheckboxListeners();
|
||||||
|
window.DownloadUtils.attachDownloadButtonListeners();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -195,7 +195,10 @@ current_prefix }}"{% endblock %} {% block content %}
|
|||||||
<div class="grid-thumb" onclick="openPreview('{{ entry.file_url }}', '{{ entry.name }}')">
|
<div class="grid-thumb" onclick="openPreview('{{ entry.file_url }}', '{{ entry.name }}')">
|
||||||
<img
|
<img
|
||||||
style="width: 100%; height: 100%; object-fit: cover; border-radius: 6px"
|
style="width: 100%; height: 100%; object-fit: cover; border-radius: 6px"
|
||||||
src="{{ entry.file_url }}"
|
src="/thumb/{{ entry.key }}"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
fetchpriority="low"
|
||||||
alt="{{ entry.name }}"
|
alt="{{ entry.name }}"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,4 +284,74 @@ current_prefix }}"{% endblock %} {% block content %}
|
|||||||
<div id="previewInfo" class="preview-info"></div>
|
<div id="previewInfo" class="preview-info"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<script>
|
||||||
|
// 客户端分页:初始只渲染部分条目,点击“加载更多”逐步显示,减少首屏 DOM 与渲染压力
|
||||||
|
(function () {
|
||||||
|
const PAGE_CHUNK = 200; // 每次显示的条目数(表格与网格分别按该步长展开)
|
||||||
|
function hideBeyond(nodeList) {
|
||||||
|
let hidden = 0;
|
||||||
|
for (let i = PAGE_CHUNK; i < nodeList.length; i++) {
|
||||||
|
const el = nodeList[i];
|
||||||
|
if (!el.hasAttribute("hidden")) {
|
||||||
|
el.setAttribute("hidden", "");
|
||||||
|
hidden++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hidden;
|
||||||
|
}
|
||||||
|
function revealNext(nodeList, count) {
|
||||||
|
let revealed = 0;
|
||||||
|
for (let i = 0; i < nodeList.length && revealed < count; i++) {
|
||||||
|
const el = nodeList[i];
|
||||||
|
if (el.hasAttribute("hidden")) {
|
||||||
|
el.removeAttribute("hidden");
|
||||||
|
revealed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return revealed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupLoadMore() {
|
||||||
|
const tableRows = document.querySelectorAll("table.files-table tbody tr");
|
||||||
|
const gridCards = document.querySelectorAll("#gridContainer .grid-card");
|
||||||
|
let hiddenCount = 0;
|
||||||
|
hiddenCount += hideBeyond(tableRows);
|
||||||
|
hiddenCount += hideBeyond(gridCards);
|
||||||
|
|
||||||
|
if (hiddenCount === 0) return;
|
||||||
|
|
||||||
|
// 创建“加载更多”按钮
|
||||||
|
const moreWrap = document.createElement("div");
|
||||||
|
moreWrap.style.display = "flex";
|
||||||
|
moreWrap.style.justifyContent = "center";
|
||||||
|
moreWrap.style.margin = "16px 0 24px";
|
||||||
|
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = "view-toggle";
|
||||||
|
btn.type = "button";
|
||||||
|
const info = document.createElement("span");
|
||||||
|
info.style.marginLeft = "8px";
|
||||||
|
info.textContent = hiddenCount;
|
||||||
|
btn.textContent = "加载更多";
|
||||||
|
btn.appendChild(info);
|
||||||
|
moreWrap.appendChild(btn);
|
||||||
|
|
||||||
|
const container = document.querySelector(".container");
|
||||||
|
container && container.appendChild(moreWrap);
|
||||||
|
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const shown = revealNext(tableRows, PAGE_CHUNK) + revealNext(gridCards, PAGE_CHUNK);
|
||||||
|
hiddenCount = Math.max(0, hiddenCount - shown);
|
||||||
|
if (hiddenCount <= 0) {
|
||||||
|
moreWrap.remove();
|
||||||
|
} else {
|
||||||
|
info.textContent = hiddenCount;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", setupLoadMore);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user