Nanny7's picture
移动端界面全面优化
56c70d9
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用逗号、分号或空格分隔&#10;例如: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):
# 解析多个小说ID,使用逗号、分号或空白分隔
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:
# 调用下载核心 - 支持批量ID
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 文件
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 并返回
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