|
|
.
|
|
|
├── 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
|
|
|
|
|
|
<!DOCTYPE html>
|
|
|
<html lang="zh-CN">
|
|
|
<head>
|
|
|
<meta charset="UTF-8">
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
<title>HD4K 下载管理</title>
|
|
|
<link rel="stylesheet" href="index.css">
|
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
|
</head>
|
|
|
<body>
|
|
|
<div class="container">
|
|
|
<header>
|
|
|
<h1><i class="fas fa-download"></i> HD4K 下载管理</h1>
|
|
|
<p class="subtitle">管理未完成的下载任务</p>
|
|
|
</header>
|
|
|
|
|
|
<div class="control-panel">
|
|
|
<button id="reloadBtn" class="btn btn-primary">
|
|
|
<i class="fas fa-sync-alt"></i> 重载下载文件夹
|
|
|
</button>
|
|
|
<button id="cleanupBtn" class="btn btn-warning">
|
|
|
<i class="fas fa-trash-alt"></i> 清理JSON文件
|
|
|
</button>
|
|
|
<div class="status" id="statusMessage"></div>
|
|
|
</div>
|
|
|
|
|
|
<div class="folders-container">
|
|
|
<h2><i class="fas fa-folder-open"></i> 未完成的任务</h2>
|
|
|
<div id="foldersList" class="folders-list">
|
|
|
<!-- 文件夹列表将在这里动态生成 -->
|
|
|
</div>
|
|
|
<div id="noFolders" class="no-folders">
|
|
|
<i class="fas fa-check-circle"></i>
|
|
|
<p>所有下载任务已完成!</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div class="api-info">
|
|
|
<h3><i class="fas fa-info-circle"></i> API 信息</h3>
|
|
|
<p>图片下载API: <code>POST http://127.0.0.1:<span id="port">55830</span>/api/save_imgs</code></p>
|
|
|
<p>请求格式: <code>{"title": "漫画标题", "imgs": {"001": "url1", "002": "url2"}}</code></p>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<!-- 下载进度模态框 -->
|
|
|
<div id="downloadModal" class="modal">
|
|
|
<div class="modal-content">
|
|
|
<div class="modal-header">
|
|
|
<h3 id="modalTitle">下载进度</h3>
|
|
|
<button class="close-btn">×</button>
|
|
|
</div>
|
|
|
<div class="modal-body">
|
|
|
<div class="progress-container">
|
|
|
<div class="progress-bar" id="progressBar"></div>
|
|
|
<div class="progress-text" id="progressText">0%</div>
|
|
|
</div>
|
|
|
<div class="download-stats">
|
|
|
<div class="stat-item">
|
|
|
<span class="stat-label">总文件数:</span>
|
|
|
<span class="stat-value" id="totalFiles">0</span>
|
|
|
</div>
|
|
|
<div class="stat-item">
|
|
|
<span class="stat-label">已下载:</span>
|
|
|
<span class="stat-value success" id="downloadedFiles">0</span>
|
|
|
</div>
|
|
|
<div class="stat-item">
|
|
|
<span class="stat-label">待下载:</span>
|
|
|
<span class="stat-value pending" id="pendingFiles">0</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="log-container">
|
|
|
<h4>下载日志</h4>
|
|
|
<div class="log-content" id="downloadLog"></div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="modal-footer">
|
|
|
<button id="closeModalBtn" class="btn btn-secondary">关闭</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<script src="index.js"></script>
|
|
|
</body>
|
|
|
</html>
|
|
|
|
|
|
---
|
|
|
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 => `
|
|
|
<div class="folder-item">
|
|
|
<div class="folder-info">
|
|
|
<div class="folder-title">
|
|
|
<i class="fas fa-book"></i>
|
|
|
${this.escapeHtml(folder.title)}
|
|
|
</div>
|
|
|
<div class="folder-stats">
|
|
|
<span>总文件: ${folder.total}</span>
|
|
|
<span>已下载: ${folder.downloaded}</span>
|
|
|
<span>缺失: ${folder.total - folder.downloaded}</span>
|
|
|
</div>
|
|
|
<div class="progress-container">
|
|
|
<div class="progress-wrapper">
|
|
|
<div class="progress">
|
|
|
<div class="progress-fill" style="width: ${folder.progress}%"></div>
|
|
|
</div>
|
|
|
<div class="progress-text">${folder.progress.toFixed(1)}%</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="folder-actions">
|
|
|
<button class="btn btn-primary btn-small" onclick="downloadManager.downloadFolder('${this.escapeAttr(folder.folder)}', '${this.escapeAttr(folder.title)}')">
|
|
|
<i class="fas fa-download"></i> 下载缺失文件 (${folder.total - folder.downloaded})
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
`).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的值不存在则创建一个
|
|
|
|