|
@@ -0,0 +1,574 @@
|
|
|
|
|
+package httpfile
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "encoding/json"
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "io"
|
|
|
|
|
+ "log"
|
|
|
|
|
+ "net/http"
|
|
|
|
|
+ "os"
|
|
|
|
|
+ "path/filepath"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+
|
|
|
|
|
+ "ftp-server/config"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// Server represents the HTTP file server
|
|
|
|
|
+type Server struct {
|
|
|
|
|
+ config *config.Config
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// NewServer creates a new HTTP file server
|
|
|
|
|
+func NewServer(cfg *config.Config) *Server {
|
|
|
|
|
+ return &Server{config: cfg}
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Start starts the HTTP file server
|
|
|
|
|
+func (s *Server) Start() error {
|
|
|
|
|
+ if !s.config.HTTPFile.Enable {
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ rootDir := s.config.HTTPFile.RootDir
|
|
|
|
|
+ if err := os.MkdirAll(rootDir, 0755); err != nil {
|
|
|
|
|
+ return fmt.Errorf("failed to create root directory: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ mux := http.NewServeMux()
|
|
|
|
|
+
|
|
|
|
|
+ // Serve HTML page
|
|
|
|
|
+ mux.HandleFunc("/", s.handleFileBrowser)
|
|
|
|
|
+
|
|
|
|
|
+ // API endpoints
|
|
|
|
|
+ mux.HandleFunc("/api/list", s.apiListDir)
|
|
|
|
|
+ if s.config.HTTPFile.Upload {
|
|
|
|
|
+ mux.HandleFunc("/api/upload", s.apiUpload)
|
|
|
|
|
+ mux.HandleFunc("/api/create-folder", s.apiCreateFolder)
|
|
|
|
|
+ }
|
|
|
|
|
+ mux.HandleFunc("/api/download", s.apiDownload)
|
|
|
|
|
+ mux.HandleFunc("/api/delete", s.apiDelete)
|
|
|
|
|
+
|
|
|
|
|
+ addr := fmt.Sprintf("%s:%d", s.config.HTTPFile.Host, s.config.HTTPFile.Port)
|
|
|
|
|
+ log.Printf("HTTP file server listening on http://%s", addr)
|
|
|
|
|
+
|
|
|
|
|
+ return http.ListenAndServe(addr, mux)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (s *Server) handleFileBrowser(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
+ if r.URL.Path != "/" {
|
|
|
|
|
+ http.NotFound(w, r)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
|
+ w.Write([]byte(fileBrowserHTML))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (s *Server) apiListDir(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
+ dir := r.URL.Query().Get("path")
|
|
|
|
|
+ if dir == "" {
|
|
|
|
|
+ dir = "/"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ realPath := s.toRealPath(dir)
|
|
|
|
|
+
|
|
|
|
|
+ entries, err := os.ReadDir(realPath)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ http.Error(w, `{"error":"Cannot read directory"}`, http.StatusInternalServerError)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var files []map[string]interface{}
|
|
|
|
|
+ for _, entry := range entries {
|
|
|
|
|
+ info, _ := entry.Info()
|
|
|
|
|
+ modTime := info.ModTime().Format("2006-01-02 15:04:05")
|
|
|
|
|
+ size := info.Size()
|
|
|
|
|
+
|
|
|
|
|
+ file := map[string]interface{}{
|
|
|
|
|
+ "name": entry.Name(),
|
|
|
|
|
+ "modTime": modTime,
|
|
|
|
|
+ "size": formatSize(size),
|
|
|
|
|
+ "isDir": entry.IsDir(),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if !entry.IsDir() {
|
|
|
|
|
+ file["ext"] = strings.ToLower(filepath.Ext(entry.Name()))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ files = append(files, file)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
+ json.NewEncoder(w).Encode(map[string]interface{}{
|
|
|
|
|
+ "files": files,
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (s *Server) apiDownload(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
+ file := r.URL.Query().Get("file")
|
|
|
|
|
+ if file == "" {
|
|
|
|
|
+ http.Error(w, "Missing file parameter", http.StatusBadRequest)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ realPath := s.toRealPath(file)
|
|
|
|
|
+
|
|
|
|
|
+ if info, err := os.Stat(realPath); err != nil || info.IsDir() {
|
|
|
|
|
+ http.Error(w, "File not found", http.StatusNotFound)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ http.ServeFile(w, r, realPath)
|
|
|
|
|
+ log.Printf("HTTP download: %s from %s", file, r.RemoteAddr)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (s *Server) apiUpload(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
+ if r.Method != http.MethodPost {
|
|
|
|
|
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Parse multipart form (max 500MB)
|
|
|
|
|
+ err := r.ParseMultipartForm(500 << 20)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ http.Error(w, `{"error":"Failed to parse upload"}`, http.StatusBadRequest)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ dir := r.FormValue("path")
|
|
|
|
|
+ if dir == "" {
|
|
|
|
|
+ dir = "/"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ files := r.MultipartForm.File["files"]
|
|
|
|
|
+ if len(files) == 0 {
|
|
|
|
|
+ http.Error(w, `{"error":"No files uploaded"}`, http.StatusBadRequest)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ realDir := s.toRealPath(dir)
|
|
|
|
|
+
|
|
|
|
|
+ for _, fileHeader := range files {
|
|
|
|
|
+ file, err := fileHeader.Open()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ destPath := filepath.Join(realDir, fileHeader.Filename)
|
|
|
|
|
+ destFile, err := os.Create(destPath)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ file.Close()
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ io.Copy(destFile, file)
|
|
|
|
|
+ destFile.Close()
|
|
|
|
|
+ file.Close()
|
|
|
|
|
+
|
|
|
|
|
+ log.Printf("HTTP upload: %s (%d bytes) from %s", fileHeader.Filename, fileHeader.Size, r.RemoteAddr)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
+ json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (s *Server) apiCreateFolder(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
+ if r.Method != http.MethodPost {
|
|
|
|
|
+ http.Error(w, `{"error":"Method not allowed"}`, http.StatusMethodNotAllowed)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var req struct {
|
|
|
|
|
+ Path string `json:"path"`
|
|
|
|
|
+ Name string `json:"name"`
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
|
|
|
+ http.Error(w, `{"error":"Invalid request"}`, http.StatusBadRequest)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if req.Name == "" {
|
|
|
|
|
+ http.Error(w, `{"error":"Folder name required"}`, http.StatusBadRequest)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ realPath := filepath.Join(s.toRealPath(req.Path), req.Name)
|
|
|
|
|
+ if err := os.MkdirAll(realPath, 0755); err != nil {
|
|
|
|
|
+ http.Error(w, `{"error":"Failed to create folder"}`, http.StatusInternalServerError)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
+ json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (s *Server) apiDelete(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
+ if r.Method != http.MethodPost {
|
|
|
|
|
+ http.Error(w, `{"error":"Method not allowed"}`, http.StatusMethodNotAllowed)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var req struct {
|
|
|
|
|
+ Path string `json:"path"`
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
|
|
|
+ http.Error(w, `{"error":"Invalid request"}`, http.StatusBadRequest)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ realPath := s.toRealPath(req.Path)
|
|
|
|
|
+ if err := os.RemoveAll(realPath); err != nil {
|
|
|
|
|
+ http.Error(w, `{"error":"Delete failed"}`, http.StatusInternalServerError)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
+ json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (s *Server) toRealPath(httpPath string) string {
|
|
|
|
|
+ rootDir := s.config.HTTPFile.RootDir
|
|
|
|
|
+ cleanPath := strings.TrimPrefix(httpPath, "/")
|
|
|
|
|
+ return filepath.Join(rootDir, cleanPath)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func formatSize(size int64) string {
|
|
|
|
|
+ switch {
|
|
|
|
|
+ case size < 1024:
|
|
|
|
|
+ return fmt.Sprintf("%d B", size)
|
|
|
|
|
+ case size < 1024*1024:
|
|
|
|
|
+ return fmt.Sprintf("%.1f KB", float64(size)/1024)
|
|
|
|
|
+ case size < 1024*1024*1024:
|
|
|
|
|
+ return fmt.Sprintf("%.1f MB", float64(size)/(1024*1024))
|
|
|
|
|
+ default:
|
|
|
|
|
+ return fmt.Sprintf("%.1f GB", float64(size)/(1024*1024*1024))
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// fileBrowserHTML is the embedded HTML page
|
|
|
|
|
+const fileBrowserHTML = `<!DOCTYPE html>
|
|
|
|
|
+<html lang="zh-CN">
|
|
|
|
|
+<head>
|
|
|
|
|
+ <meta charset="UTF-8">
|
|
|
|
|
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
+ <title>HTTP 文件服务器</title>
|
|
|
|
|
+ <style>
|
|
|
|
|
+ * { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
|
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; }
|
|
|
|
|
+ .container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
|
|
|
|
+ .header { background: white; border-radius: 12px; padding: 24px; margin-bottom: 20px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); }
|
|
|
|
|
+ .header h1 { font-size: 28px; color: #333; margin-bottom: 8px; }
|
|
|
|
|
+ .header p { color: #888; font-size: 14px; }
|
|
|
|
|
+ .breadcrumb { background: white; border-radius: 12px; padding: 16px 24px; margin-bottom: 20px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
|
|
|
+ .breadcrumb a { color: #667eea; text-decoration: none; font-weight: 500; padding: 4px 8px; border-radius: 6px; transition: background 0.2s; }
|
|
|
|
|
+ .breadcrumb a:hover { background: #f0f4ff; }
|
|
|
|
|
+ .breadcrumb span { color: #999; font-size: 20px; }
|
|
|
|
|
+ .actions { background: white; border-radius: 12px; padding: 16px 24px; margin-bottom: 20px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
|
|
|
|
+ .btn { padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.3s; display: inline-flex; align-items: center; gap: 6px; }
|
|
|
|
|
+ .btn-primary { background: linear-gradient(135deg, #667eea, #764ba2); color: white; }
|
|
|
|
|
+ .btn-primary:hover { transform: translateY(-2px); box-shadow: 0 4px 15px rgba(102,126,234,0.4); }
|
|
|
|
|
+ .btn-success { background: #28a745; color: white; }
|
|
|
|
|
+ .btn-success:hover { background: #218838; }
|
|
|
|
|
+ .btn-danger { background: #dc3545; color: white; }
|
|
|
|
|
+ .btn-danger:hover { background: #c82333; }
|
|
|
|
|
+ .btn-sm { padding: 6px 12px; font-size: 12px; }
|
|
|
|
|
+ .file-list { background: white; border-radius: 12px; padding: 24px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); }
|
|
|
|
|
+ table { width: 100%; border-collapse: collapse; }
|
|
|
|
|
+ th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #f0f0f0; font-size: 14px; }
|
|
|
|
|
+ th { background: #fafafa; font-weight: 600; color: #555; }
|
|
|
|
|
+ tr:hover { background: #f8f9ff; }
|
|
|
|
|
+ .file-name { display: flex; align-items: center; gap: 8px; }
|
|
|
|
|
+ .file-icon { width: 32px; height: 32px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 18px; }
|
|
|
|
|
+ .folder-icon { background: #ffeaa7; }
|
|
|
|
|
+ .file-icon-doc { background: #74b9ff; }
|
|
|
|
|
+ .file-icon-img { background: #ff7675; }
|
|
|
|
|
+ .file-icon-zip { background: #a29bfe; }
|
|
|
|
|
+ .file-icon-code { background: #55efc4; }
|
|
|
|
|
+ .file-icon-other { background: #dfe6e9; }
|
|
|
|
|
+ .file-link { color: #667eea; text-decoration: none; font-weight: 500; }
|
|
|
|
|
+ .file-link:hover { text-decoration: underline; }
|
|
|
|
|
+ .actions-cell { display: flex; gap: 8px; }
|
|
|
|
|
+ .toast { position: fixed; top: 80px; right: 30px; padding: 14px 24px; border-radius: 8px; color: white; font-size: 14px; font-weight: 500; z-index: 2000; animation: slideIn 0.3s ease; }
|
|
|
|
|
+ .toast-success { background: #28a745; }
|
|
|
|
|
+ .toast-error { background: #dc3545; }
|
|
|
|
|
+ @keyframes slideIn { from { transform: translateX(100px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
|
|
|
|
+ .modal-overlay { 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; }
|
|
|
|
|
+ .modal-overlay.show { display: flex; }
|
|
|
|
|
+ .modal { background: white; border-radius: 12px; padding: 30px; width: 480px; max-width: 90%; }
|
|
|
|
|
+ .modal h2 { margin-bottom: 24px; font-size: 20px; }
|
|
|
|
|
+ .modal-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; }
|
|
|
|
|
+ .form-group { margin-bottom: 16px; }
|
|
|
|
|
+ .form-group label { display: block; margin-bottom: 6px; font-weight: 600; color: #555; font-size: 14px; }
|
|
|
|
|
+ .form-group input { width: 100%; padding: 10px 14px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
|
|
|
|
|
+ .form-group input:focus { outline: none; border-color: #667eea; }
|
|
|
|
|
+ .drop-zone { border: 2px dashed #667eea; border-radius: 8px; padding: 40px 20px; text-align: center; background: #f8f9ff; transition: all 0.3s; cursor: pointer; margin-bottom: 16px; }
|
|
|
|
|
+ .drop-zone:hover, .drop-zone.dragover { background: #e8ebff; border-color: #764ba2; }
|
|
|
|
|
+ .progress-bar { width: 100%; height: 8px; background: #f0f0f0; border-radius: 4px; overflow: hidden; margin-top: 12px; display: none; }
|
|
|
|
|
+ .progress-bar.show { display: block; }
|
|
|
|
|
+ .progress-fill { height: 100%; background: linear-gradient(135deg, #667eea, #764ba2); transition: width 0.3s; width: 0%; }
|
|
|
|
|
+ .empty-state { text-align: center; padding: 60px 20px; color: #999; }
|
|
|
|
|
+ .empty-state .icon { font-size: 64px; margin-bottom: 16px; }
|
|
|
|
|
+ </style>
|
|
|
|
|
+</head>
|
|
|
|
|
+<body>
|
|
|
|
|
+ <div class="container">
|
|
|
|
|
+ <div class="header">
|
|
|
|
|
+ <h1>📁 HTTP 文件服务器</h1>
|
|
|
|
|
+ <p>在线浏览、下载、上传文件</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="breadcrumb" id="breadcrumb">
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="actions">
|
|
|
|
|
+ <button class="btn btn-primary" onclick="document.getElementById('uploadInput').click()">
|
|
|
|
|
+ 📥 上传文件
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <input type="file" id="uploadInput" multiple style="display:none" onchange="uploadFiles(this.files)">
|
|
|
|
|
+ <button class="btn btn-success" onclick="showNewFolderModal()">
|
|
|
|
|
+ 📁 新建文件夹
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button class="btn btn-danger" onclick="refresh()">
|
|
|
|
|
+ 🔄 刷新
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="file-list">
|
|
|
|
|
+ <table id="fileTable">
|
|
|
|
|
+ <thead>
|
|
|
|
|
+ <tr><th>名称</th><th>大小</th><th>修改时间</th><th>操作</th></tr>
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody id="fileTableBody">
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ <div class="empty-state" id="emptyState" style="display:none">
|
|
|
|
|
+ <div class="icon">📁</div>
|
|
|
|
|
+ <div>目录为空</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- New Folder Modal -->
|
|
|
|
|
+ <div class="modal-overlay" id="folderModal">
|
|
|
|
|
+ <div class="modal">
|
|
|
|
|
+ <h2>📁 新建文件夹</h2>
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label>文件夹名称</label>
|
|
|
|
|
+ <input type="text" id="folderName" placeholder="输入文件夹名称" onkeypress="if(event.key==='Enter')createFolder()">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="modal-actions">
|
|
|
|
|
+ <button class="btn" style="background:#eee" onclick="closeFolderModal()">取消</button>
|
|
|
|
|
+ <button class="btn btn-primary" onclick="createFolder()">创建</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Upload Progress -->
|
|
|
|
|
+ <div class="modal-overlay" id="uploadModal">
|
|
|
|
|
+ <div class="modal">
|
|
|
|
|
+ <h2>📥 上传进度</h2>
|
|
|
|
|
+ <div id="uploadStatus" style="margin-bottom:12px; font-size:14px; color:#666;"></div>
|
|
|
|
|
+ <div class="progress-bar show"><div class="progress-fill" id="progressFill"></div></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+let currentPath = '/';
|
|
|
|
|
+
|
|
|
|
|
+function init() {
|
|
|
|
|
+ loadFiles();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function loadFiles() {
|
|
|
|
|
+ fetch('/api/list?path=' + encodeURIComponent(currentPath))
|
|
|
|
|
+ .then(r => r.json())
|
|
|
|
|
+ .then(data => {
|
|
|
|
|
+ const tbody = document.getElementById('fileTableBody');
|
|
|
|
|
+ tbody.innerHTML = '';
|
|
|
|
|
+
|
|
|
|
|
+ if (!data.files || data.files.length === 0) {
|
|
|
|
|
+ document.getElementById('emptyState').style.display = 'block';
|
|
|
|
|
+ document.getElementById('fileTable').style.display = 'none';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ document.getElementById('emptyState').style.display = 'none';
|
|
|
|
|
+ document.getElementById('fileTable').style.display = 'table';
|
|
|
|
|
+
|
|
|
|
|
+ data.files.forEach(f => {
|
|
|
|
|
+ const tr = document.createElement('tr');
|
|
|
|
|
+ const icon = f.isDir ? '📁' : getFileIcon(f.name);
|
|
|
|
|
+ const iconClass = f.isDir ? 'folder-icon' : 'file-icon-' + getFileType(f.name);
|
|
|
|
|
+
|
|
|
|
|
+ tr.innerHTML = '<td><div class="file-name"><div class="file-icon ' + iconClass + '">' + icon + '</div>' +
|
|
|
|
|
+ (f.isDir ? '<a class="file-link" href="#" onclick="enterFolder(\\'' + escapeStr(f.name) + '\\');return false;">' + f.name + '</a>' : '<span>' + f.name + '</span>') +
|
|
|
|
|
+ '</div></td><td>' + (f.isDir ? '-' : f.size) + '</td><td>' + f.modTime + '</td><td class="actions-cell">' +
|
|
|
|
|
+ (f.isDir ? '<button class="btn btn-sm btn-primary" onclick="enterFolder(\\'' + escapeStr(f.name) + '\\')">打开</button>' : '<a href="/api/download?file=' + encodeURIComponent(currentPath + f.name) + '" class="btn btn-sm btn-success">下载</a>') +
|
|
|
|
|
+ '<button class="btn btn-sm btn-danger" onclick="deleteItem(\\'' + escapeStr(f.name) + '\\', ' + f.isDir + ')">删除</button></td></tr>';
|
|
|
|
|
+ tbody.appendChild(tr);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ updateBreadcrumb();
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function escapeStr(s) {
|
|
|
|
|
+ return s.replace(/\\/g, '\\\\\\\\').replace(/'/g, "\\\\'");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function enterFolder(name) {
|
|
|
|
|
+ currentPath = currentPath === '/' ? '/' + name + '/' : currentPath + name + '/';
|
|
|
|
|
+ loadFiles();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function updateBreadcrumb() {
|
|
|
|
|
+ const bc = document.getElementById('breadcrumb');
|
|
|
|
|
+ bc.innerHTML = '<a href="#" onclick="goTo(\'/\');return false;">根目录</a>';
|
|
|
|
|
+
|
|
|
|
|
+ const parts = currentPath.split('/').filter(p => p);
|
|
|
|
|
+ let path = '';
|
|
|
|
|
+ parts.forEach(p => {
|
|
|
|
|
+ path += p + '/';
|
|
|
|
|
+ bc.innerHTML += '<span>/</span><a href="#" onclick="goTo(\\'' + path + '\\');return false;">' + p + '</a>';
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function goTo(path) {
|
|
|
|
|
+ currentPath = path;
|
|
|
|
|
+ loadFiles();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function refresh() {
|
|
|
|
|
+ loadFiles();
|
|
|
|
|
+ showToast('已刷新');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function showNewFolderModal() {
|
|
|
|
|
+ document.getElementById('folderName').value = '';
|
|
|
|
|
+ document.getElementById('folderModal').classList.add('show');
|
|
|
|
|
+ document.getElementById('folderName').focus();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function closeFolderModal() {
|
|
|
|
|
+ document.getElementById('folderModal').classList.remove('show');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function createFolder() {
|
|
|
|
|
+ const name = document.getElementById('folderName').value.trim();
|
|
|
|
|
+ if (!name) { showToast('请输入文件夹名称', 'error'); return; }
|
|
|
|
|
+
|
|
|
|
|
+ fetch('/api/create-folder', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({ path: currentPath, name: name })
|
|
|
|
|
+ }).then(r => r.json()).then(data => {
|
|
|
|
|
+ if (data.status === 'ok') {
|
|
|
|
|
+ showToast('文件夹已创建');
|
|
|
|
|
+ closeFolderModal();
|
|
|
|
|
+ loadFiles();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ showToast(data.error || '创建失败', 'error');
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function uploadFiles(files) {
|
|
|
|
|
+ if (!files || files.length === 0) return;
|
|
|
|
|
+
|
|
|
|
|
+ document.getElementById('uploadModal').classList.add('show');
|
|
|
|
|
+ document.getElementById('uploadStatus').textContent = '正在上传 ' + files.length + ' 个文件...';
|
|
|
|
|
+ document.getElementById('progressFill').style.width = '0%';
|
|
|
|
|
+
|
|
|
|
|
+ const formData = new FormData();
|
|
|
|
|
+ formData.append('path', currentPath);
|
|
|
|
|
+ for (let i = 0; i < files.length; i++) {
|
|
|
|
|
+ formData.append('files', files[i]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const xhr = new XMLHttpRequest();
|
|
|
|
|
+ xhr.open('POST', '/api/upload');
|
|
|
|
|
+ xhr.upload.onprogress = function(e) {
|
|
|
|
|
+ if (e.lengthComputable) {
|
|
|
|
|
+ const percent = (e.loaded / e.total * 100).toFixed(1);
|
|
|
|
|
+ document.getElementById('progressFill').style.width = percent + '%';
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ xhr.onload = function() {
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ document.getElementById('uploadModal').classList.remove('show');
|
|
|
|
|
+ if (xhr.status === 200) {
|
|
|
|
|
+ showToast('上传成功');
|
|
|
|
|
+ loadFiles();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ showToast('上传失败', 'error');
|
|
|
|
|
+ }
|
|
|
|
|
+ }, 500);
|
|
|
|
|
+ };
|
|
|
|
|
+ xhr.send(formData);
|
|
|
|
|
+
|
|
|
|
|
+ document.getElementById('uploadInput').value = '';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function deleteItem(name, isDir) {
|
|
|
|
|
+ const confirmMsg = isDir ? '确定要删除文件夹 "' + name + '" 及其所有内容吗?' : '确定要删除文件 "' + name + '" 吗?';
|
|
|
|
|
+ if (!confirm(confirmMsg)) return;
|
|
|
|
|
+
|
|
|
|
|
+ fetch('/api/delete', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({ path: currentPath + name })
|
|
|
|
|
+ }).then(r => r.json()).then(data => {
|
|
|
|
|
+ if (data.status === 'ok') {
|
|
|
|
|
+ showToast('已删除');
|
|
|
|
|
+ loadFiles();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ showToast(data.error || '删除失败', 'error');
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getFileIcon(name) {
|
|
|
|
|
+ const ext = name.split('.').pop().toLowerCase();
|
|
|
|
|
+ const icons = {
|
|
|
|
|
+ 'doc': '📄', 'docx': '📄', 'pdf': '📄', 'txt': '📄',
|
|
|
|
|
+ 'xls': '📊', 'xlsx': '📊', 'csv': '📊',
|
|
|
|
|
+ 'jpg': '🖼️', 'jpeg': '🖼️', 'png': '🖼️', 'gif': '🖼️', 'bmp': '🖼️',
|
|
|
|
|
+ 'mp3': '🎵', 'wav': '🎵', 'flac': '🎵',
|
|
|
|
|
+ 'mp4': '🎬', 'avi': '🎬', 'mkv': '🎬', 'mov': '🎬',
|
|
|
|
|
+ 'zip': '📦', 'rar': '📦', '7z': '📦', 'tar': '📦', 'gz': '📦',
|
|
|
|
|
+ 'js': '💻', 'ts': '💻', 'py': '💻', 'go': '💻', 'java': '💻', 'c': '💻', 'cpp': '💻', 'html': '💻', 'css': '💻', 'json': '💻', 'xml': '💻', 'yml': '💻', 'yaml': '💻',
|
|
|
|
|
+ };
|
|
|
|
|
+ return icons[ext] || '📄';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getFileType(name) {
|
|
|
|
|
+ const ext = name.split('.').pop().toLowerCase();
|
|
|
|
|
+ if (['jpg','jpeg','png','gif','bmp','svg'].includes(ext)) return 'img';
|
|
|
|
|
+ if (['zip','rar','7z','tar','gz'].includes(ext)) return 'zip';
|
|
|
|
|
+ if (['js','ts','py','go','java','c','cpp','html','css','json','xml','yml','yaml'].includes(ext)) return 'code';
|
|
|
|
|
+ return 'doc';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function showToast(msg, type) {
|
|
|
|
|
+ const toast = document.createElement('div');
|
|
|
|
|
+ toast.className = 'toast ' + (type === 'error' ? 'toast-error' : 'toast-success');
|
|
|
|
|
+ toast.textContent = msg;
|
|
|
|
|
+ document.body.appendChild(toast);
|
|
|
|
|
+ setTimeout(() => toast.remove(), 3000);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Drag and drop support
|
|
|
|
|
+document.addEventListener('DOMContentLoaded', function() {
|
|
|
|
|
+ document.addEventListener('dragover', function(e) { e.preventDefault(); });
|
|
|
|
|
+ document.addEventListener('drop', function(e) {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|
|
|
|
+ uploadFiles(e.dataTransfer.files);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+init();
|
|
|
|
|
+</script>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>
|
|
|
|
|
+`
|