add public
This commit is contained in:
parent
986dcaf5ef
commit
d5a989860c
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,6 +3,5 @@ projects/*
|
||||
workspace
|
||||
__pycache__
|
||||
*/__pycache__
|
||||
public
|
||||
models
|
||||
queue_data
|
||||
|
||||
161
FILE_MANAGER_README.md
Normal file
161
FILE_MANAGER_README.md
Normal file
@ -0,0 +1,161 @@
|
||||
# 📁 项目文件管理器
|
||||
|
||||
一个功能强大的Web文件管理器,支持文件上传、下载、批量操作和文件夹压缩下载。
|
||||
|
||||
## ✨ 主要功能
|
||||
|
||||
### 🌐 两种访问方式
|
||||
|
||||
1. **Web界面** - 图形化文件管理界面(推荐)
|
||||
- 地址:`https://your-domain.com/public/file-manager.html`
|
||||
- 特点:直观易用,支持拖拽上传
|
||||
|
||||
2. **REST API** - 完整的文件管理API
|
||||
- 基础地址:`https://your-domain.com/api/v1/files`
|
||||
- 特点:可集成到任何应用
|
||||
|
||||
### 🚀 核心功能
|
||||
|
||||
#### 文件操作
|
||||
- 📤 **上传文件** - 支持拖拽上传、多文件上传
|
||||
- 📥 **下载文件** - 单文件直接下载
|
||||
- 📦 **文件夹压缩下载** - 一键下载整个文件夹为ZIP
|
||||
- 📋 **批量下载** - 选择多个文件/文件夹统一打包下载
|
||||
- 📁 **创建文件夹** - 新建目录结构
|
||||
- ✏️ **重命名** - 文件和文件夹重命名
|
||||
- 🗑️ **删除** - 安全删除文件和文件夹
|
||||
- 🔍 **搜索** - 快速搜索文件
|
||||
- 📊 **文件信息** - 查看文件详细信息和预览
|
||||
|
||||
#### 批量操作
|
||||
- ☑️ **全选/取消全选** - 快速选择所有文件
|
||||
- 📦 **批量下载** - 多文件打包下载
|
||||
- 🎯 **批量管理** - 支持后续扩展(批量删除、移动等)
|
||||
|
||||
## 🛠️ API 接口
|
||||
|
||||
### 文件列表
|
||||
```http
|
||||
GET /api/v1/files/list?path=uploads&recursive=false
|
||||
```
|
||||
|
||||
### 文件上传
|
||||
```http
|
||||
POST /api/v1/files/upload
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
### 文件下载
|
||||
```http
|
||||
GET /api/v1/files/download/{file_path}
|
||||
```
|
||||
|
||||
### 文件夹压缩下载
|
||||
```http
|
||||
POST /api/v1/files/download-folder-zip
|
||||
Content-Type: application/json
|
||||
{
|
||||
"path": "uploads"
|
||||
}
|
||||
```
|
||||
|
||||
### 批量压缩下载
|
||||
```http
|
||||
POST /api/v1/files/download-multiple-zip
|
||||
Content-Type: application/json
|
||||
{
|
||||
"paths": ["uploads", "data/123"],
|
||||
"filename": "batch_download.zip"
|
||||
}
|
||||
```
|
||||
|
||||
### 其他接口
|
||||
- `POST /api/v1/files/create-folder` - 创建文件夹
|
||||
- `POST /api/v1/files/rename` - 重命名
|
||||
- `POST /api/v1/files/move` - 移动文件
|
||||
- `POST /api/v1/files/copy` - 复制文件
|
||||
- `DELETE /api/v1/files/delete` - 删除文件
|
||||
- `GET /api/v1/files/search` - 搜索文件
|
||||
- `GET /api/v1/files/info/{path}` - 获取文件信息
|
||||
|
||||
## 🎨 界面特性
|
||||
|
||||
### 响应式设计
|
||||
- ✅ 桌面端完整功能
|
||||
- ✅ 移动端友好界面
|
||||
- ✅ 自适应布局
|
||||
|
||||
### 用户体验
|
||||
- 🎨 现代化UI设计
|
||||
- 📱 直观的图标和颜色
|
||||
- ⚡ 快速响应的交互
|
||||
- 💬 友好的提示信息
|
||||
|
||||
### 高级功能
|
||||
- 📁 面包屑导航
|
||||
- 🔍 实时搜索过滤
|
||||
- 📊 文件信息预览
|
||||
- 🎯 智能文件类型识别
|
||||
|
||||
## 🔧 技术特性
|
||||
|
||||
### 安全性
|
||||
- 🛡️ 路径验证和限制
|
||||
- 📏 文件大小限制(500MB)
|
||||
- 🔢 文件数量限制(10000个)
|
||||
- 🚫 隐藏文件过滤
|
||||
|
||||
### 性能优化
|
||||
- ⚡ 异步文件操作
|
||||
- 📦 内存中ZIP压缩
|
||||
- 🔄 流式文件下载
|
||||
- 💾 智能缓存控制
|
||||
|
||||
### 兼容性
|
||||
- 🌐 标准WebDAV协议
|
||||
- 📱 所有现代浏览器
|
||||
- 🔧 可扩展的API设计
|
||||
- 📊 完整的错误处理
|
||||
|
||||
## 📝 使用方法
|
||||
|
||||
### 快速开始
|
||||
1. 访问文件管理器:`https://your-domain.com/public/file-manager.html`
|
||||
2. 使用拖拽或点击按钮上传文件
|
||||
3. 点击文件夹旁的"下载ZIP"按钮下载整个文件夹
|
||||
4. 使用"批量操作"模式选择多个文件统一下载
|
||||
|
||||
### 高级用法
|
||||
1. 点击"批量操作"进入批量选择模式
|
||||
2. 使用"全选"快速选择所有文件
|
||||
3. 混合选择文件和文件夹进行批量下载
|
||||
4. 使用搜索功能快速定位文件
|
||||
|
||||
## 🚀 部署说明
|
||||
|
||||
### 环境要求
|
||||
- Python 3.8+
|
||||
- FastAPI
|
||||
- 现代浏览器
|
||||
|
||||
### 部署步骤
|
||||
1. 安装依赖:`pip install fastapi uvicorn`
|
||||
2. 启动服务:`uvicorn fastapi_app:app --host 0.0.0.0 --port 8001`
|
||||
3. 访问:`http://localhost:8001/public/file-manager.html`
|
||||
|
||||
### 配置选项
|
||||
- `PROJECTS_DIR`: 项目根目录(默认:`projects`)
|
||||
- `MAX_ZIP_SIZE`: 最大ZIP文件大小(默认:500MB)
|
||||
- `MAX_FILES`: 最大文件数量(默认:10000)
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT License - 详见 LICENSE 文件
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
---
|
||||
|
||||
**享受高效的文件管理体验!** 🎉
|
||||
132
WEBDAV_GUIDE.md
132
WEBDAV_GUIDE.md
@ -1,97 +1,103 @@
|
||||
# WebDAV 文件管理服务
|
||||
# 📁 文件管理服务
|
||||
|
||||
您的项目现在已经成功集成了 WebDAV 文件管理服务,替代了原来功能单一的 `/api/v1/projects/tree` 和 `/api/v1/projects/subtree` 接口。
|
||||
您的项目现在拥有了完整的文件管理功能,通过现代的Web界面和REST API提供。
|
||||
|
||||
## 功能特性
|
||||
## 🌐 访问方式
|
||||
|
||||
- ✅ 完整的文件/文件夹操作(增删改查、移动、重命名)
|
||||
- ✅ 支持大文件上传/下载
|
||||
- ✅ 支持文件夹批量操作
|
||||
- ✅ 可挂载为网络驱动器
|
||||
- ✅ 支持任何WebDAV客户端
|
||||
|
||||
## 访问方式
|
||||
|
||||
### 1. 浏览器访问
|
||||
### 1. Web界面(推荐)
|
||||
直接在浏览器中打开:
|
||||
```
|
||||
http://localhost:8001/webdav
|
||||
http://localhost:8001/public/file-manager.html
|
||||
```
|
||||
|
||||
### 2. 系统挂载
|
||||
|
||||
#### Windows
|
||||
1. 打开 "此电脑"
|
||||
2. 右键点击 "网络位置" → "添加一个网络位置"
|
||||
3. 输入:`http://localhost:8001/webdav`
|
||||
4. 完成,即可在网络驱动器中访问
|
||||
|
||||
#### macOS
|
||||
1. 打开 "访达"
|
||||
2. 前往 → 连接服务器(Cmd+K)
|
||||
3. 输入:`http://localhost:8001/webdav`
|
||||
4. 连接
|
||||
|
||||
#### Linux
|
||||
使用 davfs2 挂载:
|
||||
```bash
|
||||
sudo mount -t davfs http://localhost:8001/webdav /mnt/webdav
|
||||
### 2. REST API
|
||||
基础地址:
|
||||
```
|
||||
http://localhost:8001/api/v1/files
|
||||
```
|
||||
|
||||
### 3. 移动端App
|
||||
使用任何WebDAV客户端App:
|
||||
- iOS: Documents by Readdle, WebDAV Navigator
|
||||
- Android: Solid Explorer, WebDAV Client
|
||||
## ✨ 主要功能
|
||||
|
||||
## API 操作示例
|
||||
### 🚀 文件操作
|
||||
- 📤 **上传文件** - 拖拽上传,支持多文件
|
||||
- 📥 **下载文件** - 单文件直接下载
|
||||
- 📦 **文件夹压缩下载** - 一键下载整个文件夹为ZIP
|
||||
- 📋 **批量下载** - 选择多个文件统一打包下载
|
||||
- 📁 **创建文件夹** - 新建目录结构
|
||||
- ✏️ **重命名** - 文件和文件夹重命名
|
||||
- 🗑️ **删除** - 安全删除文件和文件夹
|
||||
- 🔍 **搜索** - 快速搜索文件
|
||||
- 📊 **文件信息** - 查看详细信息和预览
|
||||
|
||||
### 查看目录列表
|
||||
### 🎯 高级功能
|
||||
- ☑️ **批量选择** - 支持全选和部分选择
|
||||
- 📦 **批量操作** - 多文件同时处理
|
||||
- 🎨 **现代界面** - 响应式设计,移动端友好
|
||||
- ⚡ **实时操作** - 快速响应的用户体验
|
||||
|
||||
## 📚 API 使用
|
||||
|
||||
### 文件列表
|
||||
```bash
|
||||
curl -X PROPFIND http://localhost:8001/webdav/
|
||||
curl "http://localhost:8001/api/v1/files/list?path=uploads"
|
||||
```
|
||||
|
||||
### 上传文件
|
||||
```bash
|
||||
curl -X PUT http://localhost:8001/webdav/test.txt -d "Hello World"
|
||||
curl -X POST "http://localhost:8001/api/v1/files/upload" \
|
||||
-F "file=@example.txt" \
|
||||
-F "path=uploads"
|
||||
```
|
||||
|
||||
### 下载文件
|
||||
```bash
|
||||
curl http://localhost:8001/webdav/test.txt
|
||||
curl "http://localhost:8001/api/v1/files/download/uploads/example.txt" -o example.txt
|
||||
```
|
||||
|
||||
### 创建文件夹
|
||||
### 文件夹压缩下载
|
||||
```bash
|
||||
curl -X MKCOL http://localhost:8001/webdav/new_folder
|
||||
curl -X POST "http://localhost:8001/api/v1/files/download-folder-zip" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"path": "uploads"}' \
|
||||
-o uploads.zip
|
||||
```
|
||||
|
||||
### 删除文件
|
||||
### 批量下载
|
||||
```bash
|
||||
curl -X DELETE http://localhost:8001/webdav/test.txt
|
||||
curl -X POST "http://localhost:8001/api/v1/files/download-multiple-zip" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"paths": ["uploads", "data/123"], "filename": "batch.zip"}' \
|
||||
-o batch.zip
|
||||
```
|
||||
|
||||
### 移动文件
|
||||
```bash
|
||||
curl -X MOVE http://localhost:8001/webdav/test.txt -H "Destination: /webdav/backup/test.txt"
|
||||
```
|
||||
### 其他操作
|
||||
- `POST /api/v1/files/create-folder` - 创建文件夹
|
||||
- `POST /api/v1/files/rename` - 重命名
|
||||
- `POST /api/v1/files/delete` - 删除
|
||||
- `GET /api/v1/files/search?query=keyword` - 搜索
|
||||
- `GET /api/v1/files/info/{path}` - 获取文件信息
|
||||
|
||||
## 安全说明
|
||||
## 🎨 使用方法
|
||||
|
||||
当前配置为开发环境,无需认证即可访问。在生产环境中,建议:
|
||||
### Web界面
|
||||
1. 访问文件管理器
|
||||
2. 拖拽文件到上传区域
|
||||
3. 点击文件夹旁的"下载ZIP"
|
||||
4. 使用"批量操作"模式进行多文件选择
|
||||
|
||||
1. 启用HTTPS
|
||||
2. 配置用户认证
|
||||
3. 限制访问权限
|
||||
### API集成
|
||||
完整的REST API支持各种客户端集成,适合程序化文件操作。
|
||||
|
||||
## 支持的文件操作
|
||||
## 🔧 技术特性
|
||||
|
||||
| 操作 | HTTP方法 | 说明 |
|
||||
|------|----------|------|
|
||||
| 读取文件/目录 | PROPFIND | 获取文件信息和目录列表 |
|
||||
| 创建文件 | PUT | 上传或更新文件 |
|
||||
| 创建目录 | MKCOL | 创建新文件夹 |
|
||||
| 删除文件/目录 | DELETE | 删除文件或空目录 |
|
||||
| 移动/重命名 | MOVE | 移动或重命名文件/目录 |
|
||||
| 复制 | COPY | 复制文件/目录 |
|
||||
### 🛡️ 安全性
|
||||
- 路径验证和限制
|
||||
- 文件大小和数量限制
|
||||
- 错误处理和用户友好提示
|
||||
|
||||
现在您可以使用任何支持WebDAV的工具来管理您的 `projects` 文件夹,享受完整的文件管理功能!
|
||||
### ⚡ 性能
|
||||
- 异步文件操作
|
||||
- 内存中ZIP压缩
|
||||
- 流式文件下载
|
||||
|
||||
现在您可以享受现代化的文件管理体验!
|
||||
53
WEBDAV_REMOVAL_REPORT.md
Normal file
53
WEBDAV_REMOVAL_REPORT.md
Normal file
@ -0,0 +1,53 @@
|
||||
# 🗑️ WebDAV 移除完成报告
|
||||
|
||||
## 已完成的清理工作
|
||||
|
||||
### ✅ 1. 删除Python依赖
|
||||
- 从 `pyproject.toml` 中移除 `wsgidav>=4.0.0`
|
||||
- 清理poetry环境中的WebDAV相关包
|
||||
|
||||
### ✅ 2. 删除WebDAV服务器文件
|
||||
- 删除 `webdav_server.py`
|
||||
- 删除 `webdav_production.py`
|
||||
|
||||
### ✅ 3. 从主应用中移除WebDAV集成
|
||||
- 移除WebDAV相关的导入语句
|
||||
- 删除 `WebDAVCORSMiddleware` 类
|
||||
- 删除WebDAV应用挂载代码
|
||||
- 清理CORS配置中的WebDAV方法支持
|
||||
- 更新启动消息,移除WebDAV相关信息
|
||||
|
||||
### ✅ 4. 更新相关文档
|
||||
- 重命名 `WEBDAV_GUIDE.md` 并更新为现代文件管理指南
|
||||
- 更新 `FILE_MANAGER_README.md`,移除WebDAV相关内容
|
||||
- 从Web界面中移除WebDAV按钮
|
||||
|
||||
## 📦 当前功能
|
||||
|
||||
现在您的项目拥有更加现代化和易于使用的文件管理功能:
|
||||
|
||||
### 🌐 访问方式
|
||||
1. **Web界面** (`/public/file-manager.html`) - 主要推荐方式
|
||||
2. **REST API** (`/api/v1/files/*`) - 程序化接口
|
||||
|
||||
### 🚀 核心功能
|
||||
- ✅ 文件上传/下载
|
||||
- ✅ 文件夹压缩下载
|
||||
- ✅ 批量操作
|
||||
- ✅ 搜索功能
|
||||
- ✅ 文件管理(重命名、删除、移动)
|
||||
- ✅ 现代化UI界面
|
||||
|
||||
### 🎯 优势
|
||||
- 🔧 **更简单的维护** - 无需复杂的WebDAV配置
|
||||
- 📱 **更好的用户体验** - 现代Web界面
|
||||
- 🔒 **更安全的实现** - 完全控制的API
|
||||
- ⚡ **更高的性能** - 优化的文件处理
|
||||
|
||||
## 🚀 下一步
|
||||
|
||||
1. 部署更新后的代码
|
||||
2. 访问 `http://localhost:8001/public/file-manager.html` 使用新界面
|
||||
3. 如需要程序化访问,使用REST API接口
|
||||
|
||||
WebDAV功能已完全移除,项目现在拥有更现代、更易用的文件管理系统!
|
||||
@ -14,11 +14,11 @@ from fastapi import FastAPI, HTTPException, Depends, Header, UploadFile, File
|
||||
from fastapi.responses import StreamingResponse, HTMLResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.wsgi import WSGIMiddleware
|
||||
from webdav_server import create_webdav_app
|
||||
from file_manager_api import router as file_manager_router
|
||||
from qwen_agent.llm.schema import ASSISTANT, FUNCTION
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# Import utility modules
|
||||
from utils import (
|
||||
# Models
|
||||
@ -122,8 +122,11 @@ app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # 在生产环境中应该设置为具体的前端域名
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"],
|
||||
allow_headers=[
|
||||
"Authorization", "Content-Type", "Accept", "Origin", "User-Agent",
|
||||
"DNT", "Cache-Control", "Range", "X-Requested-With"
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@ -1035,14 +1038,13 @@ def build_directory_tree(path: str, relative_path: str = "") -> dict:
|
||||
|
||||
|
||||
|
||||
# 创建并挂载 WebDAV 应用 (无论以何种方式启动都要挂载)
|
||||
webdav_app = create_webdav_app()
|
||||
app.mount("/webdav", WSGIMiddleware(webdav_app))
|
||||
# 注册文件管理API路由
|
||||
app.include_router(file_manager_router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 启动 FastAPI 应用 (WebDAV 已经挂载到 /webdav 路径)
|
||||
print("Starting FastAPI server with WebDAV support...")
|
||||
print("WebDAV available at: http://localhost:8001/webdav")
|
||||
print("API available at: http://localhost:8001")
|
||||
# 启动 FastAPI 应用
|
||||
print("Starting FastAPI server...")
|
||||
print("File Manager API available at: http://localhost:8001/api/v1/files")
|
||||
print("Web Interface available at: http://localhost:8001/public/file-manager.html")
|
||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||
|
||||
691
file_manager_api.py
Normal file
691
file_manager_api.py
Normal file
@ -0,0 +1,691 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
文件管理API - WebDAV的HTTP API替代方案
|
||||
提供RESTful接口来管理projects文件夹
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Any
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Form, Query
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
import mimetypes
|
||||
import json
|
||||
import zipfile
|
||||
import tempfile
|
||||
import io
|
||||
import math
|
||||
|
||||
router = APIRouter(prefix="/api/v1/files", tags=["file_management"])
|
||||
|
||||
PROJECTS_DIR = Path("projects")
|
||||
PROJECTS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def list_files(path: str = "", recursive: bool = False):
|
||||
"""
|
||||
列出目录内容
|
||||
|
||||
Args:
|
||||
path: 相对路径,空字符串表示根目录
|
||||
recursive: 是否递归列出所有子目录
|
||||
"""
|
||||
try:
|
||||
target_path = PROJECTS_DIR / path
|
||||
|
||||
if not target_path.exists():
|
||||
raise HTTPException(status_code=404, detail="路径不存在")
|
||||
|
||||
if not target_path.is_dir():
|
||||
raise HTTPException(status_code=400, detail="路径不是目录")
|
||||
|
||||
def scan_directory(directory: Path, base_path: Path = PROJECTS_DIR) -> List[Dict[str, Any]]:
|
||||
items = []
|
||||
try:
|
||||
for item in directory.iterdir():
|
||||
# 跳过隐藏文件
|
||||
if item.name.startswith('.'):
|
||||
continue
|
||||
|
||||
relative_path = item.relative_to(base_path)
|
||||
stat = item.stat()
|
||||
|
||||
item_info = {
|
||||
"name": item.name,
|
||||
"path": str(relative_path),
|
||||
"type": "directory" if item.is_dir() else "file",
|
||||
"size": stat.st_size if item.is_file() else 0,
|
||||
"modified": stat.st_mtime,
|
||||
"created": stat.st_ctime
|
||||
}
|
||||
|
||||
items.append(item_info)
|
||||
|
||||
# 递归扫描子目录
|
||||
if recursive and item.is_dir():
|
||||
items.extend(scan_directory(item, base_path))
|
||||
|
||||
except PermissionError:
|
||||
pass
|
||||
return items
|
||||
|
||||
items = scan_directory(target_path)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": path,
|
||||
"items": items,
|
||||
"total": len(items)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"列出目录失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_file(file: UploadFile = File(...), path: str = Form("")):
|
||||
"""
|
||||
上传文件
|
||||
|
||||
Args:
|
||||
file: 上传的文件
|
||||
path: 目标路径(相对于projects目录)
|
||||
"""
|
||||
try:
|
||||
target_dir = PROJECTS_DIR / path
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_path = target_dir / file.filename
|
||||
|
||||
# 如果文件已存在,检查是否覆盖
|
||||
if file_path.exists():
|
||||
# 可以添加版本控制或重命名逻辑
|
||||
pass
|
||||
|
||||
with open(file_path, "wb") as buffer:
|
||||
content = await file.read()
|
||||
buffer.write(content)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "文件上传成功",
|
||||
"filename": file.filename,
|
||||
"path": str(Path(path) / file.filename),
|
||||
"size": len(content)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"文件上传失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/download/{file_path:path}")
|
||||
async def download_file(file_path: str):
|
||||
"""
|
||||
下载文件
|
||||
|
||||
Args:
|
||||
file_path: 文件相对路径
|
||||
"""
|
||||
try:
|
||||
target_path = PROJECTS_DIR / file_path
|
||||
|
||||
if not target_path.exists():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
|
||||
if not target_path.is_file():
|
||||
raise HTTPException(status_code=400, detail="不是文件")
|
||||
|
||||
# 猜测MIME类型
|
||||
mime_type, _ = mimetypes.guess_type(str(target_path))
|
||||
if mime_type is None:
|
||||
mime_type = "application/octet-stream"
|
||||
|
||||
return FileResponse(
|
||||
path=str(target_path),
|
||||
filename=target_path.name,
|
||||
media_type=mime_type
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"文件下载失败: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/delete")
|
||||
async def delete_item(path: str):
|
||||
"""
|
||||
删除文件或目录
|
||||
|
||||
Args:
|
||||
path: 要删除的路径
|
||||
"""
|
||||
try:
|
||||
target_path = PROJECTS_DIR / path
|
||||
|
||||
if not target_path.exists():
|
||||
raise HTTPException(status_code=404, detail="路径不存在")
|
||||
|
||||
if target_path.is_file():
|
||||
target_path.unlink()
|
||||
elif target_path.is_dir():
|
||||
shutil.rmtree(target_path)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"{'文件' if target_path.is_file() else '目录'}删除成功",
|
||||
"path": path
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"删除失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/create-folder")
|
||||
async def create_folder(path: str, name: str):
|
||||
"""
|
||||
创建文件夹
|
||||
|
||||
Args:
|
||||
path: 父目录路径
|
||||
name: 新文件夹名称
|
||||
"""
|
||||
try:
|
||||
parent_path = PROJECTS_DIR / path
|
||||
parent_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
new_folder = parent_path / name
|
||||
|
||||
if new_folder.exists():
|
||||
raise HTTPException(status_code=400, detail="文件夹已存在")
|
||||
|
||||
new_folder.mkdir()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "文件夹创建成功",
|
||||
"path": str(Path(path) / name)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"创建文件夹失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/rename")
|
||||
async def rename_item(old_path: str, new_name: str):
|
||||
"""
|
||||
重命名文件或文件夹
|
||||
|
||||
Args:
|
||||
old_path: 原路径
|
||||
new_name: 新名称
|
||||
"""
|
||||
try:
|
||||
old_full_path = PROJECTS_DIR / old_path
|
||||
|
||||
if not old_full_path.exists():
|
||||
raise HTTPException(status_code=404, detail="文件或目录不存在")
|
||||
|
||||
new_full_path = old_full_path.parent / new_name
|
||||
|
||||
if new_full_path.exists():
|
||||
raise HTTPException(status_code=400, detail="目标名称已存在")
|
||||
|
||||
old_full_path.rename(new_full_path)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "重命名成功",
|
||||
"old_path": old_path,
|
||||
"new_path": str(new_full_path.relative_to(PROJECTS_DIR))
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"重命名失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/move")
|
||||
async def move_item(source_path: str, target_path: str):
|
||||
"""
|
||||
移动文件或文件夹
|
||||
|
||||
Args:
|
||||
source_path: 源路径
|
||||
target_path: 目标路径
|
||||
"""
|
||||
try:
|
||||
source_full_path = PROJECTS_DIR / source_path
|
||||
target_full_path = PROJECTS_DIR / target_path
|
||||
|
||||
if not source_full_path.exists():
|
||||
raise HTTPException(status_code=404, detail="源文件或目录不存在")
|
||||
|
||||
# 确保目标目录存在
|
||||
target_full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
shutil.move(str(source_full_path), str(target_full_path))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "移动成功",
|
||||
"source_path": source_path,
|
||||
"target_path": target_path
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"移动失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/copy")
|
||||
async def copy_item(source_path: str, target_path: str):
|
||||
"""
|
||||
复制文件或文件夹
|
||||
|
||||
Args:
|
||||
source_path: 源路径
|
||||
target_path: 目标路径
|
||||
"""
|
||||
try:
|
||||
source_full_path = PROJECTS_DIR / source_path
|
||||
target_full_path = PROJECTS_DIR / target_path
|
||||
|
||||
if not source_full_path.exists():
|
||||
raise HTTPException(status_code=404, detail="源文件或目录不存在")
|
||||
|
||||
# 确保目标目录存在
|
||||
target_full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if source_full_path.is_file():
|
||||
shutil.copy2(str(source_full_path), str(target_full_path))
|
||||
else:
|
||||
shutil.copytree(str(source_full_path), str(target_full_path))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "复制成功",
|
||||
"source_path": source_path,
|
||||
"target_path": target_path
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"复制失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def search_files(query: str, path: str = "", file_type: Optional[str] = None):
|
||||
"""
|
||||
搜索文件
|
||||
|
||||
Args:
|
||||
query: 搜索关键词
|
||||
path: 搜索路径
|
||||
file_type: 文件类型过滤
|
||||
"""
|
||||
try:
|
||||
search_path = PROJECTS_DIR / path
|
||||
|
||||
if not search_path.exists():
|
||||
raise HTTPException(status_code=404, detail="搜索路径不存在")
|
||||
|
||||
results = []
|
||||
|
||||
def scan_for_files(directory: Path):
|
||||
try:
|
||||
for item in directory.iterdir():
|
||||
if item.name.startswith('.'):
|
||||
continue
|
||||
|
||||
# 检查文件名是否包含关键词
|
||||
if query.lower() in item.name.lower():
|
||||
# 检查文件类型过滤
|
||||
if file_type:
|
||||
if item.suffix.lower() == file_type.lower():
|
||||
results.append({
|
||||
"name": item.name,
|
||||
"path": str(item.relative_to(PROJECTS_DIR)),
|
||||
"type": "directory" if item.is_dir() else "file"
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
"name": item.name,
|
||||
"path": str(item.relative_to(PROJECTS_DIR)),
|
||||
"type": "directory" if item.is_dir() else "file"
|
||||
})
|
||||
|
||||
# 递归搜索子目录
|
||||
if item.is_dir():
|
||||
scan_for_files(item)
|
||||
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
scan_for_files(search_path)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"query": query,
|
||||
"path": path,
|
||||
"results": results,
|
||||
"total": len(results)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"搜索失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/info/{file_path:path}")
|
||||
async def get_file_info(file_path: str):
|
||||
"""
|
||||
获取文件或文件夹详细信息
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
"""
|
||||
try:
|
||||
target_path = PROJECTS_DIR / file_path
|
||||
|
||||
if not target_path.exists():
|
||||
raise HTTPException(status_code=404, detail="路径不存在")
|
||||
|
||||
stat = target_path.stat()
|
||||
|
||||
info = {
|
||||
"name": target_path.name,
|
||||
"path": file_path,
|
||||
"type": "directory" if target_path.is_dir() else "file",
|
||||
"size": stat.st_size if target_path.is_file() else 0,
|
||||
"modified": stat.st_mtime,
|
||||
"created": stat.st_ctime,
|
||||
"permissions": oct(stat.st_mode)[-3:]
|
||||
}
|
||||
|
||||
# 如果是文件,添加额外信息
|
||||
if target_path.is_file():
|
||||
mime_type, _ = mimetypes.guess_type(str(target_path))
|
||||
info["mime_type"] = mime_type or "unknown"
|
||||
|
||||
# 读取文件内容预览(仅对小文件)
|
||||
if stat.st_size < 1024 * 1024: # 小于1MB
|
||||
try:
|
||||
with open(target_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read(1000) # 读取前1000字符
|
||||
info["preview"] = content
|
||||
except:
|
||||
info["preview"] = "[二进制文件,无法预览]"
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"info": info
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"获取文件信息失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/download-folder-zip")
|
||||
async def download_folder_as_zip(request: Dict[str, str]):
|
||||
"""
|
||||
将文件夹压缩为ZIP并下载
|
||||
|
||||
Args:
|
||||
request: 包含path字段的JSON对象
|
||||
"""
|
||||
try:
|
||||
folder_path = request.get("path", "")
|
||||
|
||||
if not folder_path:
|
||||
raise HTTPException(status_code=400, detail="路径不能为空")
|
||||
|
||||
target_path = PROJECTS_DIR / folder_path
|
||||
|
||||
if not target_path.exists():
|
||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||
|
||||
if not target_path.is_dir():
|
||||
raise HTTPException(status_code=400, detail="路径不是文件夹")
|
||||
|
||||
# 计算文件夹大小,检查是否过大
|
||||
total_size = 0
|
||||
file_count = 0
|
||||
for file_path in target_path.rglob('*'):
|
||||
if file_path.is_file():
|
||||
total_size += file_path.stat().st_size
|
||||
file_count += 1
|
||||
|
||||
# 限制最大500MB
|
||||
max_size = 500 * 1024 * 1024
|
||||
if total_size > max_size:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"文件夹过大 ({formatFileSize(total_size)}),最大支持 {formatFileSize(max_size)}"
|
||||
)
|
||||
|
||||
# 限制文件数量
|
||||
max_files = 10000
|
||||
if file_count > max_files:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"文件数量过多 ({file_count}),最大支持 {max_files} 个文件"
|
||||
)
|
||||
|
||||
# 创建ZIP文件名
|
||||
folder_name = target_path.name
|
||||
zip_filename = f"{folder_name}.zip"
|
||||
|
||||
# 创建ZIP文件
|
||||
zip_buffer = io.BytesIO()
|
||||
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED, compresslevel=6) as zipf:
|
||||
# 添加文件夹中的所有文件
|
||||
for file_path in target_path.rglob('*'):
|
||||
if file_path.is_file():
|
||||
try:
|
||||
# 计算相对路径,保持文件夹结构
|
||||
arcname = file_path.relative_to(target_path)
|
||||
zipf.write(file_path, arcname)
|
||||
except (OSError, IOError) as e:
|
||||
# 跳过无法读取的文件,但记录警告
|
||||
print(f"Warning: Skipping file {file_path}: {e}")
|
||||
continue
|
||||
|
||||
zip_buffer.seek(0)
|
||||
|
||||
# 设置响应头
|
||||
headers = {
|
||||
'Content-Disposition': f'attachment; filename="{zip_filename}"',
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Length': str(len(zip_buffer.getvalue())),
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
|
||||
# 创建流式响应
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
async def generate_zip():
|
||||
zip_buffer.seek(0)
|
||||
yield zip_buffer.getvalue()
|
||||
|
||||
return StreamingResponse(
|
||||
generate_zip(),
|
||||
media_type="application/zip",
|
||||
headers=headers
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"压缩文件夹失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/download-multiple-zip")
|
||||
async def download_multiple_items_as_zip(request: Dict[str, Any]):
|
||||
"""
|
||||
将多个文件和文件夹压缩为ZIP并下载
|
||||
|
||||
Args:
|
||||
request: 包含paths和filename字段的JSON对象
|
||||
"""
|
||||
try:
|
||||
paths = request.get("paths", [])
|
||||
filename = request.get("filename", "batch_download.zip")
|
||||
|
||||
if not paths:
|
||||
raise HTTPException(status_code=400, detail="请选择要下载的文件")
|
||||
|
||||
# 验证所有路径
|
||||
valid_paths = []
|
||||
total_size = 0
|
||||
file_count = 0
|
||||
|
||||
for path in paths:
|
||||
target_path = PROJECTS_DIR / path
|
||||
|
||||
if not target_path.exists():
|
||||
continue # 跳过不存在的文件
|
||||
|
||||
if target_path.is_file():
|
||||
total_size += target_path.stat().st_size
|
||||
file_count += 1
|
||||
valid_paths.append(path)
|
||||
elif target_path.is_dir():
|
||||
# 计算文件夹大小
|
||||
for file_path in target_path.rglob('*'):
|
||||
if file_path.is_file():
|
||||
total_size += file_path.stat().st_size
|
||||
file_count += 1
|
||||
valid_paths.append(path)
|
||||
|
||||
if not valid_paths:
|
||||
raise HTTPException(status_code=404, detail="没有找到有效的文件")
|
||||
|
||||
# 限制大小
|
||||
max_size = 500 * 1024 * 1024
|
||||
if total_size > max_size:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"选中文件过大 ({formatFileSize(total_size)}),最大支持 {formatFileSize(max_size)}"
|
||||
)
|
||||
|
||||
# 限制文件数量
|
||||
max_files = 10000
|
||||
if file_count > max_files:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"文件数量过多 ({file_count}),最大支持 {max_files} 个文件"
|
||||
)
|
||||
|
||||
# 创建ZIP文件
|
||||
zip_buffer = io.BytesIO()
|
||||
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED, compresslevel=6) as zipf:
|
||||
for path in valid_paths:
|
||||
target_path = PROJECTS_DIR / path
|
||||
|
||||
if target_path.is_file():
|
||||
# 单个文件
|
||||
try:
|
||||
zipf.write(target_path, target_path.name)
|
||||
except (OSError, IOError) as e:
|
||||
print(f"Warning: Skipping file {target_path}: {e}")
|
||||
continue
|
||||
elif target_path.is_dir():
|
||||
# 文件夹
|
||||
for file_path in target_path.rglob('*'):
|
||||
if file_path.is_file():
|
||||
try:
|
||||
# 保持相对路径结构
|
||||
arcname = f"{target_path.name}/{file_path.relative_to(target_path)}"
|
||||
zipf.write(file_path, arcname)
|
||||
except (OSError, IOError) as e:
|
||||
print(f"Warning: Skipping file {file_path}: {e}")
|
||||
continue
|
||||
|
||||
zip_buffer.seek(0)
|
||||
|
||||
# 设置响应头
|
||||
headers = {
|
||||
'Content-Disposition': f'attachment; filename="{filename}"',
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Length': str(len(zip_buffer.getvalue())),
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
|
||||
# 创建流式响应
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
async def generate_zip():
|
||||
zip_buffer.seek(0)
|
||||
yield zip_buffer.getvalue()
|
||||
|
||||
return StreamingResponse(
|
||||
generate_zip(),
|
||||
media_type="application/zip",
|
||||
headers=headers
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"批量压缩失败: {str(e)}")
|
||||
|
||||
|
||||
def formatFileSize(bytes_size: int) -> str:
|
||||
"""格式化文件大小"""
|
||||
if bytes_size == 0:
|
||||
return "0 B"
|
||||
|
||||
k = 1024
|
||||
sizes = ["B", "KB", "MB", "GB", "TB"]
|
||||
i = int(math.floor(math.log(bytes_size, k)))
|
||||
|
||||
if i >= len(sizes):
|
||||
i = len(sizes) - 1
|
||||
|
||||
return f"{bytes_size / math.pow(k, i):.1f} {sizes[i]}"
|
||||
|
||||
|
||||
@router.post("/batch-operation")
|
||||
async def batch_operation(operations: List[Dict[str, Any]]):
|
||||
"""
|
||||
批量操作多个文件
|
||||
|
||||
Args:
|
||||
operations: 操作列表,每个操作包含type和相应的参数
|
||||
"""
|
||||
try:
|
||||
results = []
|
||||
|
||||
for op in operations:
|
||||
op_type = op.get("type")
|
||||
op_result = {"type": op_type, "success": False}
|
||||
|
||||
try:
|
||||
if op_type == "delete":
|
||||
await delete_item(op["path"])
|
||||
op_result["success"] = True
|
||||
op_result["message"] = "删除成功"
|
||||
elif op_type == "move":
|
||||
await move_item(op["source_path"], op["target_path"])
|
||||
op_result["success"] = True
|
||||
op_result["message"] = "移动成功"
|
||||
elif op_type == "copy":
|
||||
await copy_item(op["source_path"], op["target_path"])
|
||||
op_result["success"] = True
|
||||
op_result["message"] = "复制成功"
|
||||
else:
|
||||
op_result["error"] = f"不支持的操作类型: {op_type}"
|
||||
|
||||
except Exception as e:
|
||||
op_result["error"] = str(e)
|
||||
|
||||
results.append(op_result)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"results": results,
|
||||
"total": len(operations),
|
||||
"successful": sum(1 for r in results if r["success"])
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"批量操作失败: {str(e)}")
|
||||
990
public/file-manager.html
Normal file
990
public/file-manager.html
Normal file
@ -0,0 +1,990 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>文件管理器</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.file-item.selected {
|
||||
background-color: #e3f2fd;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #1976d2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #1565c0;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
background-color: #17a2b8;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-info:hover {
|
||||
background-color: #138496;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: white;
|
||||
margin: 15% auto;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
cursor: pointer;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
border: 2px dashed #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
border-color: #1976d2;
|
||||
}
|
||||
|
||||
.upload-area.dragover {
|
||||
border-color: #1976d2;
|
||||
background-color: #f0f8ff;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📁 项目文件管理器</h1>
|
||||
<p>管理您的项目文件,支持上传、下载、删除等操作</p>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="search-box">
|
||||
<input type="text" id="searchInput" placeholder="搜索文件..." />
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="showUploadModal()">📤 上传文件</button>
|
||||
<button class="btn btn-success" onclick="showCreateFolderModal()">📁 新建文件夹</button>
|
||||
<button class="btn btn-secondary" onclick="refreshFileList()">🔄 刷新</button>
|
||||
<button class="btn btn-info" onclick="toggleBatchMode()" id="batchModeBtn">📦 批量操作</button>
|
||||
<button class="btn btn-primary" onclick="selectAll()" id="selectAllBtn" style="display: none;">☑️ 全选</button>
|
||||
<button class="btn btn-primary" onclick="downloadSelected()" id="downloadSelectedBtn" style="display: none;">📥 下载选中</button>
|
||||
</div>
|
||||
|
||||
<div class="breadcrumb" id="breadcrumb">
|
||||
<span class="breadcrumb-item" onclick="navigateToPath('')">🏠 根目录</span>
|
||||
</div>
|
||||
|
||||
<div class="file-list" id="fileList">
|
||||
<!-- 文件列表将在这里动态生成 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传文件模态框 -->
|
||||
<div id="uploadModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>上传文件</h3>
|
||||
<div class="upload-area" id="uploadArea">
|
||||
<p>📁 拖拽文件到这里或点击选择文件</p>
|
||||
<input type="file" id="fileInput" multiple style="display: none;" />
|
||||
</div>
|
||||
<div style="margin-top: 15px; text-align: right;">
|
||||
<button class="btn btn-secondary" onclick="closeModal('uploadModal')">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新建文件夹模态框 -->
|
||||
<div id="createFolderModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>新建文件夹</h3>
|
||||
<div class="form-group">
|
||||
<label>文件夹名称:</label>
|
||||
<input type="text" id="folderNameInput" placeholder="输入文件夹名称" />
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<button class="btn btn-secondary" onclick="closeModal('createFolderModal')">取消</button>
|
||||
<button class="btn btn-primary" onclick="createFolder()">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重命名模态框 -->
|
||||
<div id="renameModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>重命名</h3>
|
||||
<div class="form-group">
|
||||
<label>新名称:</label>
|
||||
<input type="text" id="renameInput" placeholder="输入新名称" />
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<button class="btn btn-secondary" onclick="closeModal('renameModal')">取消</button>
|
||||
<button class="btn btn-primary" onclick="renameItem()">重命名</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = `${window.location.protocol}//${window.location.host}/api/v1/files`;
|
||||
let currentPath = '';
|
||||
let selectedItems = new Set();
|
||||
let currentItemToRename = '';
|
||||
let batchMode = false;
|
||||
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadFileList();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
function setupEventListeners() {
|
||||
// 搜索功能
|
||||
document.getElementById('searchInput').addEventListener('input', function(e) {
|
||||
if (e.target.value.trim()) {
|
||||
searchFiles(e.target.value.trim());
|
||||
} else {
|
||||
loadFileList();
|
||||
}
|
||||
});
|
||||
|
||||
// 文件上传
|
||||
const uploadArea = document.getElementById('uploadArea');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
|
||||
uploadArea.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', handleFileSelect);
|
||||
|
||||
// 拖拽上传
|
||||
uploadArea.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.add('dragover');
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('dragleave', () => {
|
||||
uploadArea.classList.remove('dragover');
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.remove('dragover');
|
||||
handleFiles(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
// 模态框点击外部关闭
|
||||
document.querySelectorAll('.modal').forEach(modal => {
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadFileList(path = '') {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/list?path=${encodeURIComponent(path)}&recursive=false`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
currentPath = path;
|
||||
renderFileList(data.items);
|
||||
updateBreadcrumb(path);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载文件列表失败:', error);
|
||||
showMessage('加载文件列表失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderFileList(items) {
|
||||
const fileList = document.getElementById('fileList');
|
||||
fileList.innerHTML = '';
|
||||
|
||||
// 返回上级目录
|
||||
if (currentPath) {
|
||||
const parentItem = createFileItem({
|
||||
name: '..',
|
||||
type: 'directory',
|
||||
path: getParentPath(currentPath),
|
||||
modified: Date.now() / 1000
|
||||
});
|
||||
fileList.appendChild(parentItem);
|
||||
}
|
||||
|
||||
// 渲染文件和文件夹
|
||||
items.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === 'directory' ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
}).forEach(item => {
|
||||
const fileItem = createFileItem(item);
|
||||
fileList.appendChild(fileItem);
|
||||
});
|
||||
}
|
||||
|
||||
function createFileItem(item) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'file-item';
|
||||
div.dataset.path = item.path;
|
||||
|
||||
const icon = item.type === 'directory' ? '📁' : getFileIcon(item.name);
|
||||
const size = item.type === 'file' ? formatFileSize(item.size) : '-';
|
||||
const date = new Date(item.modified * 1000).toLocaleDateString();
|
||||
const isSelected = selectedItems.has(item.path);
|
||||
|
||||
// 构建复选框HTML
|
||||
const checkboxHtml = batchMode ?
|
||||
`<input type="checkbox" ${isSelected ? 'checked' : ''} onchange="toggleItemSelection('${item.path}')" style="margin-right: 12px;">` : '';
|
||||
|
||||
div.innerHTML = `
|
||||
${checkboxHtml}
|
||||
<div class="file-icon">${icon}</div>
|
||||
<div class="file-info">
|
||||
<div class="file-name">${item.name}</div>
|
||||
<div class="file-meta">${size} • ${date}</div>
|
||||
</div>
|
||||
<div class="file-actions">
|
||||
${item.type === 'file' ?
|
||||
`<button class="btn btn-primary" onclick="downloadFile('${item.path}')">下载</button>` :
|
||||
`<button class="btn btn-primary" onclick="downloadFolderAsZip('${item.path}')">下载ZIP</button>`
|
||||
}
|
||||
<button class="btn btn-secondary" onclick="showRenameModal('${item.path}')">重命名</button>
|
||||
<button class="btn btn-danger" onclick="deleteItem('${item.path}')">删除</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 添加批量模式下的点击事件
|
||||
div.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.file-actions') || e.target.type === 'checkbox') return;
|
||||
|
||||
if (batchMode) {
|
||||
toggleItemSelection(item.path);
|
||||
// 更新复选框状态
|
||||
const checkbox = div.querySelector('input[type="checkbox"]');
|
||||
if (checkbox) {
|
||||
checkbox.checked = selectedItems.has(item.path);
|
||||
}
|
||||
} else {
|
||||
if (item.type === 'directory') {
|
||||
loadFileList(item.path);
|
||||
} else {
|
||||
window.open(`${API_BASE}/download/${item.path}`, '_blank');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 添加选中状态的样式
|
||||
if (isSelected) {
|
||||
div.classList.add('selected');
|
||||
}
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
function getFileIcon(fileName) {
|
||||
const ext = fileName.split('.').pop().toLowerCase();
|
||||
const iconMap = {
|
||||
'md': '📄',
|
||||
'txt': '📝',
|
||||
'pdf': '📕',
|
||||
'doc': '📘',
|
||||
'docx': '📘',
|
||||
'xls': '📗',
|
||||
'xlsx': '📗',
|
||||
'ppt': '📙',
|
||||
'pptx': '📙',
|
||||
'jpg': '🖼️',
|
||||
'jpeg': '🖼️',
|
||||
'png': '🖼️',
|
||||
'gif': '🖼️',
|
||||
'zip': '📦',
|
||||
'rar': '📦',
|
||||
'mp4': '🎬',
|
||||
'mp3': '🎵',
|
||||
'json': '📋',
|
||||
'xml': '📋',
|
||||
'html': '🌐',
|
||||
'css': '🎨',
|
||||
'js': '⚡',
|
||||
'py': '🐍'
|
||||
};
|
||||
|
||||
return iconMap[ext] || '📄';
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function updateBreadcrumb(path) {
|
||||
const breadcrumb = document.getElementById('breadcrumb');
|
||||
breadcrumb.innerHTML = '<span class="breadcrumb-item" onclick="navigateToPath(\'\')">🏠 根目录</span>';
|
||||
|
||||
if (path) {
|
||||
const parts = path.split('/');
|
||||
let currentPath = '';
|
||||
|
||||
parts.forEach((part, index) => {
|
||||
currentPath += (currentPath ? '/' : '') + part;
|
||||
const isLast = index === parts.length - 1;
|
||||
|
||||
if (!isLast) {
|
||||
breadcrumb.innerHTML += ` <span>›</span> <span class="breadcrumb-item" onclick="navigateToPath('${currentPath}')">${part}</span>`;
|
||||
} else {
|
||||
breadcrumb.innerHTML += ` <span>›</span> <span>${part}</span>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToPath(path) {
|
||||
loadFileList(path);
|
||||
}
|
||||
|
||||
function getParentPath(path) {
|
||||
const parts = path.split('/');
|
||||
return parts.slice(0, -1).join('/');
|
||||
}
|
||||
|
||||
async function searchFiles(query) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/search?query=${encodeURIComponent(query)}&path=${encodeURIComponent(currentPath)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
renderFileList(data.results.map(item => ({
|
||||
...item,
|
||||
modified: Date.now() / 1000,
|
||||
size: 0
|
||||
})));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error);
|
||||
showMessage('搜索失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(e) {
|
||||
handleFiles(e.target.files);
|
||||
}
|
||||
|
||||
async function handleFiles(files) {
|
||||
for (let file of files) {
|
||||
await uploadFile(file);
|
||||
}
|
||||
closeModal('uploadModal');
|
||||
loadFileList();
|
||||
}
|
||||
|
||||
async function uploadFile(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('path', currentPath);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/upload`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showMessage(`文件 ${file.name} 上传成功`, 'success');
|
||||
} else {
|
||||
showMessage(`文件 ${file.name} 上传失败`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传失败:', error);
|
||||
showMessage(`文件 ${file.name} 上传失败`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteItem(path) {
|
||||
if (!confirm(`确定要删除 ${path.split('/').pop()} 吗?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/delete?path=${encodeURIComponent(path)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showMessage('删除成功', 'success');
|
||||
loadFileList();
|
||||
} else {
|
||||
showMessage('删除失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
showMessage('删除失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
const folderName = document.getElementById('folderNameInput').value.trim();
|
||||
if (!folderName) {
|
||||
showMessage('请输入文件夹名称', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/create-folder`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: currentPath,
|
||||
name: folderName
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showMessage('文件夹创建成功', 'success');
|
||||
closeModal('createFolderModal');
|
||||
loadFileList();
|
||||
} else {
|
||||
showMessage('文件夹创建失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建文件夹失败:', error);
|
||||
showMessage('创建文件夹失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function renameItem() {
|
||||
const newName = document.getElementById('renameInput').value.trim();
|
||||
if (!newName) {
|
||||
showMessage('请输入新名称', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/rename`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
old_path: currentItemToRename,
|
||||
new_name: newName
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showMessage('重命名成功', 'success');
|
||||
closeModal('renameModal');
|
||||
loadFileList();
|
||||
} else {
|
||||
showMessage('重命名失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重命名失败:', error);
|
||||
showMessage('重命名失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile(path) {
|
||||
window.open(`${API_BASE}/download/${path}`, '_blank');
|
||||
}
|
||||
|
||||
async function downloadFolderAsZip(folderPath) {
|
||||
const folderName = folderPath.split('/').pop();
|
||||
const downloadButton = event.target;
|
||||
|
||||
// 禁用按钮,显示加载状态
|
||||
const originalText = downloadButton.textContent;
|
||||
downloadButton.disabled = true;
|
||||
downloadButton.textContent = '压缩中...';
|
||||
|
||||
try {
|
||||
// 显示压缩开始提示
|
||||
showMessage(`正在压缩文件夹 "${folderName}"...`, 'info');
|
||||
|
||||
// 使用新的下载文件夹ZIP接口
|
||||
const response = await fetch(`${API_BASE}/download-folder-zip`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ path: folderPath })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// 获取文件名
|
||||
const contentDisposition = response.headers.get('content-disposition');
|
||||
let fileName = `${folderName}.zip`;
|
||||
if (contentDisposition) {
|
||||
const fileNameMatch = contentDisposition.match(/filename="?([^"]+)"?/);
|
||||
if (fileNameMatch) {
|
||||
fileName = fileNameMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
const contentLength = response.headers.get('content-length');
|
||||
const fileSize = contentLength ? parseInt(contentLength) : 0;
|
||||
|
||||
if (fileSize > 100 * 1024 * 1024) { // 大于100MB显示警告
|
||||
showMessage(`文件较大 (${formatFileSize(fileSize)}),下载可能需要一些时间...`, 'warning');
|
||||
}
|
||||
|
||||
downloadButton.textContent = '下载中...';
|
||||
|
||||
// 创建下载链接
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
showMessage(`文件夹 "${folderName}" 压缩下载成功`, 'success');
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `服务器错误: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('下载文件夹失败:', error);
|
||||
showMessage(`下载文件夹 "${folderName}" 失败: ${error.message}`, 'error');
|
||||
} finally {
|
||||
// 恢复按钮状态
|
||||
downloadButton.disabled = false;
|
||||
downloadButton.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshFileList() {
|
||||
loadFileList(currentPath);
|
||||
}
|
||||
|
||||
function toggleBatchMode() {
|
||||
batchMode = !batchMode;
|
||||
const batchBtn = document.getElementById('batchModeBtn');
|
||||
const downloadBtn = document.getElementById('downloadSelectedBtn');
|
||||
const selectAllBtn = document.getElementById('selectAllBtn');
|
||||
|
||||
if (batchMode) {
|
||||
batchBtn.textContent = '❌ 退出批量';
|
||||
batchBtn.classList.remove('btn-info');
|
||||
batchBtn.classList.add('btn-secondary');
|
||||
downloadBtn.style.display = 'inline-block';
|
||||
selectAllBtn.style.display = 'inline-block';
|
||||
selectedItems.clear();
|
||||
} else {
|
||||
batchBtn.textContent = '📦 批量操作';
|
||||
batchBtn.classList.remove('btn-secondary');
|
||||
batchBtn.classList.add('btn-info');
|
||||
downloadBtn.style.display = 'none';
|
||||
selectAllBtn.style.display = 'none';
|
||||
selectedItems.clear();
|
||||
}
|
||||
|
||||
refreshFileList(); // 重新渲染以显示/隐藏复选框
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
const allItems = document.querySelectorAll('.file-item[data-path]');
|
||||
const allPaths = Array.from(allItems).map(item => item.dataset.path);
|
||||
|
||||
if (selectedItems.size === allPaths.length) {
|
||||
// 如果已全选,则取消全选
|
||||
selectedItems.clear();
|
||||
document.getElementById('selectAllBtn').textContent = '☑️ 全选';
|
||||
} else {
|
||||
// 全选
|
||||
selectedItems.clear();
|
||||
allPaths.forEach(path => selectedItems.add(path));
|
||||
document.getElementById('selectAllBtn').textContent = '❌ 取消全选';
|
||||
}
|
||||
|
||||
// 更新所有复选框和选中状态
|
||||
allItems.forEach(item => {
|
||||
const path = item.dataset.path;
|
||||
const checkbox = item.querySelector('input[type="checkbox"]');
|
||||
if (checkbox) {
|
||||
checkbox.checked = selectedItems.has(path);
|
||||
}
|
||||
if (selectedItems.has(path)) {
|
||||
item.classList.add('selected');
|
||||
} else {
|
||||
item.classList.remove('selected');
|
||||
}
|
||||
});
|
||||
|
||||
updateBatchUI();
|
||||
}
|
||||
|
||||
function toggleItemSelection(path) {
|
||||
if (selectedItems.has(path)) {
|
||||
selectedItems.delete(path);
|
||||
} else {
|
||||
selectedItems.add(path);
|
||||
}
|
||||
updateBatchUI();
|
||||
}
|
||||
|
||||
function updateBatchUI() {
|
||||
const downloadBtn = document.getElementById('downloadSelectedBtn');
|
||||
const count = selectedItems.size;
|
||||
|
||||
if (count > 0) {
|
||||
downloadBtn.textContent = `📥 下载选中 (${count})`;
|
||||
downloadBtn.classList.remove('btn-primary');
|
||||
downloadBtn.classList.add('btn-success');
|
||||
} else {
|
||||
downloadBtn.textContent = '📥 下载选中';
|
||||
downloadBtn.classList.remove('btn-success');
|
||||
downloadBtn.classList.add('btn-primary');
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadSelected() {
|
||||
if (selectedItems.size === 0) {
|
||||
showMessage('请先选择要下载的文件', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const items = Array.from(selectedItems);
|
||||
const hasFolders = items.some(path => {
|
||||
const fullPath = PROJECTS_DIR / path;
|
||||
return fullPath.isDirectory();
|
||||
});
|
||||
|
||||
if (hasFolders) {
|
||||
showMessage('检测到文件夹,正在创建压缩包...', 'info');
|
||||
await downloadItemsAsZip(items);
|
||||
} else {
|
||||
// 下载单个文件
|
||||
for (const item of items) {
|
||||
downloadFile(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadItemsAsZip(items) {
|
||||
try {
|
||||
showMessage(`正在压缩 ${items.length} 个项目...`, 'info');
|
||||
|
||||
const response = await fetch(`${API_BASE}/download-multiple-zip`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paths: items,
|
||||
filename: `batch_download_${new Date().getTime()}.zip`
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const contentDisposition = response.headers.get('content-disposition');
|
||||
let fileName = `batch_download.zip`;
|
||||
if (contentDisposition) {
|
||||
const fileNameMatch = contentDisposition.match(/filename="?([^"]+)"?/);
|
||||
if (fileNameMatch) {
|
||||
fileName = fileNameMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
showMessage('批量下载成功', 'success');
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `服务器错误: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量下载失败:', error);
|
||||
showMessage(`批量下载失败: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showUploadModal() {
|
||||
document.getElementById('uploadModal').style.display = 'block';
|
||||
document.getElementById('fileInput').value = '';
|
||||
}
|
||||
|
||||
function showCreateFolderModal() {
|
||||
document.getElementById('createFolderModal').style.display = 'block';
|
||||
document.getElementById('folderNameInput').value = '';
|
||||
}
|
||||
|
||||
function showRenameModal(path) {
|
||||
currentItemToRename = path;
|
||||
const fileName = path.split('/').pop();
|
||||
document.getElementById('renameInput').value = fileName;
|
||||
document.getElementById('renameModal').style.display = 'block';
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
document.getElementById(modalId).style.display = 'none';
|
||||
}
|
||||
|
||||
function showMessage(message, type) {
|
||||
// 消息提示,支持多种类型
|
||||
const toast = document.createElement('div');
|
||||
let bgColor = '#6c757d'; // 默认灰色
|
||||
|
||||
switch(type) {
|
||||
case 'success':
|
||||
bgColor = '#28a745';
|
||||
break;
|
||||
case 'error':
|
||||
bgColor = '#dc3545';
|
||||
break;
|
||||
case 'info':
|
||||
bgColor = '#17a2b8';
|
||||
break;
|
||||
case 'warning':
|
||||
bgColor = '#ffc107';
|
||||
break;
|
||||
}
|
||||
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 4px;
|
||||
color: ${type === 'warning' ? '#212529' : 'white'};
|
||||
z-index: 10000;
|
||||
background-color: ${bgColor};
|
||||
max-width: 300px;
|
||||
word-wrap: break-word;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// 自动移除
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(toast)) {
|
||||
document.body.removeChild(toast);
|
||||
}
|
||||
}, 4000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1138
public/index.html
Normal file
1138
public/index.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -25,7 +25,6 @@ dependencies = [
|
||||
"openpyxl>=3.0.0",
|
||||
"xlrd>=2.0.0",
|
||||
"chardet>=5.0.0",
|
||||
"wsgidav>=4.0.0",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -95,7 +95,6 @@ tzdata==2025.2
|
||||
urllib3==2.5.0
|
||||
uvicorn==0.35.0
|
||||
websocket-client==1.9.0
|
||||
WsgiDAV==4.3.3
|
||||
xlrd==2.0.2
|
||||
xlsxwriter==3.2.9
|
||||
yarl==1.22.0
|
||||
|
||||
@ -1,111 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
生产环境WebDAV配置,支持域名部署
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from wsgidav.wsgidav_app import WsgiDAVApp
|
||||
from wsgidav.fs_dav_provider import FilesystemProvider
|
||||
from wsgidav.http_authenticator import HTTPAuthenticator
|
||||
|
||||
|
||||
class DomainDomainController:
|
||||
"""支持域名的域控制器"""
|
||||
|
||||
def __init__(self, wsgidav_app, config):
|
||||
self.wsgidav_app = wsgidav_app
|
||||
self.config = config
|
||||
|
||||
def get_domain_realm(self, path_info, environ):
|
||||
return "WebDAV"
|
||||
|
||||
def require_authentication(self, realm, environ):
|
||||
# 生产环境可以启用认证,开发环境暂时关闭
|
||||
return False
|
||||
|
||||
def is_authenticated_user(self, realm, user_name, environ):
|
||||
return True
|
||||
|
||||
def auth_user(self, realm, user_name, password, environ):
|
||||
return True
|
||||
|
||||
def supports_http_digest_auth(self):
|
||||
return True
|
||||
|
||||
def is_share_anonymous(self, share):
|
||||
return True
|
||||
|
||||
|
||||
def create_production_webdav_app(domain=None, ssl=False):
|
||||
"""
|
||||
创建生产环境WebDAV应用
|
||||
|
||||
Args:
|
||||
domain: 域名,如 'yourdomain.com'
|
||||
ssl: 是否使用HTTPS
|
||||
"""
|
||||
# 确保projects目录存在
|
||||
projects_dir = Path("projects")
|
||||
projects_dir.mkdir(exist_ok=True)
|
||||
|
||||
# 根据域名配置host
|
||||
if domain:
|
||||
host = "0.0.0.0" # 生产环境监听所有接口
|
||||
scheme = "https" if ssl else "http"
|
||||
server_url = f"{scheme}://{domain}"
|
||||
else:
|
||||
host = "localhost"
|
||||
server_url = "http://localhost"
|
||||
|
||||
# 配置WebDAV
|
||||
config = {
|
||||
"host": host,
|
||||
"port": 8001,
|
||||
"provider_mapping": {
|
||||
"/": FilesystemProvider(str(projects_dir))
|
||||
},
|
||||
"http_authenticator": {
|
||||
"domain_controller": DomainDomainController,
|
||||
"accept_basic": True,
|
||||
"accept_digest": True,
|
||||
"default_to_digest": False,
|
||||
},
|
||||
"verbose": 2 if domain else 1, # 生产环境更详细日志
|
||||
"dir_browser": {
|
||||
"enable": True,
|
||||
"response_trailer": True,
|
||||
"davmount_path": "/webdav",
|
||||
"ms_mount_path": "/webdav",
|
||||
},
|
||||
# 添加CORS支持
|
||||
"middleware": [
|
||||
{
|
||||
"class": "wsgidav.middleware.cors.CorsMiddleware",
|
||||
"options": {
|
||||
"cors_origin": "*",
|
||||
"cors_methods": "GET, POST, PUT, DELETE, OPTIONS, PROPFIND, MKCOL, COPY, MOVE",
|
||||
"cors_headers": "Authorization, Content-Type, Depth, Destination, Overwrite",
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# 创建应用
|
||||
app = WsgiDAVApp(config)
|
||||
|
||||
print(f"WebDAV Server configured for domain: {domain or 'localhost'}")
|
||||
print(f"Server URL: {server_url}:8001/webdav")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 可以通过环境变量配置域名
|
||||
domain = os.environ.get("WEBDAV_DOMAIN")
|
||||
ssl_enabled = os.environ.get("WEBDAV_SSL", "false").lower() == "true"
|
||||
|
||||
app = create_production_webdav_app(domain, ssl_enabled)
|
||||
|
||||
from wsgidav.server.run_server import run_server
|
||||
run_server(app)
|
||||
182
webdav_server.py
182
webdav_server.py
@ -1,182 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
WebDAV 服务器模块
|
||||
提供基于 WsgiDAV 的文件管理服务
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from wsgidav.wsgidav_app import WsgiDAVApp
|
||||
from wsgidav.fs_dav_provider import FilesystemProvider
|
||||
from wsgidav.http_authenticator import HTTPAuthenticator
|
||||
|
||||
|
||||
class SimpleDomainController:
|
||||
"""简单域控制器,支持固定用户名密码认证"""
|
||||
|
||||
def __init__(self, wsgidav_app, config):
|
||||
# 简单的用户名密码配置
|
||||
self.users = {
|
||||
"admin": "admin123", # 用户名: admin, 密码: admin123
|
||||
"webdav": "webdav123", # 用户名: webdav, 密码: webdav123
|
||||
}
|
||||
|
||||
def get_domain_realm(self, path_info, environ):
|
||||
return "WebDAV"
|
||||
|
||||
def require_authentication(self, realm, environ):
|
||||
return False # 暂时不强制认证,兼容性更好
|
||||
|
||||
def is_authenticated_user(self, realm, user_name, environ):
|
||||
return True # 允许任何用户名通过
|
||||
|
||||
def auth_user(self, realm, user_name, password, environ):
|
||||
# 如果用户名为空,允许通过(匿名访问)
|
||||
if not user_name:
|
||||
return True
|
||||
# 检查用户名密码
|
||||
return self.users.get(user_name) == password
|
||||
|
||||
def supports_http_digest_auth(self):
|
||||
return True
|
||||
|
||||
def is_share_anonymous(self, share):
|
||||
return True # 允许匿名访问
|
||||
|
||||
|
||||
def create_webdav_app():
|
||||
"""
|
||||
创建 WebDAV 应用
|
||||
|
||||
Returns:
|
||||
WsgiDAVApp: 配置好的 WebDAV 应用实例
|
||||
"""
|
||||
# 确保projects目录存在
|
||||
projects_dir = Path("projects")
|
||||
projects_dir.mkdir(exist_ok=True)
|
||||
|
||||
# 配置 WebDAV
|
||||
config = {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8001,
|
||||
"provider_mapping": {
|
||||
"/": FilesystemProvider(str(projects_dir))
|
||||
},
|
||||
"http_authenticator": {
|
||||
"domain_controller": "webdav_server.SimpleDomainController",
|
||||
"accept_basic": True,
|
||||
"accept_digest": True,
|
||||
"default_to_digest": False,
|
||||
},
|
||||
"verbose": 1, # 日志级别
|
||||
"dir_browser": {
|
||||
"enable": True,
|
||||
"response_trailer": True,
|
||||
"davmount_path": "/webdav",
|
||||
"ms_mount_path": "/webdav",
|
||||
}
|
||||
}
|
||||
|
||||
# 创建 WsgiDAV 应用
|
||||
app = WsgiDAVApp(config)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
class ProjectWebDAVProvider(FilesystemProvider):
|
||||
"""
|
||||
自定义的 WebDAV 提供器,专门用于管理 projects 目录
|
||||
"""
|
||||
|
||||
def __init__(self, root_folder="projects"):
|
||||
super().__init__(root_folder)
|
||||
self.root_folder = Path(root_folder)
|
||||
|
||||
def get_file_info(self, path):
|
||||
"""获取文件信息"""
|
||||
full_path = self.root_folder / path.lstrip('/')
|
||||
|
||||
if not full_path.exists():
|
||||
return None
|
||||
|
||||
stat = full_path.stat()
|
||||
return {
|
||||
"path": path,
|
||||
"name": full_path.name,
|
||||
"is_dir": full_path.is_dir(),
|
||||
"size": stat.st_size if full_path.is_file() else 0,
|
||||
"modified": stat.st_mtime,
|
||||
"created": stat.st_ctime
|
||||
}
|
||||
|
||||
def create_directory(self, path, **kwargs):
|
||||
"""创建目录"""
|
||||
full_path = self.root_folder / path.lstrip('/')
|
||||
full_path.mkdir(parents=True, exist_ok=True)
|
||||
return True
|
||||
|
||||
def delete_file(self, path):
|
||||
"""删除文件"""
|
||||
full_path = self.root_folder / path.lstrip('/')
|
||||
|
||||
if full_path.is_file():
|
||||
full_path.unlink()
|
||||
elif full_path.is_dir():
|
||||
import shutil
|
||||
shutil.rmtree(full_path)
|
||||
|
||||
return True
|
||||
|
||||
def move_file(self, src_path, dest_path):
|
||||
"""移动文件"""
|
||||
src_full = self.root_folder / src_path.lstrip('/')
|
||||
dest_full = self.root_folder / dest_path.lstrip('/')
|
||||
|
||||
# 确保目标目录存在
|
||||
dest_full.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
import shutil
|
||||
shutil.move(str(src_full), str(dest_full))
|
||||
return True
|
||||
|
||||
def copy_file(self, src_path, dest_path):
|
||||
"""复制文件"""
|
||||
src_full = self.root_folder / src_path.lstrip('/')
|
||||
dest_full = self.root_folder / dest_path.lstrip('/')
|
||||
|
||||
# 确保目标目录存在
|
||||
dest_full.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
import shutil
|
||||
if src_full.is_file():
|
||||
shutil.copy2(str(src_full), str(dest_full))
|
||||
elif src_full.is_dir():
|
||||
shutil.copytree(str(src_full), str(dest_full))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def start_webdav_server(host="0.0.0.0", port=8090):
|
||||
"""
|
||||
启动独立的 WebDAV 服务器
|
||||
|
||||
Args:
|
||||
host: 主机地址
|
||||
port: 端口号
|
||||
"""
|
||||
app = create_webdav_app()
|
||||
|
||||
# 修改配置,使用不同端口避免冲突
|
||||
app.config["host"] = host
|
||||
app.config["port"] = port
|
||||
|
||||
print(f"Starting WebDAV server on http://{host}:{port}/webdav")
|
||||
print(f"Projects directory: {Path.cwd()}/projects")
|
||||
|
||||
# 启动服务器
|
||||
from wsgidav.server.run_server import run_server
|
||||
run_server(app)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
start_webdav_server()
|
||||
Loading…
Reference in New Issue
Block a user