. ├── Tampermonkey │   └── hdk4.js ├── build │   ├── hd4k-downloader-macos-arm │   ├── hd4k-downloader-macos-intel │   └── hd4k-downloader-windows.exe ├── build.sh ├── downloads ├── go.mod ├── index.css ├── index.html ├── index.js ├── main.go ├── prompt.txt └── test.txt --- main.go package main import ( "crypto/tls" "encoding/json" "flag" "fmt" "io" "log" "net/http" "os" "path/filepath" "strings" "sync" "time" ) var ( downloadDir = flag.String("dir", "./downloads", "下载目录") port = flag.String("port", "55830", "服务端口") ) type SaveImagesRequest struct { Title string `json:"title"` Imgs map[string]string `json:"imgs"` } // 下载详情 type DownloadDetail struct { Key string `json:"key"` URL string `json:"url"` Status string `json:"status"` // success, skipped, failed Message string `json:"message"` SavedAs string `json:"saved_as,omitempty"` } // 响应结构 type SaveImagesResponse struct { Success bool `json:"success"` Message string `json:"message"` Title string `json:"title"` Folder string `json:"folder"` JsonPath string `json:"json_path"` Total int `json:"total"` Saved int `json:"saved"` Skipped int `json:"skipped"` Failed int `json:"failed"` Details []DownloadDetail `json:"details"` } // 文件夹信息结构 type FolderInfo struct { Title string `json:"title"` Folder string `json:"folder"` JsonPath string `json:"json_path"` Total int `json:"total"` Downloaded int `json:"downloaded"` Progress float64 `json:"progress"` } // 加载JSON数据 type JsonData struct { Title string `json:"title"` Imgs map[string]string `json:"imgs"` } func saveImagesHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusMethodNotAllowed) json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "message": "只支持POST请求", }) return } // 解析请求 var req SaveImagesRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "message": "请求格式错误: " + err.Error(), }) return } // 验证参数 if req.Title == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "message": "标题不能为空", }) return } if len(req.Imgs) == 0 { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "message": "图片列表不能为空", }) return } // 处理下载 resp, err := processDownload(&req) if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "message": "处理失败: " + err.Error(), }) return } // 返回响应 w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } func reloadFoldersHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusMethodNotAllowed) json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "message": "只支持GET请求", }) return } folders, err := loadFolders() if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "message": "加载文件夹失败: " + err.Error(), }) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "folders": folders, }) } func downloadMissingHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, `{"success": false, "message": "只支持POST请求"}`, http.StatusMethodNotAllowed) return } var req struct { Folder string `json:"folder"` Title string `json:"title"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "message": "请求格式错误: " + err.Error(), }) return } if req.Folder == "" || req.Title == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "message": "参数不能为空", }) return } resp, err := processMissingDownload(req.Folder, req.Title) if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "message": "处理失败: " + err.Error(), }) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } func cleanupJsonHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusMethodNotAllowed) json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "message": "只支持POST请求", }) return } count, err := cleanupJsonFiles() if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "message": "清理失败: " + err.Error(), }) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": fmt.Sprintf("已删除 %d 个JSON文件", count), "deleted": count, }) } // 查找文件夹中的JSON文件(支持多种方式) func findJsonFiles(folderPath string) ([]string, error) { jsonFiles := []string{} // 方法1: 标准glob pattern1 := filepath.Join(folderPath, "*.json") files1, _ := filepath.Glob(pattern1) jsonFiles = append(jsonFiles, files1...) // 方法2: 不区分大小写 pattern2 := filepath.Join(folderPath, "*.JSON") files2, _ := filepath.Glob(pattern2) jsonFiles = append(jsonFiles, files2...) // 方法3: 使用ReadDir手动查找(最可靠) files3, err := os.ReadDir(folderPath) if err != nil { return jsonFiles, err } for _, file := range files3 { if !file.IsDir() { name := strings.ToLower(file.Name()) if strings.HasSuffix(name, ".json") { fullPath := filepath.Join(folderPath, file.Name()) // 去重 found := false for _, existing := range jsonFiles { if existing == fullPath { found = true break } } if !found { jsonFiles = append(jsonFiles, fullPath) } } } } return jsonFiles, nil } func loadFolders() ([]FolderInfo, error) { folders := []FolderInfo{} fmt.Println("=== 开始扫描下载目录 ===") fmt.Println("下载目录绝对路径:", *downloadDir) // 获取绝对路径 absDownloadDir, _ := filepath.Abs(*downloadDir) fmt.Println("下载目录绝对路径:", absDownloadDir) entries, err := os.ReadDir(absDownloadDir) if err != nil { fmt.Printf("读取目录失败: %v\n", err) return nil, err } for _, entry := range entries { if entry.IsDir() { folderPath := filepath.Join(absDownloadDir, entry.Name()) fmt.Printf(" 处理文件夹: %s\n", folderPath) // 使用通用函数查找JSON文件 jsonFiles, err := findJsonFiles(folderPath) if err != nil { fmt.Printf(" 查找JSON文件失败: %v\n", err) continue } for j, jsonFile := range jsonFiles { fmt.Printf(" [%d] %s\n", j, jsonFile) } if len(jsonFiles) == 0 { fmt.Println(" 没有JSON文件,跳过") continue } // 读取JSON文件 for _, jsonFile := range jsonFiles { fmt.Printf(" 处理JSON文件: %s\n", jsonFile) // 检查文件是否存在 if _, err := os.Stat(jsonFile); os.IsNotExist(err) { fmt.Printf(" ❌ JSON文件不存在\n") continue } data, err := os.ReadFile(jsonFile) if err != nil { fmt.Printf(" 读取JSON文件失败: %v\n", err) continue } fmt.Printf(" 成功读取JSON文件,大小: %d bytes\n", len(data)) var jsonData JsonData if err := json.Unmarshal(data, &jsonData); err != nil { fmt.Printf(" 解析JSON失败: %v\n", err) fmt.Printf(" 文件内容前100字节: %s\n", string(data[:min(100, len(data))])) continue } fmt.Printf(" JSON解析成功: title='%s', imgs数量=%d\n", jsonData.Title, len(jsonData.Imgs)) if len(jsonData.Imgs) == 0 { fmt.Println(" JSON中没有图片数据,跳过") continue } // 统计实际已下载的图片数量(与JSON中的key匹配) downloaded := 0 files, _ := os.ReadDir(folderPath) fmt.Printf(" 文件夹中总文件数: %d\n", len(files)) // 创建已存在文件的map existingFiles := make(map[string]bool) for _, file := range files { if !file.IsDir() && !strings.HasSuffix(strings.ToLower(file.Name()), ".json") { // 提取序号(去掉扩展名) ext := filepath.Ext(file.Name()) key := strings.TrimSuffix(file.Name(), ext) existingFiles[key] = true fmt.Printf(" 文件: %s -> key: %s\n", file.Name(), key) } } fmt.Println(" 检查JSON中的key匹配情况:") // 检查JSON中的每个key是否都有对应的文件 missingKeys := []string{} for key := range jsonData.Imgs { if existingFiles[key] { downloaded++ fmt.Printf(" ✓ 存在: key=%s\n", key) } else { missingKeys = append(missingKeys, key) fmt.Printf(" ✗ 缺失: key=%s\n", key) } } // 计算进度 total := len(jsonData.Imgs) progress := 0.0 if total > 0 { progress = float64(downloaded) / float64(total) * 100 } fmt.Printf(" 统计结果: 总=%d, 已下载=%d, 缺失=%d, 进度=%.2f%%\n", total, downloaded, len(missingKeys), progress) // 显示所有未完成的任务(包括部分完成的) folders = append(folders, FolderInfo{ Title: jsonData.Title, Folder: folderPath, JsonPath: jsonFile, Total: total, Downloaded: downloaded, Progress: progress, }) fmt.Printf(" 添加到列表: title='%s', progress=%.2f%%\n", jsonData.Title, progress) } } } fmt.Printf("\n=== 扫描完成,共找到 %d 个文件夹 ===\n", len(folders)) return folders, nil } func processMissingDownload(folderPath, title string) (*SaveImagesResponse, error) { fmt.Printf("=== 开始处理缺失文件下载 ===\n") fmt.Printf("文件夹: %s\n", folderPath) fmt.Printf("标题: %s\n", title) // 使用通用函数查找JSON文件 jsonFiles, err := findJsonFiles(folderPath) if err != nil || len(jsonFiles) == 0 { fmt.Printf("未找到JSON文件,错误: %v\n", err) return nil, fmt.Errorf("未找到JSON文件") } fmt.Printf("找到JSON文件: %s\n", jsonFiles[0]) // 读取JSON文件 data, err := os.ReadFile(jsonFiles[0]) if err != nil { fmt.Printf("读取JSON文件失败: %v\n", err) return nil, fmt.Errorf("读取JSON文件失败: %v", err) } var jsonData JsonData if err := json.Unmarshal(data, &jsonData); err != nil { fmt.Printf("解析JSON失败: %v\n", err) return nil, fmt.Errorf("解析JSON失败: %v", err) } fmt.Printf("JSON解析成功: title='%s', imgs数量=%d\n", jsonData.Title, len(jsonData.Imgs)) // 检查现有文件 - 修正为只统计与JSON key匹配的文件 existingFiles := make(map[string]bool) files, _ := os.ReadDir(folderPath) fmt.Printf("文件夹中总文件数: %d\n", len(files)) for _, file := range files { if !file.IsDir() && !strings.HasSuffix(strings.ToLower(file.Name()), ".json") { // 提取序号(去掉扩展名) ext := filepath.Ext(file.Name()) key := strings.TrimSuffix(file.Name(), ext) existingFiles[key] = true } } // 准备下载缺失的文件 needDownload := make(map[string]string) missingCount := 0 fmt.Println("检查缺失文件:") for key, url := range jsonData.Imgs { if !existingFiles[key] { needDownload[key] = url missingCount++ fmt.Printf(" ✗ 缺失: key=%s\n", key) } else { fmt.Printf(" ✓ 存在: key=%s\n", key) } } // 计算已下载数量(基于JSON中实际存在的key) downloadedCount := 0 for key := range jsonData.Imgs { if existingFiles[key] { downloadedCount++ } } fmt.Printf("统计: 总文件=%d, 已下载=%d, 缺失=%d\n", len(jsonData.Imgs), downloadedCount, missingCount) if len(needDownload) == 0 { fmt.Println("所有文件都已存在,无需下载") return &SaveImagesResponse{ Success: true, Message: "所有图片已下载完成", Title: title, Folder: folderPath, JsonPath: jsonFiles[0], Total: len(jsonData.Imgs), Saved: 0, Skipped: len(jsonData.Imgs), Failed: 0, }, nil } fmt.Printf("开始下载 %d 个缺失文件...\n", len(needDownload)) // 下载缺失图片 saved := 0 failed := 0 details := make([]DownloadDetail, 0, len(needDownload)) for key, url := range needDownload { details = append(details, DownloadDetail{ Key: key, URL: url, Status: "pending", Message: "等待下载", }) } successMap, errors := downloadImages(needDownload, folderPath) _ = successMap for i, detail := range details { if detail.Status == "pending" { if err, ok := errors[detail.Key]; ok { details[i].Status = "failed" details[i].Message = err.Error() failed++ fmt.Printf("下载失败: key=%s, error=%v\n", detail.Key, err) } else { ext := getFileExtension(detail.URL) filename := detail.Key + ext details[i].Status = "success" details[i].Message = "下载成功" details[i].SavedAs = filename saved++ fmt.Printf("下载成功: key=%s\n", detail.Key) } } } skipped := len(jsonData.Imgs) - saved - failed fmt.Printf("下载完成: 新增=%d, 跳过=%d, 失败=%d\n", saved, skipped, failed) return &SaveImagesResponse{ Success: failed == 0, Message: fmt.Sprintf("下载完成: 新增%d张, 跳过%d张, 失败%d张", saved, skipped, failed), Title: title, Folder: folderPath, JsonPath: jsonFiles[0], Total: len(jsonData.Imgs), Saved: saved, Skipped: skipped, Failed: failed, Details: details, }, nil } func cleanupJsonFiles() (int, error) { count := 0 err := filepath.Walk(*downloadDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".json") { if err := os.Remove(path); err == nil { count++ } } return nil }) return count, err } func processDownload(req *SaveImagesRequest) (*SaveImagesResponse, error) { // 清理标题(移除非法字符) cleanTitle := cleanFilename(req.Title) // 创建目录 comicDir := filepath.Join(*downloadDir, cleanTitle) if err := os.MkdirAll(comicDir, 0755); err != nil { return nil, fmt.Errorf("创建目录失败: %v", err) } // JSON文件路径 jsonFilename := cleanTitle + ".json" jsonPath := filepath.Join(comicDir, jsonFilename) // 检查现有文件 existingFiles := make(map[string]bool) files, _ := os.ReadDir(comicDir) for _, file := range files { if !file.IsDir() { filename := file.Name() if strings.HasSuffix(filename, ".json") { continue } // 提取序号(去掉扩展名) ext := filepath.Ext(filename) key := strings.TrimSuffix(filename, ext) existingFiles[key] = true } } // 准备下载 details := make([]DownloadDetail, 0, len(req.Imgs)) needDownload := make(map[string]string) for key, url := range req.Imgs { // 获取文件扩展名 ext := getFileExtension(url) filename := key + ext if existingFiles[key] { // 文件已存在,跳过 details = append(details, DownloadDetail{ Key: key, URL: url, Status: "skipped", Message: "文件已存在", SavedAs: filename, }) continue } // 需要下载 needDownload[key] = url details = append(details, DownloadDetail{ Key: key, URL: url, Status: "pending", Message: "等待下载", }) } // 下载图片 var saved, failed int if len(needDownload) > 0 { successMap, errors := downloadImages(needDownload, comicDir) _ = successMap // 更新下载详情 for i, detail := range details { if detail.Status == "pending" { if err, ok := errors[detail.Key]; ok { details[i].Status = "failed" details[i].Message = err.Error() failed++ } else { ext := getFileExtension(detail.URL) filename := detail.Key + ext details[i].Status = "success" details[i].Message = "下载成功" details[i].SavedAs = filename saved++ } } } } // 保存JSON文件 skipped := len(req.Imgs) - saved - failed finalImgs := make(map[string]string) for key, url := range req.Imgs { finalImgs[key] = url } saveJSON(comicDir, jsonFilename, req.Title, finalImgs) // 构建响应 return &SaveImagesResponse{ Success: failed == 0, Message: fmt.Sprintf("下载完成: 新增%d张, 跳过%d张, 失败%d张", saved, skipped, failed), Title: req.Title, Folder: comicDir, JsonPath: jsonPath, Total: len(req.Imgs), Saved: saved, Skipped: skipped, Failed: failed, Details: details, }, nil } func saveJSON(dir, filename, title string, imgs map[string]string) error { data := map[string]interface{}{ "title": title, "imgs": imgs, "created_at": time.Now().Format("2006-01-02 15:04:05"), "updated_at": time.Now().Format("2006-01-02 15:04:05"), } jsonData, err := json.MarshalIndent(data, "", " ") if err != nil { return err } path := filepath.Join(dir, filename) return os.WriteFile(path, jsonData, 0644) } func cleanFilename(filename string) string { illegalChars := []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|", "\n", "\r", "\t"} result := filename for _, char := range illegalChars { result = strings.ReplaceAll(result, char, "_") } if len(result) > 200 { result = result[:200] } return strings.TrimSpace(result) } func getFileExtension(url string) string { // 默认扩展名 ext := ".jpg" // 从URL提取扩展名 if idx := strings.LastIndex(url, "."); idx != -1 { fileExt := strings.ToLower(url[idx:]) // 检查是否为常见图片格式 validExts := []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff"} for _, validExt := range validExts { if strings.HasPrefix(fileExt, validExt) { ext = validExt break } } } return ext } func downloadImages(images map[string]string, dir string) (map[string]bool, map[string]error) { var mu sync.Mutex var wg sync.WaitGroup success := make(map[string]bool) errors := make(map[string]error) // 并发控制:最多同时下载5张图片 semaphore := make(chan struct{}, 5) for key, url := range images { wg.Add(1) go func(key, url string) { defer wg.Done() // 获取信号量 semaphore <- struct{}{} defer func() { <-semaphore }() // 下载图片 err := downloadImage(url, dir, key) mu.Lock() if err != nil { errors[key] = err } else { success[key] = true } mu.Unlock() }(key, url) } wg.Wait() return success, errors } func downloadImage(url, dir, filename string) error { // 创建HTTP客户端 client := &http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, }, } // 创建请求 req, err := http.NewRequest("GET", url, nil) if err != nil { return fmt.Errorf("创建请求失败: %v", err) } // 设置请求头(模拟浏览器) req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") req.Header.Set("Accept", "image/webp,image/apng,image/*,*/*;q=0.8") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") req.Header.Set("Referer", "https://hd4k.com/") // 发送请求 resp, err := client.Do(req) if err != nil { return fmt.Errorf("请求失败: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("HTTP错误: %s", resp.Status) } // 获取文件扩展名 ext := getFileExtension(url) fullFilename := filename + ext filePath := filepath.Join(dir, fullFilename) // 创建文件 file, err := os.Create(filePath) if err != nil { return fmt.Errorf("创建文件失败: %v", err) } defer file.Close() // 下载并保存 _, err = io.Copy(file, resp.Body) if err != nil { // 删除可能已损坏的文件 os.Remove(filePath) return fmt.Errorf("保存文件失败: %v", err) } return nil } func main() { flag.Parse() // 创建下载目录 if err := os.MkdirAll(*downloadDir, 0755); err != nil { log.Fatal("创建下载目录失败:", err) } // 设置路由 http.HandleFunc("/api/save_imgs", enableCORS(saveImagesHandler)) http.HandleFunc("/api/reload_folders", enableCORS(reloadFoldersHandler)) http.HandleFunc("/api/download_missing", enableCORS(downloadMissingHandler)) http.HandleFunc("/api/cleanup_json", enableCORS(cleanupJsonHandler)) // 静态文件服务 http.Handle("/", http.FileServer(http.Dir("."))) fmt.Printf("HD4K下载服务启动\n") fmt.Printf("下载目录: %s\n", *downloadDir) fmt.Printf("API地址: http://127.0.0.1:%s/api/save_imgs\n", *port) fmt.Printf("管理页面: http://127.0.0.1:%s/\n", *port) fmt.Printf("按 Ctrl+C 停止服务\n") log.Fatal(http.ListenAndServe(":"+*port, nil)) } func enableCORS(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return } next(w, r) } } --- index.html HD4K 下载管理

HD4K 下载管理

管理未完成的下载任务

未完成的任务

所有下载任务已完成!

API 信息

图片下载API: POST http://127.0.0.1:55830/api/save_imgs

请求格式: {"title": "漫画标题", "imgs": {"001": "url1", "002": "url2"}}

--- index.css * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; padding: 20px; } .container { max-width: 1200px; margin: 0 auto; background: white; border-radius: 20px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); overflow: hidden; } header { background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); color: white; padding: 40px; text-align: center; } header h1 { font-size: 2.5rem; margin-bottom: 10px; display: flex; align-items: center; justify-content: center; gap: 15px; } header .subtitle { font-size: 1.1rem; opacity: 0.9; } .control-panel { padding: 30px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; display: flex; gap: 15px; align-items: center; flex-wrap: wrap; } .btn { padding: 12px 24px; border: none; border-radius: 10px; font-size: 1rem; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; gap: 8px; transition: all 0.3s ease; } .btn-primary { background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: white; } .btn-primary:hover { transform: translateY(-2px); box-shadow: 0 10px 20px rgba(16, 185, 129, 0.3); } .btn-warning { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white; } .btn-warning:hover { transform: translateY(-2px); box-shadow: 0 10px 20px rgba(245, 158, 11, 0.3); } .btn-secondary { background: #64748b; color: white; } .btn-secondary:hover { background: #475569; } .btn-small { padding: 8px 16px; font-size: 0.9rem; } .status { margin-left: auto; padding: 10px 20px; background: white; border-radius: 8px; border-left: 4px solid #10b981; font-weight: 500; } .folders-container { padding: 30px; } .folders-container h2 { color: #334155; margin-bottom: 20px; display: flex; align-items: center; gap: 10px; } .folders-list { display: grid; gap: 20px; } .folder-item { background: white; border: 2px solid #e2e8f0; border-radius: 12px; padding: 20px; transition: all 0.3s ease; display: flex; justify-content: space-between; align-items: center; } .folder-item:hover { border-color: #94a3b8; transform: translateY(-2px); box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); } .folder-info { flex: 1; } .folder-title { font-size: 1.2rem; font-weight: 600; color: #1e293b; margin-bottom: 8px; display: flex; align-items: center; gap: 10px; } .folder-stats { display: flex; gap: 20px; color: #64748b; font-size: 0.9rem; } .progress-container { width: 200px; margin: 10px 0; } .progress-wrapper { display: flex; align-items: center; gap: 10px; } .progress { flex: 1; height: 8px; background: #e2e8f0; border-radius: 4px; overflow: hidden; } .progress-fill { height: 100%; background: linear-gradient(90deg, #10b981 0%, #34d399 100%); border-radius: 4px; transition: width 0.3s ease; } .progress-text { font-weight: 600; color: #1e293b; min-width: 50px; } .folder-actions { display: flex; gap: 10px; } .no-folders { text-align: center; padding: 60px 20px; color: #64748b; } .no-folders i { font-size: 4rem; color: #10b981; margin-bottom: 20px; } .no-folders p { font-size: 1.2rem; } .api-info { padding: 30px; background: #f1f5f9; border-top: 1px solid #e2e8f0; } .api-info h3 { color: #334155; margin-bottom: 15px; display: flex; align-items: center; gap: 10px; } .api-info code { background: #e2e8f0; padding: 4px 8px; border-radius: 4px; font-family: 'Courier New', monospace; font-size: 0.9rem; } .api-info p { margin: 10px 0; color: #475569; } /* 模态框样式 */ .modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 1000; align-items: center; justify-content: center; } .modal-content { background: white; border-radius: 15px; width: 90%; max-width: 600px; max-height: 80vh; overflow: hidden; display: flex; flex-direction: column; } .modal-header { padding: 20px; background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); color: white; display: flex; justify-content: space-between; align-items: center; } .modal-header h3 { font-size: 1.5rem; } .close-btn { background: none; border: none; color: white; font-size: 1.8rem; cursor: pointer; padding: 0; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; } .modal-body { padding: 20px; flex: 1; overflow-y: auto; } .progress-container { background: #f8fafc; border-radius: 10px; padding: 20px; margin-bottom: 20px; position: relative; } .progress-bar { height: 10px; background: linear-gradient(90deg, #10b981 0%, #34d399 100%); border-radius: 5px; width: 0%; transition: width 0.3s ease; } .progress-text { position: absolute; top: 50%; right: 20px; transform: translateY(-50%); font-weight: 600; color: #1e293b; } .download-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; margin: 20px 0; } .stat-item { background: #f8fafc; padding: 15px; border-radius: 8px; text-align: center; } .stat-label { display: block; color: #64748b; font-size: 0.9rem; margin-bottom: 5px; } .stat-value { display: block; font-size: 1.5rem; font-weight: 600; color: #1e293b; } .stat-value.success { color: #10b981; } .stat-value.pending { color: #f59e0b; } .log-container { background: #f8fafc; border-radius: 10px; padding: 20px; margin-top: 20px; } .log-container h4 { color: #334155; margin-bottom: 10px; } .log-content { max-height: 200px; overflow-y: auto; font-family: 'Courier New', monospace; font-size: 0.9rem; line-height: 1.4; color: #475569; } .log-entry { padding: 5px 0; border-bottom: 1px solid #e2e8f0; } .log-entry.success { color: #10b981; } .log-entry.error { color: #ef4444; } .log-entry.info { color: #3b82f6; } .modal-footer { padding: 20px; background: #f8fafc; border-top: 1px solid #e2e8f0; text-align: right; } @media (max-width: 768px) { .container { border-radius: 10px; } header { padding: 30px 20px; } header h1 { font-size: 2rem; } .control-panel { flex-direction: column; align-items: stretch; } .status { margin-left: 0; margin-top: 10px; text-align: center; } .folder-item { flex-direction: column; align-items: stretch; gap: 15px; } .folder-actions { justify-content: center; } .download-stats { grid-template-columns: 1fr; } } --- index.js class DownloadManager { constructor() { this.port = window.location.port || '55830'; this.baseUrl = `http://127.0.0.1:${this.port}/api`; this.currentDownload = null; this.init(); } init() { document.getElementById('port').textContent = this.port; // 绑定事件 document.getElementById('reloadBtn').addEventListener('click', () => this.reloadFolders()); document.getElementById('cleanupBtn').addEventListener('click', () => this.cleanupJson()); document.getElementById('closeModalBtn').addEventListener('click', () => this.hideModal()); document.querySelector('.close-btn').addEventListener('click', () => this.hideModal()); // 点击模态框外部关闭 document.getElementById('downloadModal').addEventListener('click', (e) => { if (e.target === document.getElementById('downloadModal')) { this.hideModal(); } }); // 初始加载 this.reloadFolders(); } showStatus(message, type = 'info') { const statusEl = document.getElementById('statusMessage'); statusEl.textContent = message; statusEl.style.borderLeftColor = type === 'error' ? '#ef4444' : type === 'warning' ? '#f59e0b' : '#10b981'; } showModal(title) { document.getElementById('modalTitle').textContent = title; document.getElementById('downloadModal').style.display = 'flex'; this.resetModal(); } hideModal() { document.getElementById('downloadModal').style.display = 'none'; if (this.currentDownload && this.currentDownload.abort) { this.currentDownload.abort(); } } resetModal() { document.getElementById('progressBar').style.width = '0%'; document.getElementById('progressText').textContent = '0%'; document.getElementById('totalFiles').textContent = '0'; document.getElementById('downloadedFiles').textContent = '0'; document.getElementById('pendingFiles').textContent = '0'; document.getElementById('downloadLog').innerHTML = ''; } updateProgress(progress, stats) { document.getElementById('progressBar').style.width = `${progress}%`; document.getElementById('progressText').textContent = `${Math.round(progress)}%`; if (stats) { document.getElementById('totalFiles').textContent = stats.total || 0; document.getElementById('downloadedFiles').textContent = stats.downloaded || 0; document.getElementById('pendingFiles').textContent = stats.pending || 0; } } addLogEntry(message, type = 'info') { const logEl = document.getElementById('downloadLog'); const entry = document.createElement('div'); entry.className = `log-entry ${type}`; entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`; logEl.appendChild(entry); logEl.scrollTop = logEl.scrollHeight; } async reloadFolders() { try { this.showStatus('正在加载文件夹...', 'info'); document.getElementById('reloadBtn').disabled = true; const response = await fetch(`${this.baseUrl}/reload_folders`); const data = await response.json(); if (data.success) { this.displayFolders(data.folders); this.showStatus(`已加载 ${data.folders.length} 个未完成的任务`, 'success'); } else { throw new Error(data.message || '加载失败'); } } catch (error) { console.error('加载失败:', error); this.showStatus(`加载失败: ${error.message}`, 'error'); this.displayFolders([]); } finally { document.getElementById('reloadBtn').disabled = false; } } displayFolders(folders) { const foldersList = document.getElementById('foldersList'); const noFolders = document.getElementById('noFolders'); // 过滤掉进度为100%的文件夹 const incompleteFolders = folders.filter(folder => folder.progress < 100); if (incompleteFolders.length === 0) { foldersList.innerHTML = ''; noFolders.style.display = 'block'; return; } noFolders.style.display = 'none'; foldersList.innerHTML = incompleteFolders.map(folder => `
${this.escapeHtml(folder.title)}
总文件: ${folder.total} 已下载: ${folder.downloaded} 缺失: ${folder.total - folder.downloaded}
${folder.progress.toFixed(1)}%
`).join(''); } async downloadFolder(folderPath, title) { try { this.showModal(`正在下载: ${title}`); this.addLogEntry('开始下载缺失文件...', 'info'); console.log('发送请求:', { folder: folderPath, title: title }); const response = await fetch(`${this.baseUrl}/download_missing`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ folder: folderPath, title: title }) }); // 检查响应状态 if (!response.ok) { const errorText = await response.text(); console.error('HTTP错误:', response.status, errorText); throw new Error(`HTTP ${response.status}: ${errorText}`); } const data = await response.json(); console.log('收到响应:', data); if (data.success) { // 更新进度 const totalSaved = (data.saved || 0) + (data.skipped || 0); const total = data.total || 1; const progress = totalSaved / total * 100; this.updateProgress(progress, { total: total, downloaded: totalSaved, pending: data.failed || 0 }); // 添加日志 if (data.details) { data.details.forEach(detail => { if (detail.status === 'success') { this.addLogEntry(`成功下载: ${detail.key}`, 'success'); } else if (detail.status === 'failed') { this.addLogEntry(`下载失败 ${detail.key}: ${detail.message}`, 'error'); } else if (detail.status === 'skipped') { this.addLogEntry(`跳过: ${detail.key} (${detail.message})`, 'info'); } }); } this.addLogEntry(`下载完成: ${data.message}`, 'success'); // 刷新文件夹列表 setTimeout(() => this.reloadFolders(), 1000); } else { this.addLogEntry(`下载失败: ${data.message}`, 'error'); } } catch (error) { console.error('下载失败:', error); this.addLogEntry(`下载失败: ${error.message}`, 'error'); } } async cleanupJson() { if (!confirm('确定要删除所有JSON文件吗?此操作不可恢复。')) { return; } try { this.showStatus('正在清理JSON文件...', 'info'); document.getElementById('cleanupBtn').disabled = true; const response = await fetch(`${this.baseUrl}/cleanup_json`, { method: 'POST' }); const data = await response.json(); if (data.success) { this.showStatus(data.message, 'success'); // 刷新文件夹列表 setTimeout(() => this.reloadFolders(), 500); } else { throw new Error(data.message || '清理失败'); } } catch (error) { console.error('清理失败:', error); this.showStatus(`清理失败: ${error.message}`, 'error'); } finally { document.getElementById('cleanupBtn').disabled = false; } } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } escapeAttr(text) { return this.escapeHtml(text).replace(/"/g, '"'); } } // 初始化应用 const downloadManager = new DownloadManager(); --- 帮我修改一下这份代码, 现在打开项目的页面路由, 会出现类似文件服务器的界面, 就是把我项目所有的文件都显示出来了, 就是显示不了 html的页面 你帮我看看什么问题? 另外, 前端推送过来的数据格式是 { "title": "测试", "imgs": { "0001": "https://icon-icons.com/images/menu_photos.png", "0002": "https://icon-icons.com/images/flags/zh.webp" } } 现在前端推送过来的数据变成了 { "title": "测试", "source": "hd4k" "imgs": { "0001": "https://icon-icons.com/images/menu_photos.png", "0002": "https://icon-icons.com/images/flags/zh.webp" } } 这个 source , 是用于, 创建文件夹的, 现在的下载文件的文件夹路径是 ./downloads/title的值, 我需要改成 ./downloads/source的值/title的值 如果source的值不存在则创建一个