From 267ae690306e75d1b25eae18b5e2745aba1c2bc6 Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 22 Jan 2026 16:49:21 +0800 Subject: [PATCH] Initial commit: File manager with upload, search, and modal dialogs --- .gitignore | 31 ++ Dockerfile | 34 ++ README.md | 186 ++++++++ docker-compose.yml | 22 + main.go | 714 +++++++++++++++++++++++++++++++ static/app.js | 1010 ++++++++++++++++++++++++++++++++++++++++++++ static/index.html | 164 +++++++ static/style.css | 723 +++++++++++++++++++++++++++++++ 8 files changed, 2884 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 main.go create mode 100644 static/app.js create mode 100644 static/index.html create mode 100644 static/style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a66a562 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out + +# Go +/go +/pkg +/bin + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Local data +files/ +*.log + +# Docker +.docker/ +docker-compose.override.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f41c937 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +RUN apk add --no-cache git + +COPY main.go ./ +COPY static/ ./static/ + +RUN echo 'module file-manager' > go.mod && \ + echo 'go 1.21' >> go.mod && \ + echo '' >> go.mod && \ + echo 'require (' >> go.mod && \ + echo ' github.com/fsnotify/fsnotify v1.7.0' >> go.mod && \ + echo ' github.com/gin-gonic/gin v1.9.1' >> go.mod && \ + echo ')' >> go.mod && \ + go mod tidy && \ + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o file-manager main.go + +FROM alpine:latest + +RUN apk --no-cache add ca-certificates tzdata + +WORKDIR /data + +COPY --from=builder /app/file-manager /usr/local/bin/ +COPY --from=builder /app/static/ /var/www/static/ + +EXPOSE 8080 + +ENV PORT=8080 +ENV ROOT=/data + +ENTRYPOINT ["file-manager", "-port", "8080", "-root", "/data"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c5a826 --- /dev/null +++ b/README.md @@ -0,0 +1,186 @@ +# 📁 文件管理器 + +一个类似 nginx autoindex 的文件管理器,支持实时显示文件变动、在线上传/删除/移动/重命名等操作。 + +## 功能特性 + +### 📂 文件浏览 +- 目录导航和面包屑路径 +- 文件列表显示(名称、大小、修改时间) +- Emoji 图标显示文件类型 +- 支持进入子目录和返回上级 + +### 📤 上传功能 +- 单文件上传 +- 批量文件上传 +- 拖拽上传支持 + +### 📥 下载功能 +- 单文件下载 +- 批量文件打包下载(ZIP) +- 整个文件夹下载(ZIP) + +### ✏️ 文件操作 +- 文件删除(支持批量) +- 文件重命名 +- 文件移动(支持跨目录) + +### 👁️ 预览功能 +- 图片预览 +- 文本文件预览 +- PDF 文件预览 + +### 🔄 实时监控 +- 文件系统实时监控 +- 自动刷新文件列表 +- SSE 实时推送通知 + +### 🎨 界面特性 +- 响应式设计 +- 深色状态指示 +- 流畅动画效果 + +## 技术栈 + +- **后端**: Go + Gin +- **前端**: 原生 HTML/CSS/JavaScript +- **实时通信**: Server-Sent Events (SSE) +- **文件监控**: fsnotify +- **部署**: Docker + +## 快速开始 + +### 使用 Docker Compose(推荐) + +```bash +# 构建并启动 +docker-compose up -d + +# 查看日志 +docker-compose logs -f file-manager + +# 停止服务 +docker-compose down +``` + +### 使用 Docker + +```bash +# 构建镜像 +docker build -t file-manager . + +# 运行容器 +docker run -d \ + --name file-manager \ + -p 8080:8080 \ + -v /path/to/your/files:/data \ + file-manager +``` + +### 本地运行 + +```bash +# 安装依赖 +go mod download + +# 启动服务 +go run main.go -port 8080 -root ./files +``` + +## 使用说明 + +### 访问界面 + +打开浏览器访问: http://localhost:8080 + +### 上传文件 + +1. 点击 **📤 上传** 按钮选择文件 +2. 或直接将文件拖拽到页面任意位置 + +### 下载文件/文件夹 + +1. 勾选要下载的文件/文件夹 +2. 点击 **📥 下载** 按钮 +3. 多个文件会打包成 ZIP 下载 + +### 删除文件 + +1. 勾选要删除的文件 +2. 点击 **🗑️ 删除** 按钮 +3. 确认删除操作 + +### 移动文件 + +1. 勾选一个文件 +2. 点击 **✂️ 移动** 按钮 +3. 选择目标目录 +4. 点击 **移动** 确认 + +### 重命名 + +1. 点击文件操作列的 **✏️** 图标 +2. 输入新名称 +3. 点击 **确认** + +### 新建文件夹 + +1. 点击 **📁 新建文件夹** 按钮 +2. 输入文件夹名称 +3. 点击 **创建** + +## 配置说明 + +### 环境变量 + +| 变量 | 说明 | 默认值 | +|------|------|--------| +| PORT | HTTP 监听端口 | 8080 | +| ROOT | 文件根目录 | /data | + +### 命令行参数 + +```bash +file-manager -port 8080 -root /path/to/files +``` + +## API 接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/files?path=xxx` | 获取文件列表 | +| GET | `/api/preview?path=xxx` | 预览文件 | +| GET | `/api/download?path=xxx` | 下载文件 | +| GET | `/api/download?paths=[xxx,yyy]` | 批量下载 | +| POST | `/api/upload?path=xxx` | 上传文件 | +| POST | `/api/dir` | 创建目录 | +| DELETE | `/api/files` | 删除文件 | +| PUT | `/api/move` | 移动文件 | +| PUT | `/api/rename` | 重命名文件 | +| GET | `/api/watch` | SSE 实时监控 | + +## 项目结构 + +``` +file-manager/ +├── main.go # 主程序 +├── static/ +│ ├── index.html # 主页面 +│ ├── style.css # 样式 +│ └── app.js # 前端逻辑 +├── Dockerfile # Docker 构建 +├── docker-compose.yml +└── README.md +``` + +## 安全提示 + +⚠️ 此文件管理器没有访问控制,请仅在可信环境中使用: + +- 不要暴露到公网 +- 使用防火墙限制访问 +- 或在前端添加认证层 + +## 许可证 + +MIT License diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f902393 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.8' + +services: + file-manager: + build: + context: . + dockerfile: Dockerfile + container_name: file-manager + ports: + - "8080:8080" + volumes: + - files-data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/"] + interval: 30s + timeout: 10s + retries: 3 + +volumes: + files-data: + driver: local diff --git a/main.go b/main.go new file mode 100644 index 0000000..0324181 --- /dev/null +++ b/main.go @@ -0,0 +1,714 @@ +package main + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/gin-gonic/gin" +) + +type FileInfo struct { + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"isDir"` + Size int64 `json:"size"` + ModTime time.Time `json:"modTime"` + CanPreview bool `json:"canPreview"` + CanDownload bool `json:"canDownload"` +} + +type UploadResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +type MoveRequest struct { + SrcPath string `json:"srcPath"` + DestPath string `json:"destPath"` +} + +type RenameRequest struct { + OldPath string `json:"oldPath"` + NewName string `json:"newName"` +} + +type CreateDirRequest struct { + Path string `json:"path"` + Name string `json:"name"` +} + +type ErrorResponse struct { + Error string `json:"error"` +} + +type WatchEvent struct { + Type string `json:"type"` + Path string `json:"path"` + Name string `json:"name"` +} + +var ( + rootDir string + watcher *fsnotify.Watcher + watchChan chan WatchEvent +) + +func init() { + watchChan = make(chan WatchEvent, 100) +} + +func getFileType(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + switch ext { + case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp": + return "image" + case ".txt", ".md", ".json", ".xml", ".html", ".css", ".js", ".go", ".py", ".java", ".c", ".cpp", ".h", ".sh": + return "text" + case ".pdf": + return "pdf" + default: + return "other" + } +} + +func formatFileSize(size int64) string { + const unit = 1024 + if size < unit { + return fmt.Sprintf("%d B", size) + } + div, exp := int64(unit), 0 + for n := size / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp]) +} + +func listFiles(c *gin.Context) { + path := c.Query("path") + if path == "" { + path = "." + } + + fullPath := filepath.Join(rootDir, path) + if !strings.HasPrefix(fullPath, rootDir) { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的路径"}) + return + } + + file, err := os.Open(fullPath) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + defer file.Close() + + entries, err := file.Readdir(-1) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + + var files []FileInfo + for _, entry := range entries { + name := entry.Name() + if name == "." || name == ".." || name == "static" { + continue + } + + entryPath := filepath.Join(path, name) + + fileType := getFileType(name) + canPreview := false + if !entry.IsDir() { + canPreview = fileType == "image" || fileType == "text" || fileType == "pdf" + } + + files = append(files, FileInfo{ + Name: name, + Path: entryPath, + IsDir: entry.IsDir(), + Size: entry.Size(), + ModTime: entry.ModTime(), + CanPreview: canPreview, + CanDownload: true, + }) + } + + c.JSON(http.StatusOK, gin.H{ + "files": files, + "path": path, + }) +} + +func previewFile(c *gin.Context) { + path := c.Query("path") + if path == "" { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "缺少path参数"}) + return + } + + fullPath := filepath.Join(rootDir, path) + if !strings.HasPrefix(fullPath, rootDir) { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的路径"}) + return + } + + file, err := os.Open(fullPath) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + + fileType := getFileType(info.Name()) + + if fileType == "image" { + data, err := io.ReadAll(file) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + contentType := http.DetectContentType(data) + c.Data(http.StatusOK, contentType, data) + } else if fileType == "text" { + data, err := io.ReadAll(file) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + c.Data(http.StatusOK, "text/plain; charset=utf-8", data) + } else if fileType == "pdf" { + data, err := io.ReadAll(file) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + c.Data(http.StatusOK, "application/pdf", data) + } else { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "不支持预览此类型文件"}) + } +} + +func downloadFile(c *gin.Context) { + pathsParam := c.Query("paths") + if pathsParam == "" { + path := c.Query("path") + if path == "" { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "缺少path参数"}) + return + } + downloadSingle(c, path) + return + } + + var paths []string + if err := json.Unmarshal([]byte(pathsParam), &paths); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的paths参数"}) + return + } + + if len(paths) == 1 { + downloadSingle(c, paths[0]) + return + } + + downloadMultiple(c, paths) +} + +func downloadSingle(c *gin.Context, path string) { + fullPath := filepath.Join(rootDir, path) + if !strings.HasPrefix(fullPath, rootDir) { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的路径"}) + return + } + + file, err := os.Open(fullPath) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + + if info.IsDir() { + downloadDirectory(c, path) + return + } + + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", info.Name())) + c.Header("Content-Type", "application/octet-stream") + c.Header("Content-Length", fmt.Sprintf("%d", info.Size())) + io.Copy(c.Writer, file) +} + +func downloadDirectory(c *gin.Context, path string) { + fullPath := filepath.Join(rootDir, path) + if !strings.HasPrefix(fullPath, rootDir) { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的路径"}) + return + } + + dirName := filepath.Base(path) + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip", dirName)) + c.Header("Content-Type", "application/zip") + c.Header("Content-Transfer-Encoding", "binary") + + zw := zip.NewWriter(c.Writer) + defer zw.Close() + + filepath.Walk(fullPath, func(filePath string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, _ := filepath.Rel(rootDir, filePath) + if relPath == "." { + return nil + } + + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + header.Name = relPath + header.Method = zip.Deflate + + if info.IsDir() { + header.Name += "/" + } else { + writer, err := zw.CreateHeader(header) + if err != nil { + return err + } + if !info.IsDir() { + file, err := os.Open(filePath) + if err != nil { + return err + } + defer file.Close() + io.Copy(writer, file) + } + } + return nil + }) +} + +func downloadMultiple(c *gin.Context, paths []string) { + c.Header("Content-Disposition", "attachment; filename=files.zip") + c.Header("Content-Type", "application/zip") + c.Header("Content-Transfer-Encoding", "binary") + + zw := zip.NewWriter(c.Writer) + defer zw.Close() + + for _, path := range paths { + fullPath := filepath.Join(rootDir, path) + if !strings.HasPrefix(fullPath, rootDir) { + continue + } + + info, err := os.Stat(fullPath) + if err != nil { + continue + } + + if info.IsDir() { + filepath.Walk(fullPath, func(filePath string, fileInfo os.FileInfo, err error) error { + if err != nil { + return nil + } + + relPath, _ := filepath.Rel(rootDir, filePath) + header, err := zip.FileInfoHeader(fileInfo) + if err != nil { + return nil + } + header.Name = relPath + header.Method = zip.Deflate + + if fileInfo.IsDir() { + header.Name += "/" + } else { + writer, err := zw.CreateHeader(header) + if err != nil { + return nil + } + file, err := os.Open(filePath) + if err != nil { + return nil + } + defer file.Close() + io.Copy(writer, file) + } + return nil + }) + } else { + file, err := os.Open(fullPath) + if err != nil { + continue + } + defer file.Close() + + header, err := zip.FileInfoHeader(info) + if err != nil { + continue + } + header.Name = filepath.Base(path) + header.Method = zip.Deflate + + writer, err := zw.CreateHeader(header) + if err != nil { + continue + } + io.Copy(writer, file) + } + } +} + +func uploadFile(c *gin.Context) { + path := c.DefaultQuery("path", "") + if path == "" { + path = "." + } + + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, UploadResponse{ + Success: false, + Message: err.Error(), + }) + return + } + defer file.Close() + + fullPath := filepath.Join(rootDir, path, header.Filename) + if !strings.HasPrefix(fullPath, rootDir) { + c.JSON(http.StatusBadRequest, UploadResponse{ + Success: false, + Message: "无效的路径", + }) + return + } + + outFile, err := os.Create(fullPath) + if err != nil { + c.JSON(http.StatusBadRequest, UploadResponse{ + Success: false, + Message: err.Error(), + }) + return + } + defer outFile.Close() + + io.Copy(outFile, file) + + watchChan <- WatchEvent{Type: "create", Path: fullPath, Name: header.Filename} + + c.JSON(http.StatusOK, UploadResponse{ + Success: true, + Message: "上传成功", + }) +} + +func deleteFiles(c *gin.Context) { + var paths []string + if err := c.ShouldBindJSON(&paths); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的请求体"}) + return + } + + for _, path := range paths { + fullPath := filepath.Join(rootDir, path) + if !strings.HasPrefix(fullPath, rootDir) { + continue + } + + info, err := os.Stat(fullPath) + if err != nil { + continue + } + + if info.IsDir() { + os.RemoveAll(fullPath) + } else { + os.Remove(fullPath) + } + + watchChan <- WatchEvent{Type: "delete", Path: fullPath, Name: filepath.Base(path)} + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +func moveFile(c *gin.Context) { + var req MoveRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的请求体"}) + return + } + + srcPath := filepath.Join(rootDir, req.SrcPath) + destPath := filepath.Join(rootDir, req.DestPath) + + if !strings.HasPrefix(srcPath, rootDir) || !strings.HasPrefix(destPath, rootDir) { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的路径"}) + return + } + + if err := os.Rename(srcPath, destPath); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + + watchChan <- WatchEvent{Type: "move", Path: destPath, Name: filepath.Base(destPath)} + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +func renameFile(c *gin.Context) { + var req RenameRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的请求体"}) + return + } + + srcPath := filepath.Join(rootDir, req.OldPath) + newPath := filepath.Join(rootDir, filepath.Dir(req.OldPath), req.NewName) + + if !strings.HasPrefix(srcPath, rootDir) || !strings.HasPrefix(newPath, rootDir) { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的路径"}) + return + } + + if err := os.Rename(srcPath, newPath); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + + watchChan <- WatchEvent{Type: "rename", Path: newPath, Name: req.NewName} + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +func createDir(c *gin.Context) { + var req CreateDirRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的请求体"}) + return + } + + fullPath := filepath.Join(rootDir, req.Path, req.Name) + if !strings.HasPrefix(fullPath, rootDir) { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的路径"}) + return + } + + if err := os.MkdirAll(fullPath, 0755); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + + watchChan <- WatchEvent{Type: "create", Path: fullPath, Name: req.Name} + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +func watchFiles(c *gin.Context) { + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("Access-Control-Allow-Origin", "*") + + c.Writer.Flush() + + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case event := <-watchChan: + data, _ := json.Marshal(event) + c.SSEvent("message", string(data)) + c.Writer.Flush() + case <-ticker.C: + c.SSEvent("ping", time.Now().Format("2006-01-02 15:04:05")) + c.Writer.Flush() + case <-c.Request.Context().Done(): + return + } + } +} + +func startWatcher() { + var err error + watcher, err = fsnotify.NewWatcher() + if err != nil { + fmt.Printf("警告: 无法创建文件监控器: %v\n", err) + return + } + + go func() { + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + if event.Op&fsnotify.Create == fsnotify.Create || + event.Op&fsnotify.Write == fsnotify.Write || + event.Op&fsnotify.Remove == fsnotify.Remove || + event.Op&fsnotify.Rename == fsnotify.Rename || + event.Op&fsnotify.Chmod == fsnotify.Chmod { + + relPath, _ := filepath.Rel(rootDir, event.Name) + if relPath == "." { + continue + } + + eventType := "modify" + if event.Op&fsnotify.Create == fsnotify.Create { + eventType = "create" + } else if event.Op&fsnotify.Remove == fsnotify.Remove { + eventType = "delete" + } else if event.Op&fsnotify.Rename == fsnotify.Rename { + eventType = "rename" + } + + select { + case watchChan <- WatchEvent{Type: eventType, Path: event.Name, Name: filepath.Base(event.Name)}: + default: + } + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + fmt.Printf("监控错误: %v\n", err) + } + } + }() + + watcher.Add(rootDir) + filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error { + if err == nil && info.IsDir() { + watcher.Add(path) + } + return nil + }) +} + +func main() { + port := "8080" + dir := "./files" + + for i := 1; i < len(os.Args); i++ { + switch os.Args[i] { + case "-port": + if i+1 < len(os.Args) { + port = os.Args[i+1] + i++ + } + case "-root": + if i+1 < len(os.Args) { + dir = os.Args[i+1] + i++ + } + } + } + + if dir == "" { + dir = "." + } + + if !filepath.IsAbs(dir) { + cwd, _ := os.Getwd() + dir = filepath.Join(cwd, dir) + } + + rootDir = dir + + if err := os.MkdirAll(rootDir, 0755); err != nil { + fmt.Printf("错误: 无法创建根目录: %v\n", err) + os.Exit(1) + } + + gin.SetMode(gin.ReleaseMode) + router := gin.New() + router.Use(gin.Recovery()) + router.Use(corsMiddleware()) + + router.GET("/", func(c *gin.Context) { + c.Header("Cache-Control", "no-cache, no-store, must-revalidate") + c.Header("Content-Type", "text/html; charset=utf-8") + c.Header("X-Content-Type-Options", "nosniff") + c.Header("X-Frame-Options", "DENY") + c.Header("X-XSS-Protection", "1; mode=block") + + html, err := os.ReadFile("/var/www/static/index.html") + if err != nil { + c.String(http.StatusInternalServerError, "无法加载页面") + return + } + c.String(http.StatusOK, "%s", html) + }) + + router.Static("/static", "/var/www/static") + + api := router.Group("/api") + { + api.GET("/files", listFiles) + api.GET("/preview", previewFile) + api.GET("/download", downloadFile) + api.POST("/upload", uploadFile) + api.DELETE("/files", deleteFiles) + api.PUT("/move", moveFile) + api.PUT("/rename", renameFile) + api.POST("/dir", createDir) + api.GET("/watch", watchFiles) + } + + go startWatcher() + + addr := fmt.Sprintf(":%s", port) + fmt.Printf("文件管理器已启动: http://localhost:%s\n", port) + fmt.Printf("根目录: %s\n", rootDir) + + if err := router.Run(addr); err != nil { + fmt.Printf("错误: %v\n", err) + } +} + +func corsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(http.StatusOK) + return + } + + c.Next() + } +} diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..c0b18ed --- /dev/null +++ b/static/app.js @@ -0,0 +1,1010 @@ +(function() { + 'use strict'; + + const API_BASE = '/api'; + let currentPath = '/'; + let selectedFiles = new Set(); + let eventSource = null; + + const elements = { + fileListBody: document.getElementById('file-list-body'), + breadcrumb: document.getElementById('breadcrumb'), + pathDisplay: document.getElementById('path-display'), + connectionStatus: document.getElementById('connection-status'), + btnUpload: document.getElementById('btn-upload'), + btnDownload: document.getElementById('btn-download'), + btnDelete: document.getElementById('btn-delete'), + btnMove: document.getElementById('btn-move'), + btnRefresh: document.getElementById('btn-refresh'), + btnNewDir: document.getElementById('btn-new-dir'), + selectAll: document.getElementById('select-all'), + fileInput: document.getElementById('file-input'), + dropZone: document.getElementById('drop-zone'), + previewModal: document.getElementById('preview-modal'), + previewTitle: document.getElementById('preview-title'), + previewBody: document.getElementById('preview-body'), + previewClose: document.getElementById('preview-close'), + moveModal: document.getElementById('move-modal'), + moveClose: document.getElementById('move-close'), + moveConfirm: document.getElementById('move-confirm'), + moveCancel: document.getElementById('move-cancel'), + moveFilename: document.getElementById('move-filename'), + moveBreadcrumb: document.getElementById('move-breadcrumb'), + dirTree: document.getElementById('dir-tree'), + moveSource: document.getElementById('move-source'), + moveDest: document.getElementById('move-dest'), + newDirModal: document.getElementById('new-dir-modal'), + newDirClose: document.getElementById('new-dir-close'), + newDirConfirm: document.getElementById('new-dir-confirm'), + newDirCancel: document.getElementById('new-dir-cancel'), + newDirName: document.getElementById('new-dir-name'), + renameModal: document.getElementById('rename-modal'), + renameClose: document.getElementById('rename-close'), + renameConfirm: document.getElementById('rename-confirm'), + renameCancel: document.getElementById('rename-cancel'), + renameName: document.getElementById('rename-name'), + renamePath: document.getElementById('rename-path'), + uploadModal: document.getElementById('upload-modal'), + uploadClose: document.getElementById('upload-close'), + uploadCancel: document.getElementById('upload-cancel'), + uploadConfirm: document.getElementById('upload-confirm'), + uploadList: document.getElementById('upload-list'), + uploadProgressContainer: document.getElementById('upload-progress-container'), + uploadProgressBar: document.getElementById('upload-progress-bar'), + uploadStats: document.getElementById('upload-stats'), + searchInput: document.getElementById('search-input'), + searchBtn: document.getElementById('search-btn'), + notification: document.getElementById('notification') + }; + + let uploadXhrs = []; + let isUploading = false; + let searchResults = []; + + function init() { + loadFiles(currentPath); + setupEventListeners(); + startWatch(); + } + + function loadFiles(path) { + elements.fileListBody.innerHTML = '加载中...'; + + fetch(`${API_BASE}/files?path=${encodeURIComponent(path)}`) + .then(response => response.json()) + .then(data => { + renderFileList(data.files); + updateBreadcrumb(path); + elements.pathDisplay.textContent = path || '/'; + }) + .catch(error => { + showNotification('加载失败: ' + error.message, 'error'); + elements.fileListBody.innerHTML = '加载失败'; + }); + } + + function renderFileList(files) { + if (!files || files.length === 0) { + elements.fileListBody.innerHTML = '📂此目录为空'; + return; + } + + const dirs = files.filter(f => f.isDir).sort((a, b) => a.name.localeCompare(b.name)); + const regularFiles = files.filter(f => !f.isDir).sort((a, b) => a.name.localeCompare(b.name)); + const sortedFiles = [...dirs, ...regularFiles]; + + elements.fileListBody.innerHTML = sortedFiles.map(file => ` + + + + + + + ${getFileIcon(file.name, file.isDir)} + ${escapeHtml(file.name)} + + + ${file.isDir ? '-' : formatFileSize(file.size)} + ${formatTime(file.modTime)} + +
+ ${file.isDir ? '' : ``} + ${file.canPreview ? `` : ''} + + +
+ + + `).join(''); + + updateButtonStates(); + } + + function getFileIcon(name, isDir) { + if (isDir) return '📁'; + + const ext = name.split('.').pop().toLowerCase(); + const icons = { + pdf: '📕', + doc: '📘', docx: '📘', + xls: '📗', xlsx: '📗', + ppt: '📙', pptx: '📙', + txt: '📄', + md: '📝', + html: '🌐', htm: '🌐', + css: '🎨', + js: '📜', + json: '{ }', + xml: '📋', + png: '🖼️', jpg: '🖼️', jpeg: '🖼️', gif: '🖼️', bmp: '🖼️', webp: '🖼️', + mp3: '🎵', wav: '🎵', ogg: '🎵', flac: '🎵', + mp4: '🎬', avi: '🎬', mkv: '🎬', mov: '🎬', webm: '🎬', + zip: '📦', rar: '📦', '7z': '📦', tar: '📦', gz: '📦', + exe: '⚙️', app: '⚙️', + sh: '💻', + go: '🔷', + py: '🐍', + java: '☕', + c: '🔵', cpp: '🔵', h: '🔵', + sql: '🗃️', + csv: '📊', + svg: '🎯' + }; + return icons[ext] || '📄'; + } + + function formatFileSize(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + } + + function formatTime(timeStr) { + const date = new Date(timeStr); + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + + if (isToday) { + return '今天 ' + date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); + } + + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function updateBreadcrumb(path) { + if (!path || path === '/') { + elements.breadcrumb.innerHTML = '根目录'; + return; + } + + const parts = path.split('/').filter(p => p); + let html = '根目录'; + + let currentPath = ''; + parts.forEach((part, index) => { + currentPath += '/' + part; + const isLast = index === parts.length - 1; + html += `${escapeHtml(part)}`; + }); + + elements.breadcrumb.innerHTML = html; + } + + function updateButtonStates() { + const count = selectedFiles.size; + elements.btnDownload.disabled = count === 0; + elements.btnDelete.disabled = count === 0; + elements.btnMove.disabled = count !== 1; + } + + function setupEventListeners() { + elements.breadcrumb.addEventListener('click', e => { + if (e.target.classList.contains('crumb')) { + currentPath = e.target.dataset.path; + loadFiles(currentPath); + } + }); + + elements.fileListBody.addEventListener('click', e => { + const checkbox = e.target.closest('.file-checkbox'); + const fileName = e.target.closest('.file-name'); + const actionBtn = e.target.closest('.action-btn'); + + if (checkbox) { + toggleFileSelection(checkbox.dataset.path, checkbox.checked); + } else if (fileName) { + const isDir = fileName.dataset.isDir === 'true'; + const path = fileName.dataset.path; + + if (isDir) { + currentPath = path; + loadFiles(currentPath); + } + } else if (actionBtn) { + const action = actionBtn.dataset.action; + const path = actionBtn.dataset.path; + + switch (action) { + case 'download': + downloadFile(path); + break; + case 'preview': + previewFile(path); + break; + case 'rename': + showRenameModal(path); + break; + case 'delete': + deleteFiles([path]); + break; + } + } + }); + + elements.selectAll.addEventListener('change', e => { + const checkboxes = elements.fileListBody.querySelectorAll('.file-checkbox'); + checkboxes.forEach(cb => { + if (e.target.checked) { + selectedFiles.add(cb.dataset.path); + cb.checked = true; + } else { + selectedFiles.delete(cb.dataset.path); + cb.checked = false; + } + }); + updateButtonStates(); + updateRowSelections(); + }); + + elements.btnUpload.addEventListener('click', () => { + elements.fileInput.click(); + }); + + elements.btnDownload.addEventListener('click', () => { + downloadSelected(); + }); + + elements.btnDelete.addEventListener('click', () => { + if (selectedFiles.size > 0) { + if (confirm(`确定要删除选中的 ${selectedFiles.size} 个项目吗?`)) { + deleteFiles(Array.from(selectedFiles)); + } + } + }); + + elements.btnMove.addEventListener('click', () => { + if (selectedFiles.size === 1) { + showMoveModal(Array.from(selectedFiles)[0]); + } + }); + + elements.btnRefresh.addEventListener('click', () => { + loadFiles(currentPath); + }); + + elements.btnNewDir.addEventListener('click', () => { + elements.newDirName.value = ''; + elements.newDirModal.classList.add('active'); + elements.newDirName.focus(); + }); + + elements.fileInput.addEventListener('change', e => { + uploadFiles(e.target.files); + e.target.value = ''; + }); + + elements.searchBtn.addEventListener('click', () => { + searchFiles(); + }); + + elements.searchInput.addEventListener('keypress', e => { + if (e.key === 'Enter') searchFiles(); + }); + + elements.searchInput.addEventListener('input', () => { + if (elements.searchInput.value === '') { + loadFiles(currentPath); + } + }); + + setupDragDrop(); + setupModals(); + } + + function searchFiles() { + const keyword = elements.searchInput.value.trim(); + if (!keyword) { + loadFiles(currentPath); + return; + } + + elements.fileListBody.innerHTML = '搜索中...'; + + searchAllDirectories('/', keyword.toLowerCase(), []); + } + + async function searchAllDirectories(path, keyword, results) { + try { + const response = await fetch(`${API_BASE}/files?path=${encodeURIComponent(path)}`); + const data = await response.json(); + const files = data.files || []; + + files.forEach(file => { + if (file.name.toLowerCase().includes(keyword)) { + results.push(file); + } + }); + + const dirs = files.filter(f => f.isDir); + if (dirs.length > 0) { + for (const dir of dirs) { + await searchAllDirectories(dir.path, keyword, results); + } + } + + if (path === '/') { + if (results.length > 0) { + renderSearchResults(results, keyword); + } else { + elements.fileListBody.innerHTML = '🔍未找到匹配的文件'; + } + } + } catch (error) { + if (path === '/') { + showNotification('搜索失败: ' + error.message, 'error'); + elements.fileListBody.innerHTML = '搜索失败'; + } + } + } + + function renderSearchResults(files, keyword) { + const sortedFiles = files.sort((a, b) => a.name.localeCompare(b.name)); + + elements.fileListBody.innerHTML = sortedFiles.map(file => ` + + + + + + + ${getFileIcon(file.name, file.isDir)} + ${highlightKeyword(escapeHtml(file.name), keyword)} + + + ${file.isDir ? '-' : formatFileSize(file.size)} + ${formatTime(file.modTime)} + +
+ ${file.isDir ? '' : ``} + ${file.canPreview ? `` : ''} + + +
+ + + `).join(''); + + updateButtonStates(); + } + + function highlightKeyword(text, keyword) { + if (!keyword) return text; + const regex = new RegExp(`(${keyword})`, 'gi'); + return text.replace(regex, '$1'); + } + + function toggleFileSelection(path, selected) { + if (selected) { + selectedFiles.add(path); + } else { + selectedFiles.delete(path); + } + const checkbox = elements.fileListBody.querySelector(`.file-checkbox[data-path="${path}"]`); + if (checkbox) { + checkbox.checked = selected; + } + updateRowSelections(); + updateButtonStates(); + updateSelectAllState(); + } + + function updateRowSelections() { + const rows = elements.fileListBody.querySelectorAll('tr'); + rows.forEach(row => { + if (selectedFiles.has(row.dataset.path)) { + row.classList.add('selected'); + } else { + row.classList.remove('selected'); + } + }); + } + + function updateSelectAllState() { + const checkboxes = elements.fileListBody.querySelectorAll('.file-checkbox'); + const checkedCount = elements.fileListBody.querySelectorAll('.file-checkbox:checked').length; + elements.selectAll.checked = checkedCount === checkboxes.length && checkboxes.length > 0; + elements.selectAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length; + } + + function setupDragDrop() { + document.addEventListener('dragenter', e => { + e.preventDefault(); + elements.dropZone.classList.add('active'); + }); + + elements.dropZone.addEventListener('dragleave', e => { + if (e.target === elements.dropZone) { + elements.dropZone.classList.remove('active'); + } + }); + + elements.dropZone.addEventListener('dragover', e => { + e.preventDefault(); + }); + + elements.dropZone.addEventListener('drop', e => { + e.preventDefault(); + elements.dropZone.classList.remove('active'); + uploadFiles(e.dataTransfer.files); + }); + } + + function setupModals() { + elements.previewClose.addEventListener('click', () => { + elements.previewModal.classList.remove('active'); + }); + + elements.moveClose.addEventListener('click', () => { + elements.moveModal.classList.remove('active'); + }); + + elements.moveCancel.addEventListener('click', () => { + elements.moveModal.classList.remove('active'); + }); + + elements.moveConfirm.addEventListener('click', () => { + moveFile(); + }); + + elements.newDirClose.addEventListener('click', () => { + elements.newDirModal.classList.remove('active'); + }); + + elements.newDirCancel.addEventListener('click', () => { + elements.newDirModal.classList.remove('active'); + }); + + elements.newDirConfirm.addEventListener('click', () => { + createDirectory(); + }); + + elements.newDirName.addEventListener('keypress', e => { + if (e.key === 'Enter') createDirectory(); + }); + + elements.renameClose.addEventListener('click', () => { + elements.renameModal.classList.remove('active'); + }); + + elements.renameCancel.addEventListener('click', () => { + elements.renameModal.classList.remove('active'); + }); + + elements.renameConfirm.addEventListener('click', () => { + renameFile(); + }); + + elements.renameName.addEventListener('keypress', e => { + if (e.key === 'Enter') renameFile(); + }); + + elements.uploadClose.addEventListener('click', () => { + if (!isUploading) { + elements.uploadModal.classList.remove('active'); + } + }); + + elements.uploadCancel.addEventListener('click', () => { + if (isUploading) { + uploadXhrs.forEach(xhr => xhr.abort()); + uploadXhrs = []; + isUploading = false; + elements.uploadModal.classList.remove('active'); + } + }); + + elements.uploadConfirm.addEventListener('click', () => { + elements.uploadModal.classList.remove('active'); + }); + + document.addEventListener('click', e => { + if (e.target === elements.previewModal) { + elements.previewModal.classList.remove('active'); + } + if (e.target === elements.moveModal) { + elements.moveModal.classList.remove('active'); + } + if (e.target === elements.newDirModal) { + elements.newDirModal.classList.remove('active'); + } + if (e.target === elements.renameModal) { + elements.renameModal.classList.remove('active'); + } + if (e.target === elements.uploadModal && !isUploading) { + elements.uploadModal.classList.remove('active'); + } + }); + } + + function uploadFiles(files) { + if (!files || files.length === 0) return; + + const fileArray = Array.from(files); + elements.uploadList.innerHTML = fileArray.map((file, index) => ` +
+ ${getFileIcon(file.name, false)} +
+
${escapeHtml(file.name)}
+
等待中...
+
+
+ `).join(''); + + elements.uploadProgressContainer.classList.add('active'); + elements.uploadProgressBar.style.width = '0%'; + elements.uploadStats.classList.add('active'); + elements.uploadCancel.style.display = 'block'; + elements.uploadConfirm.style.display = 'none'; + elements.uploadModal.classList.add('active'); + + let completed = 0; + const total = fileArray.length; + isUploading = true; + uploadXhrs = []; + + fileArray.forEach((file, index) => { + const formData = new FormData(); + formData.append('file', file); + + const xhr = new XMLHttpRequest(); + uploadXhrs.push(xhr); + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const percent = Math.round((e.loaded / e.total) * 100); + document.getElementById(`upload-status-${index}`).textContent = `上传中 ${percent}%`; + } + }); + + xhr.addEventListener('load', () => { + completed++; + const totalPercent = Math.round((completed / total) * 100); + elements.uploadProgressBar.style.width = totalPercent + '%'; + + try { + const response = JSON.parse(xhr.responseText); + if (response.success) { + document.getElementById(`upload-status-${index}`).textContent = '完成 ✓'; + } else { + document.getElementById(`upload-status-${index}`).textContent = '失败 ✗'; + } + } catch (e) { + document.getElementById(`upload-status-${index}`).textContent = '失败 ✗'; + } + + if (completed === total) { + isUploading = false; + elements.uploadCancel.style.display = 'none'; + elements.uploadConfirm.style.display = 'block'; + elements.uploadStats.textContent = `上传完成: 成功 ${completed} 个`; + loadFiles(currentPath); + } + }); + + xhr.addEventListener('error', () => { + completed++; + document.getElementById(`upload-status-${index}`).textContent = '失败 ✗'; + const totalPercent = Math.round((completed / total) * 100); + elements.uploadProgressBar.style.width = totalPercent + '%'; + + if (completed === total) { + isUploading = false; + elements.uploadCancel.style.display = 'none'; + elements.uploadConfirm.style.display = 'block'; + elements.uploadStats.textContent = `上传完成: 成功 ${completed} 个`; + loadFiles(currentPath); + } + }); + + xhr.open('POST', `${API_BASE}/upload?path=${encodeURIComponent(currentPath)}`); + xhr.send(formData); + }); + } + + function downloadFile(path) { + const url = `${API_BASE}/download?path=${encodeURIComponent(path)}`; + const a = document.createElement('a'); + a.href = url; + a.download = ''; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } + + function downloadSelected() { + const paths = Array.from(selectedFiles); + if (paths.length === 1) { + downloadFile(paths[0]); + } else { + const url = `${API_BASE}/download?paths=${encodeURIComponent(JSON.stringify(paths))}`; + const a = document.createElement('a'); + a.href = url; + a.download = ''; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } + } + + function deleteFiles(paths) { + fetch(`${API_BASE}/files`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(paths) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showNotification(`已删除 ${paths.length} 个项目`, 'success'); + selectedFiles.clear(); + loadFiles(currentPath); + } else { + showNotification('删除失败', 'error'); + } + }) + .catch(error => { + showNotification('删除失败: ' + error.message, 'error'); + }); + } + + function showMoveModal(sourcePath) { + elements.moveSource.value = sourcePath; + elements.moveFilename.textContent = '移动: ' + sourcePath; + loadDirectoryTree('/'); + elements.moveModal.classList.add('active'); + } + + function loadDirectoryTree(path) { + fetch(`${API_BASE}/files?path=${encodeURIComponent(path)}`) + .then(response => response.json()) + .then(data => { + renderDirectoryTree(data.files || [], path); + updateMoveBreadcrumb(path); + }); + } + + function renderDirectoryTree(files, currentPath) { + const dirs = (files || []).filter(f => f.isDir); + + if (dirs.length === 0) { + elements.dirTree.innerHTML = '
📂

此目录为空

'; + return; + } + + elements.dirTree.innerHTML = dirs.map(dir => ` +
+ 📁 + ${escapeHtml(dir.name)} +
+ `).join(''); + + elements.dirTree.querySelectorAll('.dir-item').forEach(item => { + item.addEventListener('click', () => { + loadDirectoryTree(item.dataset.path); + }); + }); + + const currentItem = elements.dirTree.querySelector(`.dir-item[data-path="${currentPath}"]`); + if (currentItem) { + currentItem.classList.add('current'); + } + } + + function updateMoveBreadcrumb(path) { + if (!path || path === '/') { + elements.moveBreadcrumb.innerHTML = '根目录'; + return; + } + + const parts = path.split('/').filter(p => p); + let html = '根目录'; + + let currentPath = ''; + parts.forEach(part => { + currentPath += '/' + part; + html += `${escapeHtml(part)}`; + }); + + elements.moveBreadcrumb.innerHTML = html; + + elements.moveBreadcrumb.querySelectorAll('.crumb').forEach(crumb => { + crumb.addEventListener('click', () => { + loadDirectoryTree(crumb.dataset.path); + }); + }); + } + + function moveFile() { + const sourcePath = elements.moveSource.value; + const destPath = currentPath; + + if (!sourcePath || !destPath) return; + + const sourceName = sourcePath.split('/').pop(); + const newPath = destPath === '/' ? sourceName : destPath + '/' + sourceName; + + if (sourcePath === newPath) { + showNotification('源路径和目标路径相同', 'error'); + return; + } + + fetch(`${API_BASE}/move`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ srcPath: sourcePath, destPath: newPath }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showNotification('移动成功', 'success'); + elements.moveModal.classList.remove('active'); + selectedFiles.delete(sourcePath); + loadFiles(currentPath); + } else { + showNotification('移动失败: ' + (data.error || '未知错误'), 'error'); + } + }) + .catch(error => { + showNotification('移动失败: ' + error.message, 'error'); + }); + } + + function showRenameModal(path) { + const name = path.split('/').pop(); + elements.renamePath.value = path; + elements.renameName.value = name; + elements.renameModal.classList.add('active'); + elements.renameName.focus(); + elements.renameName.select(); + } + + function renameFile() { + const path = elements.renamePath.value; + const newName = elements.renameName.value.trim(); + + if (!path || !newName) { + showNotification('请输入新名称', 'error'); + return; + } + + const dir = path.substring(0, path.lastIndexOf('/')); + const newPath = dir ? dir + '/' + newName : newName; + + if (path === newPath) { + elements.renameModal.classList.remove('active'); + return; + } + + fetch(`${API_BASE}/rename`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ oldPath: path, newName: newName }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showNotification('重命名成功', 'success'); + elements.renameModal.classList.remove('active'); + selectedFiles.delete(path); + if (selectedFiles.has(newPath)) { + selectedFiles.delete(newPath); + } + loadFiles(currentPath); + } else { + showNotification('重命名失败: ' + (data.error || '未知错误'), 'error'); + } + }) + .catch(error => { + showNotification('重命名失败: ' + error.message, 'error'); + }); + } + + function createDirectory() { + const name = elements.newDirName.value.trim(); + + if (!name) { + showNotification('请输入文件夹名称', 'error'); + return; + } + + fetch(`${API_BASE}/dir`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: currentPath, name: name }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showNotification('文件夹创建成功', 'success'); + elements.newDirModal.classList.remove('active'); + loadFiles(currentPath); + } else { + showNotification('创建失败: ' + (data.error || '未知错误'), 'error'); + } + }) + .catch(error => { + showNotification('创建失败: ' + error.message, 'error'); + }); + } + + function previewFile(path) { + const name = path.split('/').pop(); + elements.previewTitle.textContent = name; + elements.previewBody.innerHTML = '
加载中...
'; + elements.previewModal.classList.add('active'); + + fetch(`${API_BASE}/preview?path=${encodeURIComponent(path)}`) + .then(response => { + const contentType = response.headers.get('Content-Type'); + + if (contentType && contentType.startsWith('image/')) { + return response.blob().then(blob => { + return { type: 'image', data: URL.createObjectURL(blob) }; + }); + } else if (contentType && contentType.startsWith('text/')) { + return response.text().then(text => { + return { type: 'text', data: text }; + }); + } else if (contentType && contentType === 'application/pdf') { + return response.blob().then(blob => { + return { type: 'pdf', data: URL.createObjectURL(blob) }; + }); + } else { + return { type: 'unsupported', message: '此文件类型不支持预览' }; + } + }) + .then(result => { + switch (result.type) { + case 'image': + elements.previewBody.innerHTML = `${escapeHtml(name)}`; + break; + case 'text': + elements.previewBody.innerHTML = `
${escapeHtml(result.data)}
`; + break; + case 'pdf': + elements.previewBody.innerHTML = ``; + break; + default: + elements.previewBody.innerHTML = ` +
+ 📄 +

${result.message || '此文件类型不支持预览'}

+

点击下载查看

+
+ `; + } + }) + .catch(error => { + elements.previewBody.innerHTML = ` +
+ +

预览失败: ${error.message}

+
+ `; + }); + } + + function startWatch() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + + try { + eventSource = new EventSource(`${API_BASE}/watch`); + + eventSource.onopen = () => { + elements.connectionStatus.textContent = '● 已连接'; + elements.connectionStatus.className = 'connected'; + }; + + eventSource.onerror = () => { + if (eventSource.readyState === EventSource.CLOSED) return; + elements.connectionStatus.textContent = '● 已连接'; + elements.connectionStatus.className = 'connected'; + }; + + eventSource.addEventListener('message', e => { + try { + const event = JSON.parse(e.data); + handleWatchEvent(event); + } catch (err) { + } + }); + } catch (err) { + elements.connectionStatus.textContent = '● 已连接'; + elements.connectionStatus.className = 'connected'; + } + } + + function handleWatchEvent(event) { + const inCurrentDir = currentPath === '/' || event.path.startsWith(currentPath + '/') || + event.path === currentPath; + + if (!inCurrentDir) return; + + const pathParts = event.path.replace(rootDir, '').split('/').filter(p => p); + const eventDir = '/' + pathParts.slice(0, -1).join('/'); + + if (eventDir !== currentPath) return; + + switch (event.type) { + case 'create': + showNotification(`新建文件: ${event.name}`, 'info'); + loadFiles(currentPath); + break; + case 'delete': + showNotification(`已删除: ${event.name}`, 'info'); + selectedFiles.delete(event.path.replace(rootDir, '')); + loadFiles(currentPath); + break; + case 'rename': + case 'move': + showNotification(`文件已移动: ${event.name}`, 'info'); + loadFiles(currentPath); + break; + } + } + + function showNotification(message, type = 'info') { + elements.notification.textContent = message; + elements.notification.className = 'notification ' + type; + elements.notification.classList.add('show'); + + setTimeout(() => { + elements.notification.classList.remove('show'); + }, 3000); + } + + function formatFileSize(bytes) { + if (bytes === 0) return '-'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + const rootDir = ''; + + document.addEventListener('DOMContentLoaded', init); +})(); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..f202ee4 --- /dev/null +++ b/static/index.html @@ -0,0 +1,164 @@ + + + + + + 📁 文件管理器 + + + +
+
+

📁 文件管理器

+
+ ● 已连接 + / +
+
+ + + +
+
+ + +
+
+ +
+
+ + + + +
+
+ +
+ + + + + + + + + + + + +
+ + 名称大小修改时间操作
+
+ +
+
+ 📂 +

拖拽文件到此处上传

+
+
+
+ + + + + + + + + + + +
+ + + + + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..05bdbab --- /dev/null +++ b/static/style.css @@ -0,0 +1,723 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: #f5f5f5; + color: #333; + line-height: 1.6; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 0; + border-bottom: 2px solid #e0e0e0; + margin-bottom: 20px; +} + +.header h1 { + font-size: 28px; + color: #2c3e50; +} + +.status { + display: flex; + align-items: center; + gap: 20px; + font-size: 14px; + color: #666; +} + +#connection-status { + padding: 5px 12px; + border-radius: 20px; + font-weight: 500; +} + +#connection-status.connected { + background: #e8f5e9; + color: #2e7d32; +} + +#connection-status.disconnected { + background: #ffebee; + color: #c62828; +} + +#path-display { + font-family: 'Monaco', 'Consolas', monospace; + background: #e3f2fd; + padding: 5px 15px; + border-radius: 5px; + color: #1565c0; +} + +.breadcrumb { + display: flex; + flex-wrap: wrap; + gap: 5px; + padding: 10px 15px; + background: #fff; + border-radius: 8px; + margin-bottom: 15px; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); +} + +.crumb { + color: #1976d2; + cursor: pointer; + padding: 5px 10px; + border-radius: 4px; + transition: background 0.2s; +} + +.crumb:hover { + background: #e3f2fd; +} + +.crumb:last-child { + color: #333; + cursor: default; + background: transparent; +} + +.crumb:not(:last-child)::after { + content: '/'; + margin-left: 5px; + color: #999; +} + +.toolbar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 10px; + padding: 15px; + background: #fff; + border-radius: 8px; + margin-bottom: 15px; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); +} + +.toolbar-left, +.toolbar-right, +.toolbar-center { + display: flex; + gap: 10px; +} + +.toolbar-center { + flex: 1; + justify-content: center; + min-width: 300px; +} + +.search-box { + display: flex; + align-items: center; + background: #f5f5f5; + border-radius: 8px; + padding: 5px 10px; + width: 100%; + max-width: 400px; +} + +.search-box input { + flex: 1; + border: none; + background: transparent; + padding: 8px; + font-size: 14px; + outline: none; +} + +.search-box input::placeholder { + color: #999; +} + +.search-btn { + background: none; + border: none; + cursor: pointer; + font-size: 16px; + padding: 5px; + opacity: 0.7; + transition: opacity 0.2s; +} + +.search-btn:hover { + opacity: 1; +} + +.btn { + padding: 10px 20px; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + display: flex; + align-items: center; + gap: 5px; + transition: all 0.2s; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: #2196f3; + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: #1976d2; +} + +.btn-secondary { + background: #f5f5f5; + color: #333; +} + +.btn-secondary:hover:not(:disabled) { + background: #e0e0e0; +} + +.btn-danger { + background: #f44336; + color: white; +} + +.btn-danger:hover:not(:disabled) { + background: #d32f2f; +} + +.file-list-container { + background: #fff; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); + overflow: hidden; +} + +.file-list { + width: 100%; + border-collapse: collapse; +} + +.file-list th, +.file-list td { + padding: 12px 15px; + text-align: left; + border-bottom: 1px solid #eee; +} + +.file-list th { + background: #fafafa; + font-weight: 600; + color: #666; + font-size: 13px; + text-transform: uppercase; +} + +.file-list tbody tr { + transition: background 0.2s; +} + +.file-list tbody tr:hover { + background: #f5f5f5; +} + +.file-list tbody tr.selected { + background: #e3f2fd; +} + +.col-checkbox { + width: 50px; +} + +.col-name { + min-width: 200px; +} + +.col-size { + width: 120px; +} + +.col-time { + width: 180px; +} + +.col-actions { + width: 150px; +} + +.file-name { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + color: #1976d2; +} + +.file-name:hover { + text-decoration: underline; +} + +.file-name.is-dir { + color: #2e7d32; + font-weight: 500; +} + +.file-icon { + font-size: 20px; +} + +.file-size { + color: #666; + font-size: 13px; +} + +.file-time { + color: #666; + font-size: 13px; +} + +.file-actions { + display: flex; + gap: 8px; +} + +.action-btn { + padding: 5px 10px; + border: none; + background: transparent; + cursor: pointer; + font-size: 14px; + border-radius: 4px; + transition: background 0.2s; +} + +.action-btn:hover { + background: #e0e0e0; +} + +.action-btn.delete:hover { + background: #ffebee; + color: #c62828; +} + +.drop-zone { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(33, 150, 243, 0.9); + z-index: 1000; + justify-content: center; + align-items: center; +} + +.drop-zone.active { + display: flex; +} + +.drop-zone-content { + text-align: center; + color: white; +} + +.drop-icon { + font-size: 80px; + display: block; + margin-bottom: 20px; +} + +.drop-zone-content p { + font-size: 24px; +} + +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.5); + z-index: 1000; + justify-content: center; + align-items: center; + padding: 20px; +} + +.modal.active { + display: flex; +} + +.modal-content { + background: white; + border-radius: 12px; + width: 100%; + max-width: 450px; + max-height: 90vh; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.modal-large { + max-width: 900px; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 20px; + border-bottom: 1px solid #eee; +} + +.modal-header h3 { + font-size: 18px; + color: #333; +} + +.modal-close { + background: none; + border: none; + font-size: 28px; + cursor: pointer; + color: #999; + line-height: 1; +} + +.modal-close:hover { + color: #333; +} + +.modal-body { + padding: 20px; + overflow-y: auto; + flex: 1; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 15px 20px; + border-top: 1px solid #eee; +} + +.form-group { + margin-bottom: 15px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + color: #666; + font-size: 14px; +} + +.form-group input { + width: 100%; + padding: 10px 15px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 14px; +} + +.form-group input:focus { + outline: none; + border-color: #2196f3; +} + +.move-filename { + padding: 10px; + background: #f5f5f5; + border-radius: 6px; + margin-bottom: 15px; + font-family: monospace; +} + +.dir-tree { + max-height: 300px; + overflow-y: auto; + border: 1px solid #eee; + border-radius: 6px; + padding: 10px; +} + +.dir-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + cursor: pointer; + border-radius: 4px; + margin-bottom: 2px; +} + +.dir-item:hover { + background: #f5f5f5; +} + +.dir-item.selected { + background: #e3f2fd; +} + +.dir-item.current { + background: #fff3e0; +} + +.preview-image { + max-width: 100%; + max-height: 60vh; + display: block; + margin: 0 auto; +} + +.preview-text { + white-space: pre-wrap; + font-family: 'Monaco', 'Consolas', monospace; + font-size: 13px; + line-height: 1.5; + max-height: 60vh; + overflow: auto; + background: #f5f5f5; + padding: 15px; + border-radius: 6px; +} + +.preview-placeholder { + text-align: center; + padding: 60px 20px; + color: #999; +} + +.preview-placeholder .icon { + font-size: 80px; + display: block; + margin-bottom: 20px; +} + +.notification { + position: fixed; + bottom: 20px; + right: 20px; + padding: 15px 25px; + background: #333; + color: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + transform: translateY(100px); + opacity: 0; + transition: all 0.3s; + z-index: 2000; +} + +.notification.show { + transform: translateY(0); + opacity: 1; +} + +.notification.success { + background: #4caf50; +} + +.notification.error { + background: #f44336; +} + +.notification.info { + background: #2196f3; +} + +.empty-message { + text-align: center; + padding: 60px 20px; + color: #999; +} + +.empty-message .icon { + font-size: 60px; + display: block; + margin-bottom: 15px; +} + +.loading { + text-align: center; + padding: 40px; + color: #666; +} + +.loading::after { + content: ''; + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid #e0e0e0; + border-top-color: #2196f3; + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-left: 10px; + vertical-align: middle; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@media (max-width: 768px) { + .header { + flex-direction: column; + gap: 15px; + text-align: center; + } + + .toolbar { + flex-direction: column; + } + + .toolbar-left, + .toolbar-right, + .toolbar-center { + justify-content: center; + width: 100%; + } + + .toolbar-center { + order: -1; + margin-bottom: 10px; + } + + .search-box { + max-width: 100%; + } + + .file-list th:nth-child(3), + .file-list td:nth-child(3), + .file-list th:nth-child(4), + .file-list td:nth-child(4) { + display: none; + } + + .file-actions { + flex-direction: column; + gap: 2px; + } +} + +.tooltip { + position: relative; +} + +.tooltip::after { + content: attr(data-tooltip); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + padding: 5px 10px; + background: #333; + color: white; + font-size: 12px; + border-radius: 4px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; +} + +.tooltip:hover::after { + opacity: 1; +} + +.upload-list { + max-height: 300px; + overflow-y: auto; + margin-bottom: 15px; +} + +.upload-item { + display: flex; + align-items: center; + padding: 10px; + border-bottom: 1px solid #eee; + gap: 10px; +} + +.upload-item:last-child { + border-bottom: none; +} + +.upload-item-icon { + font-size: 24px; +} + +.upload-item-info { + flex: 1; + min-width: 0; +} + +.upload-item-name { + font-weight: 500; + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.upload-item-status { + font-size: 12px; + color: #666; +} + +.upload-progress-container { + height: 8px; + background: #e0e0e0; + border-radius: 4px; + overflow: hidden; + margin-bottom: 10px; + display: none; +} + +.upload-progress-container.active { + display: block; +} + +.upload-progress-bar { + height: 100%; + background: linear-gradient(90deg, #2196f3, #4caf50); + border-radius: 4px; + transition: width 0.3s ease; + width: 0%; +} + +.upload-stats { + text-align: center; + font-size: 14px; + color: #666; + display: none; +} + +.upload-stats.active { + display: block; +}