add public

This commit is contained in:
朱潮 2025-11-08 20:23:04 +08:00
parent 986dcaf5ef
commit d5a989860c
12 changed files with 3115 additions and 370 deletions

1
.gitignore vendored
View File

@ -3,6 +3,5 @@ projects/*
workspace
__pycache__
*/__pycache__
public
models
queue_data

161
FILE_MANAGER_README.md Normal file
View 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
---
**享受高效的文件管理体验!** 🎉

View File

@ -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
View 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功能已完全移除项目现在拥有更现代、更易用的文件管理系统

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -25,7 +25,6 @@ dependencies = [
"openpyxl>=3.0.0",
"xlrd>=2.0.0",
"chardet>=5.0.0",
"wsgidav>=4.0.0",
]

View File

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

View File

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

View File

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