|
|
from fastapi import FastAPI, HTTPException |
|
|
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse |
|
|
import os, shutil, glob |
|
|
import tempfile |
|
|
import re |
|
|
import zipfile |
|
|
from main import Run, fetch_api_endpoints_from_server, get_book_info, get_headers |
|
|
import queue, threading |
|
|
from fastapi.staticfiles import StaticFiles |
|
|
|
|
|
|
|
|
TASK_QUEUE = queue.Queue() |
|
|
STATUS = {} |
|
|
FILE_PATHS = {} |
|
|
NAMES = {} |
|
|
TASK_DETAILS = {} |
|
|
|
|
|
|
|
|
def process_queue(): |
|
|
from datetime import datetime |
|
|
|
|
|
while True: |
|
|
try: |
|
|
book_id = TASK_QUEUE.get(timeout=1) |
|
|
if book_id in STATUS and STATUS[book_id] != "queued": |
|
|
continue |
|
|
|
|
|
|
|
|
start_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
|
|
if book_id in TASK_DETAILS: |
|
|
TASK_DETAILS[book_id]["start_time"] = start_time |
|
|
|
|
|
STATUS[book_id] = "in-progress" |
|
|
save_path = os.path.join(DOWNLOAD_ROOT, book_id) |
|
|
os.makedirs(save_path, exist_ok=True) |
|
|
|
|
|
try: |
|
|
fetch_api_endpoints_from_server() |
|
|
Run(book_id, save_path) |
|
|
name, _, _ = get_book_info(book_id, get_headers()) |
|
|
if not name: |
|
|
name = f"小说_{book_id}" |
|
|
|
|
|
filename = f"{name}.txt" |
|
|
path = os.path.join(save_path, filename) |
|
|
|
|
|
|
|
|
complete_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
|
|
|
|
|
if os.path.exists(path): |
|
|
FILE_PATHS[book_id] = f"/files/{book_id}/{filename}" |
|
|
NAMES[book_id] = name |
|
|
STATUS[book_id] = "done" |
|
|
if book_id in TASK_DETAILS: |
|
|
TASK_DETAILS[book_id]["complete_time"] = complete_time |
|
|
TASK_DETAILS[book_id]["name"] = name |
|
|
print(f"下载完成: {name}") |
|
|
else: |
|
|
STATUS[book_id] = "error" |
|
|
if book_id in TASK_DETAILS: |
|
|
TASK_DETAILS[book_id]["complete_time"] = complete_time |
|
|
print(f"下载失败: 文件不存在 - {book_id}") |
|
|
|
|
|
except Exception as e: |
|
|
STATUS[book_id] = "error" |
|
|
complete_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
|
|
if book_id in TASK_DETAILS: |
|
|
TASK_DETAILS[book_id]["complete_time"] = complete_time |
|
|
print(f"下载异常: {book_id} - {str(e)}") |
|
|
|
|
|
except: |
|
|
|
|
|
continue |
|
|
|
|
|
|
|
|
app = FastAPI( |
|
|
title="番茄小说下载器API", |
|
|
description="通过 /download?book_id=xxx 获取小说文本文件", |
|
|
) |
|
|
|
|
|
|
|
|
DOWNLOAD_ROOT = os.path.join(tempfile.gettempdir(), "tomato_downloads") |
|
|
os.makedirs(DOWNLOAD_ROOT, exist_ok=True) |
|
|
app.mount("/files", StaticFiles(directory=DOWNLOAD_ROOT), name="files") |
|
|
|
|
|
|
|
|
@app.on_event("startup") |
|
|
def startup_event(): |
|
|
threading.Thread(target=process_queue, daemon=True).start() |
|
|
|
|
|
@app.get("/", response_class=HTMLResponse) |
|
|
def root(): |
|
|
return """ |
|
|
<!DOCTYPE html> |
|
|
<html lang="zh-CN"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"> |
|
|
<meta name="mobile-web-app-capable" content="yes"> |
|
|
<meta name="apple-mobile-web-app-capable" content="yes"> |
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> |
|
|
<meta name="theme-color" content="#667eea"> |
|
|
<meta name="msapplication-navbutton-color" content="#667eea"> |
|
|
<meta name="apple-mobile-web-app-title" content="番茄小说下载器"> |
|
|
<title>🍅 番茄小说下载器</title> |
|
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"> |
|
|
<style> |
|
|
* { |
|
|
margin: 0; |
|
|
padding: 0; |
|
|
box-sizing: border-box; |
|
|
} |
|
|
|
|
|
body { |
|
|
font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
|
min-height: 100vh; |
|
|
color: #333; |
|
|
line-height: 1.6; |
|
|
font-size: 16px; |
|
|
-webkit-font-smoothing: antialiased; |
|
|
-moz-osx-font-smoothing: grayscale; |
|
|
text-rendering: optimizeLegibility; |
|
|
} |
|
|
|
|
|
.header { |
|
|
text-align: center; |
|
|
padding: 2rem 0; |
|
|
background: rgba(255, 255, 255, 0.1); |
|
|
backdrop-filter: blur(10px); |
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.2); |
|
|
margin-bottom: 2rem; |
|
|
} |
|
|
|
|
|
.header h1 { |
|
|
font-size: 2.5rem; |
|
|
font-weight: 700; |
|
|
color: white; |
|
|
text-shadow: 0 2px 4px rgba(0,0,0,0.3); |
|
|
margin-bottom: 0.5rem; |
|
|
} |
|
|
|
|
|
.header p { |
|
|
color: rgba(255, 255, 255, 0.9); |
|
|
font-size: 1.1rem; |
|
|
} |
|
|
|
|
|
.container { |
|
|
max-width: 1200px; |
|
|
margin: 0 auto; |
|
|
padding: 0 1rem; |
|
|
} |
|
|
|
|
|
.grid { |
|
|
display: grid; |
|
|
grid-template-columns: 1fr 1fr; |
|
|
gap: 2rem; |
|
|
margin-bottom: 2rem; |
|
|
} |
|
|
|
|
|
.card { |
|
|
background: rgba(255, 255, 255, 0.95); |
|
|
backdrop-filter: blur(10px); |
|
|
border-radius: 20px; |
|
|
padding: 2rem; |
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); |
|
|
border: 1px solid rgba(255, 255, 255, 0.2); |
|
|
transition: all 0.3s ease; |
|
|
position: relative; |
|
|
overflow: hidden; |
|
|
} |
|
|
|
|
|
.card:hover { |
|
|
transform: translateY(-5px); |
|
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15); |
|
|
} |
|
|
|
|
|
.card-title { |
|
|
font-size: 1.5rem; |
|
|
font-weight: 600; |
|
|
color: #2d3748; |
|
|
margin-bottom: 1rem; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 0.5rem; |
|
|
} |
|
|
|
|
|
.card-title i { |
|
|
color: #667eea; |
|
|
} |
|
|
|
|
|
.form-group { |
|
|
margin-bottom: 1.5rem; |
|
|
} |
|
|
|
|
|
.form-label { |
|
|
display: block; |
|
|
font-weight: 500; |
|
|
color: #4a5568; |
|
|
margin-bottom: 0.5rem; |
|
|
} |
|
|
|
|
|
.form-input { |
|
|
width: 100%; |
|
|
padding: 0.75rem 1rem; |
|
|
border: 2px solid #e2e8f0; |
|
|
border-radius: 12px; |
|
|
font-size: 1rem; |
|
|
transition: all 0.3s ease; |
|
|
background: white; |
|
|
} |
|
|
|
|
|
.form-input:focus { |
|
|
outline: none; |
|
|
border-color: #667eea; |
|
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); |
|
|
} |
|
|
|
|
|
.form-textarea { |
|
|
resize: vertical; |
|
|
min-height: 120px; |
|
|
} |
|
|
|
|
|
.btn { |
|
|
display: inline-flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
gap: 0.5rem; |
|
|
padding: 0.75rem 1.5rem; |
|
|
border: none; |
|
|
border-radius: 12px; |
|
|
font-size: 1rem; |
|
|
font-weight: 500; |
|
|
cursor: pointer; |
|
|
transition: all 0.3s ease; |
|
|
text-decoration: none; |
|
|
} |
|
|
|
|
|
.btn-primary { |
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
|
color: white; |
|
|
} |
|
|
|
|
|
.btn-primary:hover { |
|
|
transform: translateY(-2px); |
|
|
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3); |
|
|
} |
|
|
|
|
|
.btn-secondary { |
|
|
background: #f7fafc; |
|
|
color: #4a5568; |
|
|
border: 2px solid #e2e8f0; |
|
|
} |
|
|
|
|
|
.btn-secondary:hover { |
|
|
background: #edf2f7; |
|
|
border-color: #cbd5e0; |
|
|
} |
|
|
|
|
|
.btn-small { |
|
|
padding: 0.5rem 1rem; |
|
|
font-size: 0.9rem; |
|
|
} |
|
|
|
|
|
.progress-container { |
|
|
margin-top: 1rem; |
|
|
display: none; |
|
|
} |
|
|
|
|
|
.progress-bar { |
|
|
width: 100%; |
|
|
height: 8px; |
|
|
background: #e2e8f0; |
|
|
border-radius: 4px; |
|
|
overflow: hidden; |
|
|
margin-bottom: 0.5rem; |
|
|
} |
|
|
|
|
|
.progress-fill { |
|
|
height: 100%; |
|
|
background: linear-gradient(90deg, #667eea, #764ba2); |
|
|
border-radius: 4px; |
|
|
transition: width 0.3s ease; |
|
|
width: 0%; |
|
|
} |
|
|
|
|
|
.progress-text { |
|
|
text-align: center; |
|
|
font-size: 0.9rem; |
|
|
color: #4a5568; |
|
|
} |
|
|
|
|
|
.download-result { |
|
|
margin-top: 1rem; |
|
|
} |
|
|
|
|
|
.download-link { |
|
|
display: inline-flex; |
|
|
align-items: center; |
|
|
gap: 0.5rem; |
|
|
padding: 0.75rem 1rem; |
|
|
background: #f0fff4; |
|
|
color: #22543d; |
|
|
text-decoration: none; |
|
|
border-radius: 8px; |
|
|
border: 1px solid #9ae6b4; |
|
|
margin-top: 0.5rem; |
|
|
transition: all 0.3s ease; |
|
|
} |
|
|
|
|
|
.download-link:hover { |
|
|
background: #c6f6d5; |
|
|
transform: translateX(5px); |
|
|
} |
|
|
|
|
|
.list-container { |
|
|
background: white; |
|
|
border-radius: 12px; |
|
|
overflow: hidden; |
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
|
|
} |
|
|
|
|
|
.list-item { |
|
|
padding: 1rem; |
|
|
border-bottom: 1px solid #e2e8f0; |
|
|
display: flex; |
|
|
align-items: flex-start; |
|
|
justify-content: space-between; |
|
|
transition: all 0.3s ease; |
|
|
min-height: 80px; |
|
|
opacity: 1; |
|
|
transform: translateX(0); |
|
|
} |
|
|
|
|
|
.list-item:hover { |
|
|
background: #f7fafc; |
|
|
} |
|
|
|
|
|
.list-item:last-child { |
|
|
border-bottom: none; |
|
|
} |
|
|
|
|
|
.status-badge { |
|
|
padding: 0.25rem 0.75rem; |
|
|
border-radius: 20px; |
|
|
font-size: 0.8rem; |
|
|
font-weight: 500; |
|
|
transition: all 0.3s ease; |
|
|
white-space: nowrap; |
|
|
} |
|
|
|
|
|
.status-queued { |
|
|
background: #fef5e7; |
|
|
color: #744210; |
|
|
} |
|
|
|
|
|
.status-progress { |
|
|
background: #e6fffa; |
|
|
color: #234e52; |
|
|
} |
|
|
|
|
|
.status-done { |
|
|
background: #f0fff4; |
|
|
color: #22543d; |
|
|
} |
|
|
|
|
|
.status-error { |
|
|
background: #fed7d7; |
|
|
color: #742a2a; |
|
|
} |
|
|
|
|
|
.current-download { |
|
|
animation: pulse 2s infinite; |
|
|
} |
|
|
|
|
|
@keyframes pulse { |
|
|
0% { opacity: 1; } |
|
|
50% { opacity: 0.7; } |
|
|
100% { opacity: 1; } |
|
|
} |
|
|
|
|
|
.progress-info { |
|
|
font-size: 0.8rem; |
|
|
color: #4a5568; |
|
|
margin-top: 0.25rem; |
|
|
} |
|
|
|
|
|
.search-container { |
|
|
display: flex; |
|
|
gap: 0.5rem; |
|
|
margin-bottom: 1rem; |
|
|
} |
|
|
|
|
|
.pagination { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
gap: 1rem; |
|
|
padding: 1rem; |
|
|
background: #f7fafc; |
|
|
} |
|
|
|
|
|
.full-width { |
|
|
grid-column: 1 / -1; |
|
|
} |
|
|
|
|
|
/* 响应式排版系统 */ |
|
|
.text-xs { font-size: 0.75rem; line-height: 1.4; } |
|
|
.text-sm { font-size: 0.875rem; line-height: 1.5; } |
|
|
.text-base { font-size: 1rem; line-height: 1.6; } |
|
|
.text-lg { font-size: 1.125rem; line-height: 1.6; } |
|
|
.text-xl { font-size: 1.25rem; line-height: 1.6; } |
|
|
.text-2xl { font-size: 1.5rem; line-height: 1.5; } |
|
|
.text-3xl { font-size: 1.875rem; line-height: 1.4; } |
|
|
|
|
|
/* 改进的间距系统 */ |
|
|
.space-y-1 > * + * { margin-top: 0.25rem; } |
|
|
.space-y-2 > * + * { margin-top: 0.5rem; } |
|
|
.space-y-3 > * + * { margin-top: 0.75rem; } |
|
|
.space-y-4 > * + * { margin-top: 1rem; } |
|
|
.space-y-6 > * + * { margin-top: 1.5rem; } |
|
|
|
|
|
/* 移动端优化 */ |
|
|
@media (max-width: 768px) { |
|
|
.container { |
|
|
padding: 0 0.75rem; |
|
|
} |
|
|
|
|
|
.grid { |
|
|
grid-template-columns: 1fr; |
|
|
gap: 1rem; |
|
|
} |
|
|
|
|
|
.header { |
|
|
padding: 1.5rem 0; |
|
|
margin-bottom: 1.5rem; |
|
|
} |
|
|
|
|
|
.header h1 { |
|
|
font-size: 1.8rem; |
|
|
margin-bottom: 0.25rem; |
|
|
} |
|
|
|
|
|
.header p { |
|
|
font-size: 1rem; |
|
|
} |
|
|
|
|
|
.card { |
|
|
padding: 1.25rem; |
|
|
border-radius: 16px; |
|
|
margin-bottom: 0.5rem; |
|
|
} |
|
|
|
|
|
.card-title { |
|
|
font-size: 1.3rem; |
|
|
margin-bottom: 1.25rem; |
|
|
} |
|
|
|
|
|
/* 触摸友好的按钮 */ |
|
|
.btn { |
|
|
min-height: 48px; |
|
|
padding: 0.875rem 1.5rem; |
|
|
font-size: 1.1rem; |
|
|
border-radius: 14px; |
|
|
touch-action: manipulation; |
|
|
-webkit-tap-highlight-color: transparent; |
|
|
} |
|
|
|
|
|
.btn-small { |
|
|
min-height: 44px; |
|
|
padding: 0.75rem 1.25rem; |
|
|
font-size: 1rem; |
|
|
} |
|
|
|
|
|
/* 表单元素优化 */ |
|
|
.form-input, .form-textarea { |
|
|
min-height: 48px; |
|
|
padding: 0.875rem 1rem; |
|
|
font-size: 1.1rem; |
|
|
border-radius: 12px; |
|
|
touch-action: manipulation; |
|
|
} |
|
|
|
|
|
.form-textarea { |
|
|
min-height: 120px; |
|
|
resize: none; |
|
|
} |
|
|
|
|
|
.search-container { |
|
|
flex-direction: column; |
|
|
gap: 0.75rem; |
|
|
} |
|
|
|
|
|
.search-container .form-input { |
|
|
margin-bottom: 0; |
|
|
} |
|
|
|
|
|
/* 列表项优化 */ |
|
|
.list-item { |
|
|
padding: 1rem; |
|
|
margin-bottom: 0.75rem; |
|
|
border-radius: 12px; |
|
|
flex-direction: column; |
|
|
align-items: stretch; |
|
|
min-height: auto; |
|
|
gap: 0.75rem; |
|
|
} |
|
|
|
|
|
.list-item .status-badge { |
|
|
align-self: flex-start; |
|
|
margin-bottom: 0.5rem; |
|
|
} |
|
|
|
|
|
/* 任务信息布局优化 */ |
|
|
.task-info { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
gap: 0.5rem; |
|
|
flex: 1; |
|
|
} |
|
|
|
|
|
.task-title { |
|
|
font-weight: 600; |
|
|
font-size: 1.1rem; |
|
|
line-height: 1.4; |
|
|
color: #2d3748; |
|
|
} |
|
|
|
|
|
.task-meta { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
gap: 0.25rem; |
|
|
font-size: 0.9rem; |
|
|
color: #718096; |
|
|
} |
|
|
|
|
|
.task-actions { |
|
|
display: flex; |
|
|
gap: 0.5rem; |
|
|
margin-top: 0.5rem; |
|
|
} |
|
|
|
|
|
.task-actions .btn { |
|
|
flex: 1; |
|
|
justify-content: center; |
|
|
} |
|
|
|
|
|
/* 分页控件优化 */ |
|
|
.pagination { |
|
|
padding: 1.25rem 1rem; |
|
|
gap: 1.25rem; |
|
|
flex-wrap: wrap; |
|
|
justify-content: space-between; |
|
|
} |
|
|
|
|
|
.pagination button { |
|
|
min-width: 100px; |
|
|
} |
|
|
|
|
|
#pageInfo { |
|
|
order: -1; |
|
|
width: 100%; |
|
|
text-align: center; |
|
|
margin-bottom: 0.5rem; |
|
|
font-weight: 500; |
|
|
} |
|
|
} |
|
|
|
|
|
/* 小屏幕手机优化 */ |
|
|
@media (max-width: 480px) { |
|
|
.container { |
|
|
padding: 0 0.5rem; |
|
|
} |
|
|
|
|
|
.header { |
|
|
padding: 1rem 0; |
|
|
} |
|
|
|
|
|
.header h1 { |
|
|
font-size: 1.6rem; |
|
|
} |
|
|
|
|
|
.card { |
|
|
padding: 1rem; |
|
|
border-radius: 12px; |
|
|
} |
|
|
|
|
|
.card-title { |
|
|
font-size: 1.2rem; |
|
|
margin-bottom: 1rem; |
|
|
} |
|
|
|
|
|
.btn { |
|
|
padding: 1rem 1.25rem; |
|
|
font-size: 1rem; |
|
|
} |
|
|
|
|
|
.form-input, .form-textarea { |
|
|
padding: 1rem; |
|
|
font-size: 1rem; |
|
|
} |
|
|
} |
|
|
|
|
|
/* 移动端性能优化 */ |
|
|
@media (max-width: 768px) { |
|
|
/* 减少动画以提高性能 */ |
|
|
.card:hover { |
|
|
transform: none; |
|
|
} |
|
|
|
|
|
.btn:hover { |
|
|
transform: none; |
|
|
} |
|
|
|
|
|
.list-item:hover { |
|
|
background: #f7fafc; |
|
|
transform: none; |
|
|
} |
|
|
|
|
|
/* 优化滚动性能 */ |
|
|
.list-container { |
|
|
-webkit-overflow-scrolling: touch; |
|
|
scroll-behavior: smooth; |
|
|
} |
|
|
|
|
|
/* 减少阴影复杂度 */ |
|
|
.card { |
|
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); |
|
|
} |
|
|
|
|
|
.list-container { |
|
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); |
|
|
} |
|
|
|
|
|
/* 优化背景模糊效果 */ |
|
|
.card { |
|
|
backdrop-filter: blur(5px); |
|
|
} |
|
|
|
|
|
.header { |
|
|
backdrop-filter: blur(5px); |
|
|
} |
|
|
} |
|
|
|
|
|
/* 低端设备优化 */ |
|
|
@media (max-width: 480px) and (max-resolution: 150dpi) { |
|
|
/* 完全禁用动画和特效 */ |
|
|
* { |
|
|
animation-duration: 0s !important; |
|
|
transition-duration: 0s !important; |
|
|
} |
|
|
|
|
|
.card { |
|
|
backdrop-filter: none; |
|
|
background: rgba(255, 255, 255, 0.98); |
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
|
|
} |
|
|
|
|
|
.header { |
|
|
backdrop-filter: none; |
|
|
background: rgba(255, 255, 255, 0.15); |
|
|
} |
|
|
} |
|
|
|
|
|
/* 触摸反馈和可访问性 */ |
|
|
@media (max-width: 768px) { |
|
|
/* 触摸反馈 */ |
|
|
.btn:active { |
|
|
transform: scale(0.98); |
|
|
opacity: 0.9; |
|
|
} |
|
|
|
|
|
.list-item:active { |
|
|
background: #edf2f7; |
|
|
} |
|
|
|
|
|
/* 改善焦点可见性 */ |
|
|
.btn:focus, |
|
|
.form-input:focus, |
|
|
.form-textarea:focus { |
|
|
outline: 3px solid #667eea; |
|
|
outline-offset: 2px; |
|
|
} |
|
|
|
|
|
/* 确保文本可读性 */ |
|
|
.card-title { |
|
|
line-height: 1.3; |
|
|
word-wrap: break-word; |
|
|
} |
|
|
|
|
|
.list-item { |
|
|
word-wrap: break-word; |
|
|
overflow-wrap: break-word; |
|
|
} |
|
|
|
|
|
/* 改善按钮间距 */ |
|
|
.search-container .btn { |
|
|
margin-top: 0.5rem; |
|
|
} |
|
|
|
|
|
.task-actions .btn { |
|
|
min-height: 44px; |
|
|
font-size: 0.9rem; |
|
|
} |
|
|
|
|
|
/* 优化表单标签 */ |
|
|
.form-label { |
|
|
font-size: 1rem; |
|
|
margin-bottom: 0.75rem; |
|
|
color: #2d3748; |
|
|
} |
|
|
|
|
|
/* 状态徽章移动端优化 */ |
|
|
.status-badge { |
|
|
font-size: 0.85rem; |
|
|
padding: 0.4rem 0.8rem; |
|
|
border-radius: 16px; |
|
|
} |
|
|
} |
|
|
|
|
|
/* 横屏模式优化 */ |
|
|
@media (max-width: 768px) and (orientation: landscape) { |
|
|
.header { |
|
|
padding: 1rem 0; |
|
|
} |
|
|
|
|
|
.header h1 { |
|
|
font-size: 1.5rem; |
|
|
} |
|
|
|
|
|
.card { |
|
|
padding: 1.25rem; |
|
|
} |
|
|
} |
|
|
|
|
|
/* 高对比度模式支持 */ |
|
|
@media (prefers-contrast: high) { |
|
|
.btn { |
|
|
border: 2px solid currentColor; |
|
|
} |
|
|
|
|
|
.card { |
|
|
border: 2px solid rgba(0, 0, 0, 0.2); |
|
|
} |
|
|
|
|
|
.status-badge { |
|
|
border: 1px solid currentColor; |
|
|
} |
|
|
} |
|
|
|
|
|
/* 减少动画偏好支持 */ |
|
|
@media (prefers-reduced-motion: reduce) { |
|
|
* { |
|
|
animation-duration: 0.01ms !important; |
|
|
animation-iteration-count: 1 !important; |
|
|
transition-duration: 0.01ms !important; |
|
|
} |
|
|
} |
|
|
|
|
|
.loading { |
|
|
display: inline-block; |
|
|
width: 20px; |
|
|
height: 20px; |
|
|
border: 3px solid #f3f3f3; |
|
|
border-top: 3px solid #667eea; |
|
|
border-radius: 50%; |
|
|
animation: spin 1s linear infinite; |
|
|
} |
|
|
|
|
|
@keyframes spin { |
|
|
0% { transform: rotate(0deg); } |
|
|
100% { transform: rotate(360deg); } |
|
|
} |
|
|
|
|
|
.fade-in { |
|
|
animation: fadeIn 0.5s ease-in; |
|
|
} |
|
|
|
|
|
@keyframes fadeIn { |
|
|
from { opacity: 0; transform: translateY(20px); } |
|
|
to { opacity: 1; transform: translateY(0); } |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="header"> |
|
|
<h1><i class="fas fa-book"></i> 番茄小说下载器</h1> |
|
|
<p>高效、便捷的小说下载工具</p> |
|
|
</div> |
|
|
|
|
|
<div class="container"> |
|
|
<div class="grid"> |
|
|
<!-- 下载区域 --> |
|
|
<div class="card"> |
|
|
<div class="card-title"> |
|
|
<i class="fas fa-download"></i> |
|
|
开始下载 |
|
|
</div> |
|
|
|
|
|
<div class="form-group"> |
|
|
<label class="form-label" for="book_ids"> |
|
|
<i class="fas fa-list"></i> 小说ID列表 |
|
|
</label> |
|
|
<textarea |
|
|
id="book_ids" |
|
|
class="form-input form-textarea" |
|
|
placeholder="请输入小说ID,支持多个ID用逗号、分号或空格分隔 例如:12345, 67890" |
|
|
></textarea> |
|
|
</div> |
|
|
|
|
|
<button id="downloadBtn" class="btn btn-primary" style="width: 100%;"> |
|
|
<i class="fas fa-rocket"></i> |
|
|
开始下载 |
|
|
</button> |
|
|
|
|
|
<div id="progress" class="progress-container"> |
|
|
<div class="progress-bar"> |
|
|
<div id="progBar" class="progress-fill"></div> |
|
|
</div> |
|
|
<div id="progText" class="progress-text">0%</div> |
|
|
</div> |
|
|
|
|
|
<div id="downloadResult" class="download-result"></div> |
|
|
</div> |
|
|
|
|
|
<!-- 任务管理中心 --> |
|
|
<div class="card"> |
|
|
<div class="card-title"> |
|
|
<i class="fas fa-tasks"></i> |
|
|
任务管理中心 |
|
|
<button id="refreshTasks" class="btn btn-secondary btn-small" style="margin-left: auto;"> |
|
|
<i class="fas fa-sync-alt"></i> |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
<div class="search-container"> |
|
|
<input |
|
|
type="text" |
|
|
id="taskSearchInput" |
|
|
class="form-input" |
|
|
placeholder="🔍 搜索任务(按小说名称或ID)..." |
|
|
style="flex: 1;" |
|
|
/> |
|
|
<button id="taskSearchBtn" class="btn btn-secondary"> |
|
|
<i class="fas fa-search"></i> |
|
|
搜索 |
|
|
</button> |
|
|
<button id="clearSearchBtn" class="btn btn-secondary" style="display: none;"> |
|
|
<i class="fas fa-times"></i> |
|
|
清除 |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
<div id="taskList" class="list-container"> |
|
|
<div class="list-item"> |
|
|
<span style="color: #a0aec0;">暂无任务</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="pagination"> |
|
|
<button id="prevPage" class="btn btn-secondary btn-small"> |
|
|
<i class="fas fa-chevron-left"></i> |
|
|
上一页 |
|
|
</button> |
|
|
<span id="pageInfo">第1/1页</span> |
|
|
<button id="nextPage" class="btn btn-secondary btn-small"> |
|
|
下一页 |
|
|
<i class="fas fa-chevron-right"></i> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
</div> |
|
|
<script> |
|
|
// 工具函数 |
|
|
function showNotification(message, type = 'info') { |
|
|
// 创建通知元素 |
|
|
const notification = document.createElement('div'); |
|
|
notification.className = `notification notification-${type}`; |
|
|
notification.innerHTML = ` |
|
|
<i class="fas ${getNotificationIcon(type)}"></i> |
|
|
<span>${message}</span> |
|
|
`; |
|
|
|
|
|
// 检测是否为移动设备 |
|
|
const isMobile = window.innerWidth <= 768; |
|
|
|
|
|
// 添加样式 |
|
|
notification.style.cssText = ` |
|
|
position: fixed; |
|
|
${isMobile ? 'top: 20px; left: 16px; right: 16px;' : 'top: 20px; right: 20px;'} |
|
|
padding: ${isMobile ? '16px 20px' : '12px 16px'}; |
|
|
border-radius: ${isMobile ? '12px' : '8px'}; |
|
|
color: white; |
|
|
font-weight: 500; |
|
|
z-index: 1000; |
|
|
${isMobile ? '' : 'max-width: 300px;'} |
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15); |
|
|
transform: ${isMobile ? 'translateY(-100%)' : 'translateX(100%)'}; |
|
|
transition: transform 0.3s ease; |
|
|
background: ${getNotificationColor(type)}; |
|
|
font-size: ${isMobile ? '1rem' : '0.9rem'}; |
|
|
line-height: 1.5; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 12px; |
|
|
min-height: ${isMobile ? '56px' : 'auto'}; |
|
|
`; |
|
|
|
|
|
document.body.appendChild(notification); |
|
|
|
|
|
// 显示动画 |
|
|
setTimeout(() => { |
|
|
notification.style.transform = isMobile ? 'translateY(0)' : 'translateX(0)'; |
|
|
}, 100); |
|
|
|
|
|
// 移动端添加触摸关闭功能 |
|
|
if (isMobile) { |
|
|
let startY = 0; |
|
|
let currentY = 0; |
|
|
let isDragging = false; |
|
|
|
|
|
notification.addEventListener('touchstart', (e) => { |
|
|
startY = e.touches[0].clientY; |
|
|
isDragging = true; |
|
|
notification.style.transition = 'none'; |
|
|
}); |
|
|
|
|
|
notification.addEventListener('touchmove', (e) => { |
|
|
if (!isDragging) return; |
|
|
currentY = e.touches[0].clientY; |
|
|
const deltaY = currentY - startY; |
|
|
if (deltaY < 0) { |
|
|
notification.style.transform = `translateY(${deltaY}px)`; |
|
|
} |
|
|
}); |
|
|
|
|
|
notification.addEventListener('touchend', () => { |
|
|
if (!isDragging) return; |
|
|
isDragging = false; |
|
|
notification.style.transition = 'transform 0.3s ease'; |
|
|
|
|
|
const deltaY = currentY - startY; |
|
|
if (deltaY < -50) { |
|
|
// 向上滑动超过50px则关闭 |
|
|
notification.style.transform = 'translateY(-100%)'; |
|
|
setTimeout(() => { |
|
|
if (notification.parentNode) { |
|
|
notification.parentNode.removeChild(notification); |
|
|
} |
|
|
}, 300); |
|
|
return; |
|
|
} else { |
|
|
// 否则回弹 |
|
|
notification.style.transform = 'translateY(0)'; |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
// 自动隐藏 |
|
|
setTimeout(() => { |
|
|
notification.style.transform = isMobile ? 'translateY(-100%)' : 'translateX(100%)'; |
|
|
setTimeout(() => { |
|
|
if (notification.parentNode) { |
|
|
notification.parentNode.removeChild(notification); |
|
|
} |
|
|
}, 300); |
|
|
}, type === 'error' ? 5000 : (isMobile ? 4000 : 3000)); |
|
|
|
|
|
console.log(`${type.toUpperCase()}: ${message}`); |
|
|
} |
|
|
|
|
|
function getNotificationIcon(type) { |
|
|
const icons = { |
|
|
'success': 'fa-check-circle', |
|
|
'error': 'fa-exclamation-circle', |
|
|
'warning': 'fa-exclamation-triangle', |
|
|
'info': 'fa-info-circle' |
|
|
}; |
|
|
return icons[type] || icons.info; |
|
|
} |
|
|
|
|
|
function getNotificationColor(type) { |
|
|
const colors = { |
|
|
'success': 'linear-gradient(135deg, #10b981, #059669)', |
|
|
'error': 'linear-gradient(135deg, #ef4444, #dc2626)', |
|
|
'warning': 'linear-gradient(135deg, #f59e0b, #d97706)', |
|
|
'info': 'linear-gradient(135deg, #3b82f6, #2563eb)' |
|
|
}; |
|
|
return colors[type] || colors.info; |
|
|
} |
|
|
|
|
|
function setButtonLoading(button, loading = true) { |
|
|
if (loading) { |
|
|
button.disabled = true; |
|
|
const icon = button.querySelector('i'); |
|
|
if (icon) { |
|
|
icon.className = 'fas fa-spinner fa-spin'; |
|
|
} |
|
|
} else { |
|
|
button.disabled = false; |
|
|
const icon = button.querySelector('i'); |
|
|
if (icon && icon.classList.contains('fa-spinner')) { |
|
|
icon.className = 'fas fa-rocket'; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
document.getElementById('downloadBtn').addEventListener('click', async function(){ |
|
|
const book_ids = document.getElementById('book_ids').value.trim(); |
|
|
if (!book_ids) { |
|
|
showNotification('请输入小说ID', 'warning'); |
|
|
return; |
|
|
} |
|
|
|
|
|
const btn = this; |
|
|
setButtonLoading(btn, true); |
|
|
|
|
|
try { |
|
|
// 解析输入的ID |
|
|
const ids = book_ids.split(/[\\s,;]+/).filter(id => id); |
|
|
|
|
|
// 将所有ID加入队列,队列系统会自动管理执行 |
|
|
for (const bid of ids) { |
|
|
await fetch(`/enqueue?book_id=${encodeURIComponent(bid)}`); |
|
|
} |
|
|
|
|
|
// 清空输入框 |
|
|
document.getElementById('book_ids').value = ''; |
|
|
|
|
|
// 立即刷新队列显示 |
|
|
fetchTasks(); |
|
|
showNotification(`已添加 ${ids.length} 个任务到队列,队列系统将自动处理`, 'success'); |
|
|
|
|
|
setButtonLoading(btn, false); |
|
|
} catch (error) { |
|
|
setButtonLoading(btn, false); |
|
|
showNotification('添加任务失败:' + error.message, 'error'); |
|
|
} |
|
|
}); |
|
|
|
|
|
// 全局变量跟踪当前下载状态(仅用于UI显示) |
|
|
let currentDownloadId = null; |
|
|
let lastTasksHash = ''; // 用于检测任务列表是否真的发生了变化 |
|
|
|
|
|
// 任务管理中心的分页和搜索变量 |
|
|
let allTasks = []; // 存储所有任务数据 |
|
|
let filteredTasks = []; // 存储过滤后的任务数据 |
|
|
let currentPage = 1; |
|
|
let tasksPerPage = 10; |
|
|
let currentSearchTerm = ''; |
|
|
// 全局变量存储任务名称和当前任务状态 |
|
|
let NAMES = {}; |
|
|
let currentTasks = new Map(); // 存储当前任务状态,避免不必要的DOM更新 |
|
|
|
|
|
// 简单的哈希函数 |
|
|
function simpleHash(str) { |
|
|
let hash = 0; |
|
|
for (let i = 0; i < str.length; i++) { |
|
|
const char = str.charCodeAt(i); |
|
|
hash = ((hash << 5) - hash) + char; |
|
|
hash = hash & hash; // 转换为32位整数 |
|
|
} |
|
|
return hash; |
|
|
} |
|
|
|
|
|
// 智能刷新队列,支持分页和搜索 |
|
|
function fetchTasks() { |
|
|
fetch('/tasks').then(res => res.json()).then(data => { |
|
|
// 计算数据哈希,检查是否真的发生了变化 |
|
|
const dataHash = simpleHash(JSON.stringify(data.map(task => ({ |
|
|
book_id: task.book_id, |
|
|
status: task.status, |
|
|
start_time: task.start_time, |
|
|
complete_time: task.complete_time |
|
|
})))); |
|
|
|
|
|
// 如果数据没有变化,跳过更新 |
|
|
if (dataHash === lastTasksHash && allTasks.length > 0) { |
|
|
return; |
|
|
} |
|
|
lastTasksHash = dataHash; |
|
|
|
|
|
// 更新全局名称缓存 |
|
|
data.forEach(task => { |
|
|
if (task.name) { |
|
|
NAMES[task.book_id] = task.name; |
|
|
} |
|
|
}); |
|
|
|
|
|
// 更新全局任务数据 |
|
|
allTasks = data; |
|
|
|
|
|
// 应用搜索过滤 |
|
|
applySearchFilter(); |
|
|
|
|
|
// 渲染当前页面 |
|
|
renderTaskPage(); |
|
|
|
|
|
// 更新分页控件 |
|
|
updatePagination(); |
|
|
|
|
|
}).catch(error => { |
|
|
console.error('获取任务列表失败:', error); |
|
|
const container = document.getElementById('taskList'); |
|
|
container.innerHTML = '<div class="list-item"><span style="color: #ef4444;"><i class="fas fa-exclamation-triangle"></i> 获取任务列表失败</span></div>'; |
|
|
}); |
|
|
} |
|
|
|
|
|
// 应用搜索过滤 |
|
|
function applySearchFilter() { |
|
|
if (!currentSearchTerm.trim()) { |
|
|
filteredTasks = [...allTasks]; |
|
|
} else { |
|
|
const searchLower = currentSearchTerm.toLowerCase(); |
|
|
filteredTasks = allTasks.filter(task => { |
|
|
const name = (task.name || '').toLowerCase(); |
|
|
const bookId = task.book_id.toString().toLowerCase(); |
|
|
return name.includes(searchLower) || bookId.includes(searchLower); |
|
|
}); |
|
|
} |
|
|
|
|
|
// 重置到第一页 |
|
|
if (currentPage > Math.ceil(filteredTasks.length / tasksPerPage)) { |
|
|
currentPage = 1; |
|
|
} |
|
|
} |
|
|
|
|
|
// 渲染当前页面的任务 |
|
|
function renderTaskPage() { |
|
|
const container = document.getElementById('taskList'); |
|
|
|
|
|
if (filteredTasks.length === 0) { |
|
|
if (currentSearchTerm.trim()) { |
|
|
container.innerHTML = '<div class="list-item"><span style="color: #a0aec0;"><i class="fas fa-search"></i> 未找到匹配的任务</span></div>'; |
|
|
} else { |
|
|
container.innerHTML = '<div class="list-item"><span style="color: #a0aec0;"><i class="fas fa-inbox"></i> 暂无任务</span></div>'; |
|
|
} |
|
|
return; |
|
|
} |
|
|
|
|
|
// 计算当前页面的任务 |
|
|
const startIndex = (currentPage - 1) * tasksPerPage; |
|
|
const endIndex = startIndex + tasksPerPage; |
|
|
const pageTasks = filteredTasks.slice(startIndex, endIndex); |
|
|
|
|
|
// 渲染任务列表 |
|
|
container.innerHTML = ''; |
|
|
pageTasks.forEach((task, index) => { |
|
|
const taskElement = createTaskElement(task, startIndex + index); |
|
|
container.appendChild(taskElement); |
|
|
}); |
|
|
} |
|
|
|
|
|
// 更新分页控件 |
|
|
function updatePagination() { |
|
|
const totalPages = Math.ceil(filteredTasks.length / tasksPerPage); |
|
|
const pageInfo = document.getElementById('pageInfo'); |
|
|
const prevBtn = document.getElementById('prevPage'); |
|
|
const nextBtn = document.getElementById('nextPage'); |
|
|
|
|
|
pageInfo.textContent = `第${currentPage}/${totalPages}页 (共${filteredTasks.length}个任务)`; |
|
|
|
|
|
prevBtn.disabled = currentPage <= 1; |
|
|
nextBtn.disabled = currentPage >= totalPages; |
|
|
|
|
|
// 更新按钮样式 |
|
|
if (prevBtn.disabled) { |
|
|
prevBtn.style.opacity = '0.5'; |
|
|
prevBtn.style.cursor = 'not-allowed'; |
|
|
} else { |
|
|
prevBtn.style.opacity = '1'; |
|
|
prevBtn.style.cursor = 'pointer'; |
|
|
} |
|
|
|
|
|
if (nextBtn.disabled) { |
|
|
nextBtn.style.opacity = '0.5'; |
|
|
nextBtn.style.cursor = 'not-allowed'; |
|
|
} else { |
|
|
nextBtn.style.opacity = '1'; |
|
|
nextBtn.style.cursor = 'pointer'; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 创建任务元素 |
|
|
function createTaskElement(task, index) { |
|
|
const div = document.createElement('div'); |
|
|
div.className = 'list-item'; |
|
|
div.setAttribute('data-book-id', task.book_id); |
|
|
|
|
|
updateTaskElement(div, task); |
|
|
|
|
|
return div; |
|
|
} |
|
|
|
|
|
// 更新任务元素内容 |
|
|
function updateTaskElement(element, task) { |
|
|
const statusClass = { |
|
|
'queued': 'status-queued', |
|
|
'in-progress': 'status-progress', |
|
|
'done': 'status-done', |
|
|
'error': 'status-error' |
|
|
}[task.status] || 'status-queued'; |
|
|
|
|
|
const statusIcon = { |
|
|
'queued': 'fas fa-clock', |
|
|
'in-progress': 'fas fa-spinner fa-spin', |
|
|
'done': 'fas fa-check-circle', |
|
|
'error': 'fas fa-exclamation-triangle' |
|
|
}[task.status] || 'fas fa-clock'; |
|
|
|
|
|
const statusText = { |
|
|
'queued': '排队中', |
|
|
'in-progress': '下载中', |
|
|
'done': '已完成', |
|
|
'error': '失败' |
|
|
}[task.status] || '未知'; |
|
|
|
|
|
// 如果是当前正在下载的任务,添加特殊标识 |
|
|
const isCurrentDownload = task.book_id === currentDownloadId; |
|
|
const extraClass = isCurrentDownload ? ' current-download' : ''; |
|
|
|
|
|
// 构建任务信息显示 |
|
|
const taskInfo = `${task.name || task.book_id} - ID: [${task.book_id}]`; |
|
|
const timeInfo = `添加时间: ${task.add_time}`; |
|
|
const statusInfo = `状态: ${statusText}`; |
|
|
|
|
|
element.innerHTML = ` |
|
|
<div style="flex: 1;"> |
|
|
<div style="font-weight: 500; margin-bottom: 4px;"> |
|
|
<i class="fas fa-book"></i> ${taskInfo} |
|
|
</div> |
|
|
<div style="font-size: 0.8rem; color: #6b7280; margin-bottom: 2px;"> |
|
|
<i class="fas fa-clock"></i> ${timeInfo} |
|
|
</div> |
|
|
<div style="font-size: 0.8rem; color: #6b7280;"> |
|
|
${task.start_time ? `<i class="fas fa-play"></i> 开始: ${task.start_time}` : ''} |
|
|
${task.complete_time ? `<i class="fas fa-flag-checkered"></i> 完成: ${task.complete_time}` : ''} |
|
|
</div> |
|
|
</div> |
|
|
<div style="display: flex; align-items: center; gap: 8px;"> |
|
|
<span class="status-badge ${statusClass}${extraClass}"> |
|
|
<i class="${statusIcon}"></i> ${statusInfo} |
|
|
</span> |
|
|
${task.status === 'done' ? ` |
|
|
<a href="/files/${task.book_id}/${task.name}.txt" |
|
|
download="${task.name}.txt" |
|
|
class="btn btn-secondary btn-small" |
|
|
title="下载文件"> |
|
|
<i class="fas fa-download"></i> |
|
|
</a> |
|
|
` : ''} |
|
|
</div> |
|
|
`; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 任务管理中心事件监听器 |
|
|
document.getElementById('taskSearchBtn').addEventListener('click', () => { |
|
|
currentSearchTerm = document.getElementById('taskSearchInput').value.trim(); |
|
|
currentPage = 1; |
|
|
applySearchFilter(); |
|
|
renderTaskPage(); |
|
|
updatePagination(); |
|
|
|
|
|
// 显示/隐藏清除按钮 |
|
|
const clearBtn = document.getElementById('clearSearchBtn'); |
|
|
if (currentSearchTerm) { |
|
|
clearBtn.style.display = 'inline-block'; |
|
|
} else { |
|
|
clearBtn.style.display = 'none'; |
|
|
} |
|
|
}); |
|
|
|
|
|
document.getElementById('taskSearchInput').addEventListener('keypress', (e) => { |
|
|
if (e.key === 'Enter') { |
|
|
document.getElementById('taskSearchBtn').click(); |
|
|
} |
|
|
}); |
|
|
|
|
|
document.getElementById('clearSearchBtn').addEventListener('click', () => { |
|
|
document.getElementById('taskSearchInput').value = ''; |
|
|
currentSearchTerm = ''; |
|
|
currentPage = 1; |
|
|
applySearchFilter(); |
|
|
renderTaskPage(); |
|
|
updatePagination(); |
|
|
document.getElementById('clearSearchBtn').style.display = 'none'; |
|
|
}); |
|
|
|
|
|
document.getElementById('prevPage').addEventListener('click', () => { |
|
|
if (currentPage > 1) { |
|
|
currentPage--; |
|
|
renderTaskPage(); |
|
|
updatePagination(); |
|
|
} |
|
|
}); |
|
|
|
|
|
document.getElementById('nextPage').addEventListener('click', () => { |
|
|
const totalPages = Math.ceil(filteredTasks.length / tasksPerPage); |
|
|
if (currentPage < totalPages) { |
|
|
currentPage++; |
|
|
renderTaskPage(); |
|
|
updatePagination(); |
|
|
} |
|
|
}); |
|
|
|
|
|
document.getElementById('refreshTasks').addEventListener('click', () => { |
|
|
const btn = document.getElementById('refreshTasks'); |
|
|
const icon = btn.querySelector('i'); |
|
|
icon.classList.add('fa-spin'); |
|
|
fetchTasks(); |
|
|
setTimeout(() => { |
|
|
icon.classList.remove('fa-spin'); |
|
|
}, 1000); |
|
|
}); |
|
|
|
|
|
// 初始化 |
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
fetchTasks(); |
|
|
|
|
|
// 自动刷新任务列表 |
|
|
setInterval(() => { |
|
|
fetchTasks(); |
|
|
// 更新当前下载任务ID用于UI显示 |
|
|
fetch('/tasks').then(res => res.json()).then(tasks => { |
|
|
const inProgressTasks = tasks.filter(t => t.status === 'in-progress'); |
|
|
currentDownloadId = inProgressTasks.length > 0 ? inProgressTasks[0].book_id : null; |
|
|
}).catch(console.error); |
|
|
}, 2000); |
|
|
|
|
|
// 添加一些交互效果 |
|
|
document.querySelectorAll('.card').forEach(card => { |
|
|
card.addEventListener('mouseenter', () => { |
|
|
card.style.transform = 'translateY(-2px)'; |
|
|
}); |
|
|
card.addEventListener('mouseleave', () => { |
|
|
card.style.transform = 'translateY(0)'; |
|
|
}); |
|
|
}); |
|
|
|
|
|
// 添加键盘快捷键 |
|
|
document.getElementById('book_ids').addEventListener('keypress', (e) => { |
|
|
if (e.key === 'Enter' && !e.shiftKey) { |
|
|
e.preventDefault(); |
|
|
document.getElementById('downloadBtn').click(); |
|
|
} |
|
|
}); |
|
|
}); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
""" |
|
|
|
|
|
@app.get("/enqueue") |
|
|
def enqueue(book_id: str): |
|
|
"""添加到下载队列""" |
|
|
from datetime import datetime |
|
|
|
|
|
|
|
|
if STATUS.get(book_id) in ("queued", "in-progress"): |
|
|
return {"message": "已在队列", "status": STATUS[book_id]} |
|
|
|
|
|
|
|
|
try: |
|
|
name, _, _ = get_book_info(book_id, get_headers()) |
|
|
if name: |
|
|
NAMES[book_id] = name |
|
|
except: |
|
|
NAMES[book_id] = f"小说_{book_id}" |
|
|
|
|
|
|
|
|
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
|
|
TASK_DETAILS[book_id] = { |
|
|
"add_time": current_time, |
|
|
"start_time": None, |
|
|
"complete_time": None, |
|
|
"name": NAMES[book_id] |
|
|
} |
|
|
|
|
|
TASK_QUEUE.put(book_id) |
|
|
STATUS[book_id] = "queued" |
|
|
return {"message": "已添加到下载队列", "status": "queued"} |
|
|
|
|
|
@app.get("/status") |
|
|
def task_status(book_id: str): |
|
|
"""查询下载任务状态""" |
|
|
status = STATUS.get(book_id) |
|
|
if not status: |
|
|
raise HTTPException(status_code=404, detail="任务不存在") |
|
|
data = {"book_id": book_id, "status": status} |
|
|
if status == "done": |
|
|
data["name"] = NAMES[book_id] |
|
|
data["url"] = FILE_PATHS[book_id] |
|
|
return data |
|
|
|
|
|
@app.get("/tasks") |
|
|
def tasks(): |
|
|
"""获取所有任务""" |
|
|
task_list = [] |
|
|
for book_id, status in STATUS.items(): |
|
|
task_detail = TASK_DETAILS.get(book_id, {}) |
|
|
task = { |
|
|
"book_id": book_id, |
|
|
"name": NAMES.get(book_id, book_id), |
|
|
"status": status, |
|
|
"add_time": task_detail.get("add_time", "未知"), |
|
|
"start_time": task_detail.get("start_time"), |
|
|
"complete_time": task_detail.get("complete_time") |
|
|
} |
|
|
task_list.append(task) |
|
|
|
|
|
|
|
|
task_list.sort(key=lambda x: x["add_time"], reverse=True) |
|
|
return task_list |
|
|
|
|
|
@app.get("/search") |
|
|
def search(name: str = ""): |
|
|
"""按名称搜索已下载小说""" |
|
|
results = [] |
|
|
for bid, n in NAMES.items(): |
|
|
if name.lower() in n.lower(): |
|
|
results.append({"book_id": bid, "name": n, "url": FILE_PATHS.get(bid)}) |
|
|
return results |
|
|
|
|
|
@app.get("/download") |
|
|
def download(book_ids: str): |
|
|
|
|
|
ids = [bid.strip() for bid in re.split(r'[\\s,;]+', book_ids) if bid.strip()] |
|
|
if not ids: |
|
|
raise HTTPException(status_code=400, detail="请提供至少一个 book_id") |
|
|
|
|
|
|
|
|
save_path = tempfile.mkdtemp(prefix="tomato_") |
|
|
|
|
|
try: |
|
|
|
|
|
for bid in ids: |
|
|
try: |
|
|
|
|
|
STATUS[bid] = "in-progress" |
|
|
|
|
|
|
|
|
name, _, _ = get_book_info(bid, get_headers()) |
|
|
if not name: |
|
|
name = f"小说_{bid}" |
|
|
NAMES[bid] = name |
|
|
|
|
|
|
|
|
fetch_api_endpoints_from_server() |
|
|
Run(bid, save_path) |
|
|
|
|
|
|
|
|
expected_filename = f"{name}.txt" |
|
|
expected_path = os.path.join(save_path, expected_filename) |
|
|
|
|
|
if os.path.exists(expected_path): |
|
|
|
|
|
dest_dir = os.path.join(DOWNLOAD_ROOT, bid) |
|
|
os.makedirs(dest_dir, exist_ok=True) |
|
|
dst = os.path.join(dest_dir, expected_filename) |
|
|
shutil.copy(expected_path, dst) |
|
|
|
|
|
|
|
|
FILE_PATHS[bid] = f"/files/{bid}/{expected_filename}" |
|
|
STATUS[bid] = "done" |
|
|
else: |
|
|
STATUS[bid] = "error" |
|
|
raise Exception(f"文件未生成: {expected_filename}") |
|
|
|
|
|
except Exception as e: |
|
|
STATUS[bid] = "error" |
|
|
print(f"下载 {bid} 失败: {str(e)}") |
|
|
raise HTTPException(status_code=500, detail=f"下载 {bid} 失败: {str(e)}") |
|
|
|
|
|
|
|
|
txt_files = glob.glob(os.path.join(save_path, "*.txt")) |
|
|
if not txt_files: |
|
|
raise HTTPException(status_code=404, detail="未生成txt文件,下载失败") |
|
|
|
|
|
|
|
|
if len(txt_files) == 1: |
|
|
file_path = txt_files[0] |
|
|
filename = os.path.basename(file_path) |
|
|
return FileResponse( |
|
|
path=file_path, |
|
|
filename=filename, |
|
|
media_type="text/plain; charset=utf-8", |
|
|
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"} |
|
|
) |
|
|
|
|
|
|
|
|
zip_path = os.path.join(save_path, "novels.zip") |
|
|
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: |
|
|
for fpath in txt_files: |
|
|
zf.write(fpath, arcname=os.path.basename(fpath)) |
|
|
|
|
|
return FileResponse( |
|
|
path=zip_path, |
|
|
filename="novels.zip", |
|
|
media_type="application/zip", |
|
|
headers={"Content-Disposition": "attachment; filename=novels.zip"} |
|
|
) |
|
|
|
|
|
except Exception as e: |
|
|
|
|
|
try: |
|
|
shutil.rmtree(save_path) |
|
|
except: |
|
|
pass |
|
|
raise |
|
|
|
|
|
|