更新下载进度,修复不能重新下载的问题

This commit is contained in:
2025-08-23 12:03:40 +08:00
parent 8d4e479ee1
commit b0179139cc
18 changed files with 1986 additions and 633 deletions
+9 -3
View File
@@ -20,9 +20,15 @@ Pixiv 下载浏览管理器是一个基于 Web 的应用程序,提供以下功
### 便携版下载(如果不想自义定或者是懒)
如果懒得配置环境,可以直接下载便携版(日,我自己用怎么还被当成木马了,算了忽略一下,不放心就自己打包):
- **下载链接**: https://pan.baidu.com/s/1pIdl8eqQSA8jc2RM7HoZfg?pwd=j18v
- **提取码**: j18v
- **使用说明**: 下载后解压,记事本打开start.bat配置代理(看readme有介绍),双击 `start.bat` 即可启动,打开网站,按照教程登录即可
**方式一:百度网盘下载(更新不勤,版本可能比较落后)**
- **下载链接**: https://pan.baidu.com/s/1SNsiDRzrNoHp4BhUBNvr9w?pwd=2yyn 提取码: 2yyn
- **提取码**: 2yyn
**方式二:直接下载(可能比较慢,服务器带宽有限辣)**
- **下载链接**: https://sywb.top/Staticfiles/p%E4%B8%8B%E8%BD%BD%E5%99%A8.rar
**使用说明**: 下载后解压,记事本打开start.bat配置代理(看readme有介绍),双击 `start.bat` 即可启动,打开网站,按照教程登录即可
### 环境要求
+179 -18
View File
@@ -8,13 +8,17 @@ const DownloadService = require('../services/download');
*/
router.post('/artwork/:id', async (req, res) => {
try {
console.log(`收到下载请求: 作品ID ${req.params.id}`);
const { id } = req.params;
const {
size = 'original',
quality = 'high',
format = 'auto'
format = 'auto',
skipExisting = true
} = req.body;
console.log(`下载参数: size=${size}, quality=${quality}, format=${format}, skipExisting=${skipExisting}`);
if (!id || isNaN(parseInt(id))) {
return res.status(400).json({
success: false,
@@ -23,12 +27,16 @@ router.post('/artwork/:id', async (req, res) => {
}
const downloadService = req.backend.getDownloadService();
console.log('开始调用下载服务...');
const result = await downloadService.downloadArtwork(parseInt(id), {
size,
quality,
format
format,
skipExisting
});
console.log('下载服务返回结果:', result);
if (result.success) {
res.json({
success: true,
@@ -41,6 +49,7 @@ router.post('/artwork/:id', async (req, res) => {
});
}
} catch (error) {
console.error('下载路由错误:', error);
res.status(500).json({
success: false,
error: error.message
@@ -161,20 +170,27 @@ router.get('/progress/:taskId', async (req, res) => {
try {
const { taskId } = req.params;
const downloadService = req.backend.getDownloadService();
const progress = downloadService.getTaskProgress(taskId);
if (!progress) {
return res.status(404).json({
if (!taskId) {
return res.status(400).json({
success: false,
error: 'Task not found'
error: 'Task ID is required'
});
}
const downloadService = req.backend.getDownloadService();
const result = await downloadService.getTaskProgress(taskId);
if (result.success) {
res.json({
success: true,
data: progress
data: result.data
});
} else {
res.status(404).json({
success: false,
error: result.error
});
}
} catch (error) {
res.status(500).json({
success: false,
@@ -190,11 +206,11 @@ router.get('/progress/:taskId', async (req, res) => {
router.get('/tasks', async (req, res) => {
try {
const downloadService = req.backend.getDownloadService();
const tasks = downloadService.getAllTasks();
const result = await downloadService.getAllTasks();
res.json({
success: true,
data: tasks
data: result.data
});
} catch (error) {
res.status(500).json({
@@ -205,20 +221,102 @@ router.get('/tasks', async (req, res) => {
});
/**
* 取消下载任务
* POST /api/download/cancel/:taskId
* 暂停任务
* POST /api/download/pause/:taskId
*/
router.post('/cancel/:taskId', async (req, res) => {
router.post('/pause/:taskId', async (req, res) => {
try {
const { taskId } = req.params;
if (!taskId) {
return res.status(400).json({
success: false,
error: 'Task ID is required'
});
}
const downloadService = req.backend.getDownloadService();
const result = await downloadService.pauseTask(taskId);
if (result.success) {
res.json({
success: true,
message: '任务已暂停'
});
} else {
res.status(400).json({
success: false,
error: result.error
});
}
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
/**
* 恢复任务
* POST /api/download/resume/:taskId
*/
router.post('/resume/:taskId', async (req, res) => {
try {
const { taskId } = req.params;
if (!taskId) {
return res.status(400).json({
success: false,
error: 'Task ID is required'
});
}
const downloadService = req.backend.getDownloadService();
const result = await downloadService.resumeTask(taskId);
if (result.success) {
res.json({
success: true,
data: result.data,
message: '任务已恢复'
});
} else {
res.status(400).json({
success: false,
error: result.error
});
}
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
/**
* 取消任务
* DELETE /api/download/cancel/:taskId
*/
router.delete('/cancel/:taskId', async (req, res) => {
try {
const { taskId } = req.params;
if (!taskId) {
return res.status(400).json({
success: false,
error: 'Task ID is required'
});
}
const downloadService = req.backend.getDownloadService();
const result = await downloadService.cancelTask(taskId);
if (result.success) {
res.json({
success: true,
message: 'Task cancelled successfully'
message: '任务已取消'
});
} else {
res.status(400).json({
@@ -240,14 +338,14 @@ router.post('/cancel/:taskId', async (req, res) => {
*/
router.get('/history', async (req, res) => {
try {
const { limit = 50, offset = 0 } = req.query;
const { offset = 0, limit = 50 } = req.query;
const downloadService = req.backend.getDownloadService();
const history = downloadService.getDownloadHistory(parseInt(limit), parseInt(offset));
const result = await downloadService.getDownloadHistory(parseInt(offset), parseInt(limit));
res.json({
success: true,
data: history
data: result.data
});
} catch (error) {
res.status(500).json({
@@ -369,4 +467,67 @@ router.delete('/files', async (req, res) => {
}
});
/**
* SSE端点 - 实时推送下载进度
* GET /api/download/stream/:taskId
*/
router.get('/stream/:taskId', async (req, res) => {
const { taskId } = req.params;
if (!taskId) {
return res.status(400).json({
success: false,
error: 'Task ID is required'
});
}
// 设置SSE头部
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control'
});
const downloadService = req.backend.getDownloadService();
// 创建进度监听器
const progressListener = (task) => {
if (task.id === taskId) {
res.write(`data: ${JSON.stringify({
type: 'progress',
data: task
})}\n\n`);
// 如果任务完成,关闭连接
if (['completed', 'failed', 'cancelled', 'partial'].includes(task.status)) {
res.write(`data: ${JSON.stringify({
type: 'complete',
data: task
})}\n\n`);
res.end();
downloadService.removeProgressListener(taskId, progressListener);
}
}
};
// 注册监听器
downloadService.addProgressListener(taskId, progressListener);
// 立即发送当前状态
const currentTask = downloadService.getTask(taskId);
if (currentTask) {
res.write(`data: ${JSON.stringify({
type: 'progress',
data: currentTask
})}\n\n`);
}
// 客户端断开连接时清理
req.on('close', () => {
downloadService.removeProgressListener(taskId, progressListener);
});
});
module.exports = router;
+1 -1
View File
@@ -432,7 +432,7 @@ class ArtistService {
method,
url: `${this.baseURL}${endpoint}`,
headers,
timeout: 30000
timeout: 60000 // 增加到60秒
};
if (data) {
+1 -1
View File
@@ -331,7 +331,7 @@ class ArtworkService {
method,
url: `${this.baseURL}${endpoint}`,
headers,
timeout: 30000
timeout: 60000 // 增加到60秒
};
if (data) {
+232
View File
@@ -0,0 +1,232 @@
const fs = require('fs-extra');
const path = require('path');
/**
* 下载执行器 - 负责具体的下载逻辑执行
*/
class DownloadExecutor {
constructor(fileManager, taskManager, progressManager, historyManager) {
this.fileManager = fileManager;
this.taskManager = taskManager;
this.progressManager = progressManager;
this.historyManager = historyManager;
}
/**
* 执行单个作品下载
*/
async executeArtworkDownload(task, images, size, artworkDir, artwork) {
try {
// 检查哪些文件已经存在(断点续传)
const existingFiles = new Set();
if (await this.fileManager.directoryExists(artworkDir)) {
const files = await this.fileManager.listDirectory(artworkDir);
for (const file of files) {
if (/\.(jpg|jpeg|png|gif|webp)$/i.test(file)) {
existingFiles.add(file);
}
}
}
// 逐个下载图片,实时更新进度
const results = [];
for (let index = 0; index < images.length; index++) {
if (task.status === 'cancelled') {
break;
}
const image = images[index];
const imageUrl = image[size] || image.original;
const fileName = `${artwork.title || 'Untitled'}_${artwork.id}_${index + 1}${this.fileManager.getFileExtension(imageUrl)}`;
const filePath = path.join(artworkDir, fileName);
// 如果文件已存在,跳过下载
if (existingFiles.has(fileName)) {
task.completed_files++;
task.progress = Math.round((task.completed_files / task.total_files) * 100);
await this.taskManager.saveTasks();
this.progressManager.notifyProgressUpdate(task.id, task);
results.push({ success: true, file: fileName, skipped: true });
continue;
}
try {
await this.fileManager.downloadFile(imageUrl, filePath);
task.completed_files++;
task.progress = Math.round((task.completed_files / task.total_files) * 100);
await this.taskManager.saveTasks();
this.progressManager.notifyProgressUpdate(task.id, task);
results.push({ success: true, file: fileName });
} catch (error) {
task.failed_files++;
console.error(`下载图片失败 ${index + 1}:`, error.message);
this.progressManager.notifyProgressUpdate(task.id, task);
results.push({ success: false, error: error.message });
}
}
// 保存作品信息
const infoPath = path.join(artworkDir, 'artwork_info.json');
await fs.writeJson(infoPath, artwork, { spaces: 2 });
// 更新任务状态
task.status = task.failed_files === 0 ? 'completed' : 'partial';
task.end_time = new Date();
task.progress = 100;
await this.taskManager.saveTasks();
this.progressManager.notifyProgressUpdate(task.id, task);
// 添加到历史记录
const historyItem = {
id: task.id,
type: 'artwork',
artwork_id: task.artwork_id,
artist_name: task.artist_name,
artwork_title: task.artwork_title,
download_path: artworkDir,
total_files: task.total_files,
completed_files: task.completed_files,
failed_files: task.failed_files,
start_time: task.start_time,
end_time: task.end_time,
status: task.status
};
await this.historyManager.addHistoryItem(historyItem);
console.log('下载完成,历史记录已保存:', {
taskId: task.id,
historyLength: this.historyManager.history.length,
tasksCount: this.taskManager.tasks.size
});
} catch (error) {
console.error('异步下载执行失败:', error);
task.status = 'failed';
task.error = error.message;
task.end_time = new Date();
await this.taskManager.saveTasks();
this.progressManager.notifyProgressUpdate(task.id, task);
}
}
/**
* 执行批量下载
*/
async executeBatchDownload(task, artworkIds, options) {
const { concurrent = 3, size = 'original', quality = 'high', format = 'auto' } = options;
try {
const results = [];
// 分批下载
for (let i = 0; i < task.filtered_ids.length; i += concurrent) {
if (task.status === 'cancelled') {
break;
}
const batch = task.filtered_ids.slice(i, i + concurrent);
const batchPromises = batch.map(async (artworkId) => {
try {
// 这里需要调用主下载服务的方法,暂时返回模拟结果
task.completed++;
const result = { artwork_id: artworkId, success: true };
results.push(result);
return result;
} catch (error) {
task.failed++;
const result = { artwork_id: artworkId, success: false, error: error.message };
results.push(result);
return result;
}
});
await Promise.all(batchPromises);
task.progress = Math.round((task.completed / task.total) * 100);
await this.taskManager.saveTasks();
this.progressManager.notifyProgressUpdate(task.id, task);
// 添加延迟避免请求过于频繁
if (i + concurrent < task.filtered_ids.length) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// 更新任务状态
task.status = task.failed === 0 ? 'completed' : 'partial';
task.end_time = new Date();
task.results = results;
await this.taskManager.saveTasks();
this.progressManager.notifyProgressUpdate(task.id, task);
} catch (error) {
task.status = 'failed';
task.error = error.message;
task.end_time = new Date();
await this.taskManager.saveTasks();
this.progressManager.notifyProgressUpdate(task.id, task);
}
}
/**
* 执行作者作品下载
*/
async executeArtistDownload(task, newArtworks, options) {
const { maxConcurrent = 3, size = 'original', quality = 'high', format = 'auto' } = options;
try {
const results = [];
// 分批下载作品
for (let i = 0; i < newArtworks.length; i += maxConcurrent) {
if (task.status === 'cancelled') {
break;
}
const batch = newArtworks.slice(i, i + maxConcurrent);
const batchPromises = batch.map(async (artwork) => {
try {
// 这里需要调用主下载服务的方法,暂时返回模拟结果
task.completed++;
const result = { artwork_id: artwork.id, success: true };
results.push(result);
return result;
} catch (error) {
task.failed++;
const result = { artwork_id: artwork.id, success: false, error: error.message };
results.push(result);
return result;
}
});
await Promise.all(batchPromises);
task.progress = Math.round((task.completed / task.total) * 100);
await this.taskManager.saveTasks();
this.progressManager.notifyProgressUpdate(task.id, task);
// 添加延迟避免请求过于频繁
if (i + maxConcurrent < newArtworks.length) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// 更新任务状态
task.status = task.failed === 0 ? 'completed' : 'partial';
task.end_time = new Date();
task.results = results;
await this.taskManager.saveTasks();
this.progressManager.notifyProgressUpdate(task.id, task);
} catch (error) {
task.status = 'failed';
task.error = error.message;
task.end_time = new Date();
await this.taskManager.saveTasks();
this.progressManager.notifyProgressUpdate(task.id, task);
}
}
}
module.exports = DownloadExecutor;
File diff suppressed because it is too large Load Diff
+239
View File
@@ -0,0 +1,239 @@
const axios = require('axios');
const fs = require('fs-extra');
const path = require('path');
const crypto = require('crypto');
const ConfigManager = require('../config/config-manager');
/**
* 文件管理器 - 负责文件下载、检查和目录管理
*/
class FileManager {
constructor() {
this.configManager = new ConfigManager();
// 下载配置
this.downloadConfig = {
timeout: 300000, // 5分钟超时
chunkSize: 1024 * 1024, // 1MB块大小
retryAttempts: 3, // 重试次数
retryDelay: 2000, // 重试延迟
concurrentDownloads: 3 // 并发下载数
};
}
/**
* 获取当前下载路径
*/
async getDownloadPath() {
try {
const config = await this.configManager.readConfig();
const downloadDir = config.downloadDir || './downloads';
// 如果是相对路径,转换为绝对路径
return path.isAbsolute(downloadDir)
? downloadDir
: path.resolve(process.cwd(), downloadDir);
} catch (error) {
console.error('获取下载路径失败:', error);
// 返回默认路径
return path.resolve(process.cwd(), 'downloads');
}
}
/**
* 计算文件MD5
*/
async calculateFileMD5(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('md5');
const stream = fs.createReadStream(filePath);
stream.on('data', (data) => {
hash.update(data);
});
stream.on('end', () => {
resolve(hash.digest('hex'));
});
stream.on('error', reject);
});
}
/**
* 检查文件完整性
*/
async checkFileIntegrity(filePath, expectedSize = null) {
try {
if (!await fs.pathExists(filePath)) {
return { valid: false, reason: '文件不存在' };
}
const stats = await fs.stat(filePath);
// 检查文件大小
if (expectedSize && stats.size !== expectedSize) {
return { valid: false, reason: '文件大小不匹配', actual: stats.size, expected: expectedSize };
}
// 检查文件是否为空
if (stats.size === 0) {
return { valid: false, reason: '文件为空' };
}
return { valid: true, size: stats.size };
} catch (error) {
return { valid: false, reason: '检查文件失败', error: error.message };
}
}
/**
* 简单的文件下载方法
*/
async downloadFile(url, filePath) {
const response = await axios({
method: 'GET',
url: url,
responseType: 'stream',
headers: {
'Referer': 'https://www.pixiv.net/',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
},
timeout: 60000
});
const writer = fs.createWriteStream(filePath);
response.data.pipe(writer);
return new Promise((resolve, reject) => {
writer.on('finish', resolve);
writer.on('error', (error) => {
// 下载失败时删除文件
fs.unlink(filePath, () => {});
reject(error);
});
});
}
/**
* 获取文件扩展名
*/
getFileExtension(url) {
const match = url.match(/\.([a-zA-Z0-9]+)(\?|$)/);
return match ? `.${match[1]}` : '.jpg';
}
/**
* 获取目录大小
*/
async getDirectorySize(dirPath) {
try {
const files = await fs.readdir(dirPath);
let totalSize = 0;
for (const file of files) {
const filePath = path.join(dirPath, file);
const stat = await fs.stat(filePath);
if (stat.isFile()) {
totalSize += stat.size;
}
}
return totalSize;
} catch (error) {
return 0;
}
}
/**
* 创建安全的目录名
*/
createSafeDirectoryName(name) {
return name.replace(/[<>:"/\\|?*]/g, '_');
}
/**
* 确保目录存在
*/
async ensureDirectory(dirPath) {
await fs.ensureDir(dirPath);
}
/**
* 删除目录
*/
async removeDirectory(dirPath) {
if (await fs.pathExists(dirPath)) {
await fs.remove(dirPath);
}
}
/**
* 检查目录是否存在
*/
async directoryExists(dirPath) {
return await fs.pathExists(dirPath);
}
/**
* 列出目录内容
*/
async listDirectory(dirPath) {
try {
return await fs.readdir(dirPath);
} catch (error) {
return [];
}
}
/**
* 复制文件
*/
async copyFile(src, dest) {
await fs.copy(src, dest);
}
/**
* 移动文件
*/
async moveFile(src, dest) {
await fs.move(src, dest);
}
/**
* 删除文件
*/
async deleteFile(filePath) {
if (await fs.pathExists(filePath)) {
await fs.unlink(filePath);
}
}
/**
* 检查文件是否存在
*/
async fileExists(filePath) {
return await fs.pathExists(filePath);
}
/**
* 获取文件信息
*/
async getFileInfo(filePath) {
try {
const stats = await fs.stat(filePath);
return {
exists: true,
size: stats.size,
created: stats.birthtime,
modified: stats.mtime,
isFile: stats.isFile(),
isDirectory: stats.isDirectory()
};
} catch (error) {
return { exists: false };
}
}
}
module.exports = FileManager;
+158
View File
@@ -0,0 +1,158 @@
const fs = require('fs-extra');
const path = require('path');
/**
* 历史记录管理器 - 负责下载历史的管理
*/
class HistoryManager {
constructor(dataPath) {
this.dataPath = dataPath;
this.historyFile = path.join(dataPath, 'download_history.json');
this.history = [];
this.initialized = false;
}
/**
* 初始化历史记录管理器
*/
async init() {
try {
await fs.ensureDir(this.dataPath);
await this.loadHistory();
this.initialized = true;
console.log('历史记录管理器初始化完成');
} catch (error) {
console.error('历史记录管理器初始化失败:', error);
this.initialized = false;
}
}
/**
* 加载下载历史
*/
async loadHistory() {
try {
if (await fs.pathExists(this.historyFile)) {
this.history = await fs.readJson(this.historyFile);
}
} catch (error) {
console.error('加载下载历史失败:', error);
this.history = [];
}
}
/**
* 保存下载历史
*/
async saveHistory() {
try {
await fs.writeJson(this.historyFile, this.history, { spaces: 2 });
} catch (error) {
console.error('保存下载历史失败:', error);
}
}
/**
* 添加历史记录
*/
async addHistoryItem(item) {
this.history.unshift(item);
await this.saveHistory();
}
/**
* 获取下载历史
*/
getDownloadHistory(offset = 0, limit = 50) {
const start = offset;
const end = offset + limit;
const history = this.history.slice(start, end);
return {
history,
total: this.history.length,
offset,
limit
};
}
/**
* 根据作品ID查找历史记录
*/
findHistoryByArtworkId(artworkId) {
return this.history.find(item => item.artwork_id === artworkId);
}
/**
* 根据作者ID查找历史记录
*/
findHistoryByArtistId(artistId) {
return this.history.filter(item => item.artist_id === artistId);
}
/**
* 删除历史记录
*/
async removeHistoryItem(artworkId) {
const index = this.history.findIndex(item => item.artwork_id === artworkId);
if (index > -1) {
this.history.splice(index, 1);
await this.saveHistory();
return true;
}
return false;
}
/**
* 清理历史记录
*/
async clearHistory() {
this.history = [];
await this.saveHistory();
}
/**
* 获取历史统计信息
*/
getHistoryStats() {
const stats = {
total: this.history.length,
completed: 0,
failed: 0,
partial: 0,
totalFiles: 0,
totalSize: 0
};
for (const item of this.history) {
if (stats.hasOwnProperty(item.status)) {
stats[item.status]++;
}
if (item.completed_files) {
stats.totalFiles += item.completed_files;
}
}
return stats;
}
/**
* 获取最近下载的作品
*/
getRecentDownloads(limit = 10) {
return this.history.slice(0, limit);
}
/**
* 搜索历史记录
*/
searchHistory(query) {
const lowerQuery = query.toLowerCase();
return this.history.filter(item =>
(item.artwork_title && item.artwork_title.toLowerCase().includes(lowerQuery)) ||
(item.artist_name && item.artist_name.toLowerCase().includes(lowerQuery))
);
}
}
module.exports = HistoryManager;
+69
View File
@@ -0,0 +1,69 @@
/**
* 进度管理器 - 负责处理下载进度的监听和通知
*/
class ProgressManager {
constructor() {
// 进度监听器: taskId -> listeners[]
this.progressListeners = new Map();
}
/**
* 添加进度监听器
*/
addProgressListener(taskId, listener) {
if (!this.progressListeners.has(taskId)) {
this.progressListeners.set(taskId, []);
}
this.progressListeners.get(taskId).push(listener);
}
/**
* 移除进度监听器
*/
removeProgressListener(taskId, listener) {
if (this.progressListeners.has(taskId)) {
const listeners = this.progressListeners.get(taskId);
const index = listeners.indexOf(listener);
if (index > -1) {
listeners.splice(index, 1);
}
if (listeners.length === 0) {
this.progressListeners.delete(taskId);
}
}
}
/**
* 通知进度更新
*/
notifyProgressUpdate(taskId, task) {
if (this.progressListeners.has(taskId)) {
const listeners = this.progressListeners.get(taskId);
listeners.forEach(listener => {
try {
listener(task);
} catch (error) {
console.error('进度监听器执行失败:', error);
}
});
}
}
/**
* 获取指定任务的监听器数量
*/
getListenerCount(taskId) {
return this.progressListeners.has(taskId)
? this.progressListeners.get(taskId).length
: 0;
}
/**
* 清理所有监听器
*/
clearAllListeners() {
this.progressListeners.clear();
}
}
module.exports = ProgressManager;
+177
View File
@@ -0,0 +1,177 @@
const fs = require('fs-extra');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
/**
* 任务管理器 - 负责下载任务的生命周期管理
*/
class TaskManager {
constructor(dataPath) {
this.dataPath = dataPath;
this.tasksFile = path.join(dataPath, 'download_tasks.json');
this.tasks = new Map(); // 内存中的任务状态
this.initialized = false;
}
/**
* 初始化任务管理器
*/
async init() {
try {
await fs.ensureDir(this.dataPath);
await this.loadTasks();
this.initialized = true;
console.log('任务管理器初始化完成');
} catch (error) {
console.error('任务管理器初始化失败:', error);
this.initialized = false;
}
}
/**
* 加载任务状态
*/
async loadTasks() {
try {
if (await fs.pathExists(this.tasksFile)) {
const tasksData = await fs.readJson(this.tasksFile);
this.tasks = new Map(Object.entries(tasksData));
// 恢复进行中的任务状态
for (const [taskId, task] of this.tasks) {
if (task.status === 'downloading' || task.status === 'paused') {
task.status = 'paused'; // 重启后暂停所有进行中的任务
}
}
}
} catch (error) {
console.error('加载任务状态失败:', error);
this.tasks = new Map();
}
}
/**
* 保存任务状态
*/
async saveTasks() {
try {
const tasksData = Object.fromEntries(this.tasks);
await fs.writeJson(this.tasksFile, tasksData, { spaces: 2 });
} catch (error) {
console.error('保存任务状态失败:', error);
}
}
/**
* 创建新任务
*/
createTask(type, data) {
const taskId = uuidv4();
const task = {
id: taskId,
type,
status: 'downloading',
progress: 0,
start_time: new Date(),
end_time: null,
error: null,
...data
};
this.tasks.set(taskId, task);
return task;
}
/**
* 获取任务
*/
getTask(taskId) {
return this.tasks.get(taskId);
}
/**
* 更新任务
*/
async updateTask(taskId, updates) {
const task = this.tasks.get(taskId);
if (!task) {
return false;
}
Object.assign(task, updates);
await this.saveTasks();
return true;
}
/**
* 删除任务
*/
async deleteTask(taskId) {
const deleted = this.tasks.delete(taskId);
if (deleted) {
await this.saveTasks();
}
return deleted;
}
/**
* 获取所有任务
*/
getAllTasks() {
return Array.from(this.tasks.values());
}
/**
* 获取指定状态的任务
*/
getTasksByStatus(status) {
return Array.from(this.tasks.values()).filter(task => task.status === status);
}
/**
* 清理已完成的任务
*/
async cleanupCompletedTasks() {
const completedStatuses = ['completed', 'failed', 'cancelled', 'partial'];
let cleanedCount = 0;
for (const [taskId, task] of this.tasks) {
if (completedStatuses.includes(task.status)) {
this.tasks.delete(taskId);
cleanedCount++;
}
}
if (cleanedCount > 0) {
await this.saveTasks();
console.log(`清理了 ${cleanedCount} 个已完成的任务`);
}
return cleanedCount;
}
/**
* 获取任务统计信息
*/
getTaskStats() {
const stats = {
total: this.tasks.size,
downloading: 0,
paused: 0,
completed: 0,
failed: 0,
cancelled: 0,
partial: 0
};
for (const task of this.tasks.values()) {
if (stats.hasOwnProperty(task.status)) {
stats[task.status]++;
}
}
return stats;
}
}
module.exports = TaskManager;
+3 -2
View File
@@ -65,9 +65,9 @@ pause
2. 在浏览器中访问 http://localhost:3000
3. 按 Ctrl+C 停止服务器
## 代理设置
## 代理设置(重要)
如需使用代理,请编辑 \`start.bat\` 文件,修改第6行的端口号:
如需使用代理,请用记事本编辑 \`start.bat\` 文件,修改PROXY_PORT=xxxx的端口号:
- Clash: 7890
- V2Ray: 10809
- Shadowsocks: 1080
@@ -76,6 +76,7 @@ pause
- 首次运行可能需要几秒钟启动时间
- 程序会在当前目录创建数据文件夹
- 没代理或者代理设置错误无法成功登录,注意仔细检查,获取code的时间比较短,记得快速操作
- 支持Windows 10/11 64位系统
`;
BIN
View File
Binary file not shown.
@@ -0,0 +1,416 @@
<template>
<div class="download-progress" v-if="task">
<div class="progress-header">
<h4 class="progress-title">{{ task.artwork_title || '下载中...' }}</h4>
<div class="progress-actions">
<button
v-if="task.status === 'downloading'"
@click="pauseTask"
class="btn btn-sm btn-secondary"
:disabled="loading"
>
暂停
</button>
<button
v-if="task.status === 'paused'"
@click="resumeTask"
class="btn btn-sm btn-primary"
:disabled="loading"
>
恢复
</button>
<button
@click="cancelTask"
class="btn btn-sm btn-danger"
:disabled="loading"
>
取消
</button>
</div>
</div>
<div class="progress-overview">
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: `${task.progress}%` }"
:class="progressClass"
></div>
</div>
<div class="progress-text">
{{ task.progress }}% ({{ task.completed_files }}/{{ task.total_files }})
</div>
</div>
<div class="task-status">
<span class="status-badge" :class="statusClass">
{{ getStatusText(task.status) }}
</span>
<span class="task-time">{{ formatTime(task.start_time) }}</span>
</div>
<!-- 错误信息 -->
<div v-if="task.error" class="task-error">
<span class="error-icon"></span>
{{ task.error }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import downloadService from '@/services/download';
import type { DownloadTask } from '@/types';
interface Props {
task: DownloadTask;
loading?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
update: [task: DownloadTask];
remove: [taskId: string];
}>();
// 计算属性
const progressClass = computed(() => {
if (props.task.status === 'completed') return 'completed';
if (props.task.status === 'failed') return 'failed';
if (props.task.status === 'paused') return 'paused';
return 'downloading';
});
const statusClass = computed(() => {
return `status-${props.task.status}`;
});
// 方法
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
downloading: '下载中',
completed: '已完成',
failed: '失败',
partial: '部分完成',
cancelled: '已取消',
paused: '已暂停'
};
return statusMap[status] || status;
};
const formatTime = (timeString: string) => {
const date = new Date(timeString);
return date.toLocaleString('zh-CN');
};
const pauseTask = async () => {
try {
const response = await downloadService.pauseTask(props.task.id);
if (response.success) {
emit('update', { ...props.task, status: 'paused' });
}
} catch (error) {
console.error('暂停任务失败:', error);
}
};
const resumeTask = async () => {
try {
const response = await downloadService.resumeTask(props.task.id);
if (response.success) {
emit('update', { ...props.task, status: 'downloading' });
}
} catch (error) {
console.error('恢复任务失败:', error);
}
};
const cancelTask = async () => {
try {
const response = await downloadService.cancelTask(props.task.id);
if (response.success) {
emit('remove', props.task.id);
}
} catch (error) {
console.error('取消任务失败:', error);
}
};
</script>
<style scoped>
.download-progress {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
padding: 1.25rem;
margin-bottom: 0;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.progress-title {
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.progress-actions {
display: flex;
gap: 0.5rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
text-decoration: none;
transition: all 0.2s;
border: none;
cursor: pointer;
font-size: 0.875rem;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
min-width: 60px;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #e5e7eb;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #dc2626;
}
.progress-overview {
margin-bottom: 0.75rem;
}
.progress-bar {
width: 100%;
height: 0.375rem;
background: #e5e7eb;
border-radius: 0.25rem;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-fill {
height: 100%;
transition: width 0.3s ease;
}
.progress-fill.downloading {
background: #3b82f6;
}
.progress-fill.completed {
background: #10b981;
}
.progress-fill.failed {
background: #ef4444;
}
.progress-fill.paused {
background: #f59e0b;
}
.progress-text {
font-size: 0.875rem;
color: #6b7280;
text-align: center;
}
.task-status {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.75rem;
font-weight: 500;
}
.status-downloading {
background: #dbeafe;
color: #1d4ed8;
}
.status-completed {
background: #d1fae5;
color: #065f46;
}
.status-failed {
background: #fee2e2;
color: #dc2626;
}
.status-partial {
background: #fef3c7;
color: #d97706;
}
.status-cancelled {
background: #f3f4f6;
color: #6b7280;
}
.status-paused {
background: #fef3c7;
color: #d97706;
}
.task-time {
font-size: 0.75rem;
color: #9ca3af;
}
.files-progress {
border-top: 1px solid #e5e7eb;
padding-top: 1rem;
}
.file-item {
margin-bottom: 0.75rem;
}
.file-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.file-name {
font-size: 0.875rem;
color: #374151;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 0.5rem;
}
.file-status {
font-size: 0.75rem;
font-weight: 500;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
}
.file-status-pending {
background: #f3f4f6;
color: #6b7280;
}
.file-status-downloading {
background: #dbeafe;
color: #1d4ed8;
}
.file-status-completed {
background: #d1fae5;
color: #065f46;
}
.file-status-failed {
background: #fee2e2;
color: #dc2626;
}
.file-progress {
display: flex;
align-items: center;
gap: 0.5rem;
}
.file-progress-bar {
flex: 1;
height: 0.25rem;
background: #f3f4f6;
border-radius: 0.125rem;
overflow: hidden;
}
.file-progress-fill {
height: 100%;
background: #3b82f6;
transition: width 0.3s ease;
}
.file-progress-text {
font-size: 0.75rem;
color: #6b7280;
min-width: 2.5rem;
text-align: right;
}
.file-error {
font-size: 0.75rem;
color: #dc2626;
margin-top: 0.25rem;
}
.task-error {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.5rem;
color: #dc2626;
font-size: 0.875rem;
}
.error-icon {
font-size: 1rem;
}
</style>
+1 -1
View File
@@ -10,7 +10,7 @@ class ApiService {
constructor() {
this.client = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
timeout: 60000, // 增加到60秒
headers: {
'Content-Type': 'application/json',
},
+92 -45
View File
@@ -9,10 +9,60 @@ class DownloadService {
size?: string;
quality?: string;
format?: string;
skipExisting?: boolean;
} = {}) {
return apiService.post(`/api/download/artwork/${artworkId}`, options);
}
/**
* 获取任务进度
*/
async getTaskProgress(taskId: string) {
return apiService.get(`/api/download/progress/${taskId}`);
}
/**
* 获取所有任务
*/
async getAllTasks() {
return apiService.get('/api/download/tasks');
}
/**
* 暂停任务
*/
async pauseTask(taskId: string) {
return apiService.post(`/api/download/pause/${taskId}`);
}
/**
* 恢复任务
*/
async resumeTask(taskId: string) {
return apiService.post(`/api/download/resume/${taskId}`);
}
/**
* 取消任务
*/
async cancelTask(taskId: string) {
return apiService.delete(`/api/download/cancel/${taskId}`);
}
/**
* 获取下载历史
*/
async getDownloadHistory(offset = 0, limit = 50) {
return apiService.get('/api/download/history', { params: { offset, limit } });
}
/**
* 检查作品是否已下载
*/
async checkArtworkDownloaded(artworkId: number) {
return apiService.get(`/api/download/check/${artworkId}`);
}
/**
* 批量下载作品
*/
@@ -42,56 +92,12 @@ class DownloadService {
}
/**
* 获取任务进度
*/
async getTaskProgress(taskId: string) {
return apiService.get(`/api/download/progress/${taskId}`);
}
/**
* 获取所有任务
*/
async getAllTasks() {
return apiService.get('/api/download/tasks');
}
/**
* 取消下载任务
*/
async cancelTask(taskId: string) {
return apiService.post(`/api/download/cancel/${taskId}`);
}
/**
* 获取下载历史
*/
async getDownloadHistory(limit: number = 50, offset: number = 0) {
return apiService.get('/api/download/history', {
params: { limit, offset }
});
}
/**
* 获取下载的文件列表
* 获取已下载的文件列表
*/
async getDownloadedFiles() {
return apiService.get('/api/download/files');
}
/**
* 检查作品是否已下载
*/
async checkArtworkDownloaded(artworkId: number) {
return apiService.get(`/api/download/check/${artworkId}`);
}
/**
* 获取已下载的作品ID列表
*/
async getDownloadedArtworkIds() {
return apiService.get('/api/download/downloaded-ids');
}
/**
* 删除下载的文件
*/
@@ -100,6 +106,47 @@ class DownloadService {
data: { artist, artwork }
});
}
/**
* 获取已下载的作品ID列表
*/
async getDownloadedArtworkIds() {
return apiService.get('/api/download/downloaded-ids');
}
/**
* 使用SSE监听下载进度
*/
streamTaskProgress(taskId: string, onProgress: (task: DownloadTask) => void, onComplete?: () => void) {
const eventSource = new EventSource(`http://localhost:3000/api/download/stream/${taskId}`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'progress') {
onProgress(data.data);
} else if (data.type === 'complete') {
onProgress(data.data);
if (onComplete) {
onComplete();
}
eventSource.close();
}
} catch (error) {
console.error('解析SSE数据失败:', error);
}
};
eventSource.onerror = (error) => {
console.error('SSE连接错误:', error);
eventSource.close();
};
// 返回关闭函数
return () => {
eventSource.close();
};
}
}
export default new DownloadService();
+7 -12
View File
@@ -98,22 +98,17 @@ export interface LoginStatus {
export interface DownloadTask {
id: string;
type: 'artwork' | 'batch' | 'artist';
status: 'downloading' | 'completed' | 'failed' | 'partial' | 'cancelled';
status: 'downloading' | 'completed' | 'failed' | 'partial' | 'cancelled' | 'paused';
progress: number;
total: number;
completed: number;
failed: number;
total_files: number;
completed_files: number;
failed_files: number;
artwork_id?: number;
artist_name?: string;
artwork_title?: string;
start_time: string;
end_time?: string;
error?: string;
artwork_id?: number;
artist_id?: number;
files?: Array<{
path: string;
url: string;
size: string;
filename: string;
}>;
results?: any[];
}
+135 -14
View File
@@ -55,8 +55,12 @@
{{ artwork.is_bookmarked ? '取消收藏' : '收藏' }}
</button>
</div>
</div>
<!-- 下载状态和进度区域 -->
<div class="download-section">
<!-- 下载状态提示 -->
<div v-if="isDownloaded" class="download-status">
<div v-if="isDownloaded && !currentTask" class="download-status">
<div class="status-indicator">
<svg viewBox="0 0 24 24" fill="currentColor" class="status-icon">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
@@ -64,6 +68,15 @@
<span>已下载到本地</span>
</div>
</div>
<!-- 下载进度 -->
<DownloadProgress
v-if="currentTask"
:task="currentTask"
:loading="downloading"
@update="updateTask"
@remove="removeTask"
/>
</div>
<!-- 作者信息 -->
@@ -182,9 +195,10 @@ import artworkService from '@/services/artwork';
import artistService from '@/services/artist';
import downloadService from '@/services/download';
import { useRepositoryStore } from '@/stores/repository';
import type { Artwork } from '@/types';
import type { Artwork, DownloadTask } from '@/types';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
import ErrorMessage from '@/components/common/ErrorMessage.vue';
import DownloadProgress from '@/components/download/DownloadProgress.vue';
const route = useRoute();
const router = useRouter();
@@ -202,6 +216,10 @@ const downloading = ref(false);
const isDownloaded = ref(false);
const checkingDownloadStatus = ref(false);
// 下载任务状态
const currentTask = ref<DownloadTask | null>(null);
const sseConnection = ref<(() => void) | null>(null);
// 导航相关状态
const artistArtworks = ref<Artwork[]>([]);
const currentArtworkIndex = ref(-1);
@@ -255,6 +273,10 @@ const fetchArtworkDetail = async () => {
imageError.value = false;
currentPage.value = 0;
// 清理之前的任务状态
currentTask.value = null;
stopTaskStreaming();
const response = await artworkService.getArtworkDetail(artworkId);
if (response.success && response.data) {
@@ -299,15 +321,40 @@ const handleDownload = async () => {
try {
downloading.value = true;
const response = await downloadService.downloadArtwork(artwork.value.id);
// 如果已经下载过,则强制重新下载(跳过现有文件检查)
const skipExisting = !isDownloaded.value;
const response = await downloadService.downloadArtwork(artwork.value.id, {
skipExisting
});
if (response.success) {
// 可以显示下载成功提示
console.log('下载任务已创建:', response.data);
// 下载完成后重新检查下载状态
setTimeout(() => {
checkDownloadStatus(artwork.value!.id);
}, 2000); // 等待2秒让下载完成
console.log('下载响应:', response.data);
// 检查是否跳过下载
if (response.data.skipped) {
console.log('作品已存在,跳过下载');
// 重新检查下载状态
await checkDownloadStatus(artwork.value.id);
return;
}
// 如果是新任务,开始监听进度
if (response.data.task_id) {
currentTask.value = {
id: response.data.task_id,
type: 'artwork',
status: 'downloading',
progress: 0,
total_files: 0,
completed_files: 0,
failed_files: 0,
artwork_id: artwork.value.id,
start_time: new Date().toISOString()
};
// 开始SSE监听任务进度
startTaskStreaming(response.data.task_id);
}
} else {
throw new Error(response.error || '下载失败');
}
@@ -319,6 +366,72 @@ const handleDownload = async () => {
}
};
// 开始SSE监听任务进度
const startTaskStreaming = (taskId: string) => {
// 清除之前的连接
if (sseConnection.value) {
sseConnection.value();
}
console.log('开始SSE监听任务进度:', taskId);
// 建立SSE连接
sseConnection.value = downloadService.streamTaskProgress(
taskId,
(task) => {
console.log('收到SSE进度更新:', {
taskId,
status: task.status,
progress: task.progress,
completed: task.completed_files,
total: task.total_files
});
currentTask.value = task;
// 如果任务完成,清理连接并检查下载状态
if (['completed', 'failed', 'cancelled', 'partial'].includes(task.status)) {
console.log('任务完成,关闭SSE连接');
stopTaskStreaming();
// 延迟检查下载状态,确保文件写入完成
setTimeout(async () => {
await checkDownloadStatus(artwork.value!.id);
// 清理任务状态,显示下载完成状态
currentTask.value = null;
}, 1000);
}
},
() => {
console.log('SSE连接完成');
stopTaskStreaming();
}
);
};
// 停止SSE监听
const stopTaskStreaming = () => {
if (sseConnection.value) {
sseConnection.value();
sseConnection.value = null;
}
};
// 更新任务状态
const updateTask = (task: DownloadTask) => {
currentTask.value = task;
};
// 移除任务
const removeTask = (taskId: string) => {
if (currentTask.value?.id === taskId) {
currentTask.value = null;
stopTaskStreaming();
}
};
// 收藏/取消收藏
const handleBookmark = () => {
// 这里可以添加收藏功能
@@ -423,6 +536,10 @@ const goBackToArtist = () => {
// 监听路由变化,重新获取作品详情和导航数据
watch(() => route.params.id, () => {
// 清理之前的任务状态
currentTask.value = null;
stopTaskStreaming();
// 重新获取作品详情
fetchArtworkDetail();
@@ -458,9 +575,10 @@ onMounted(() => {
document.addEventListener('keydown', handleKeydown);
});
// 组件卸载时移除事件监听
// 组件卸载时移除事件监听和清理SSE连接
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown);
stopTaskStreaming();
});
</script>
@@ -578,6 +696,10 @@ onUnmounted(() => {
}
.artwork-header {
margin-bottom: 1.5rem;
}
.download-section {
margin-bottom: 2rem;
}
@@ -649,7 +771,7 @@ onUnmounted(() => {
padding: 1.5rem;
background: #f8fafc;
border-radius: 0.75rem;
margin-bottom: 2rem;
margin-bottom: 1.5rem;
}
.artist-avatar {
@@ -814,11 +936,10 @@ onUnmounted(() => {
}
.download-status {
margin-top: 1rem;
padding: 0.75rem 1rem;
padding: 1rem 1.25rem;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 0.5rem;
border-radius: 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
+17 -7
View File
@@ -92,7 +92,7 @@
></div>
</div>
<div class="progress-text">
{{ task.completed }}/{{ task.total }} ({{ task.progress }}%)
{{ task.completed_files }}/{{ task.total_files }} ({{ task.progress }}%)
</div>
</div>
@@ -271,9 +271,9 @@ const getTaskTitle = (task: DownloadTask) => {
if (task.type === 'artwork') {
return `作品 ${task.artwork_id}`;
} else if (task.type === 'artist') {
return `作者 ${task.artist_id}作品`;
return `作者作品`;
} else if (task.type === 'batch') {
return `批量下载 ${task.total} 个作品`;
return `批量下载 ${task.total_files} 个作品`;
}
return '未知任务';
};
@@ -521,6 +521,7 @@ onUnmounted(() => {
border-radius: 0.5rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
overflow: hidden;
margin-top: 1rem;
}
.loading-section,
@@ -559,17 +560,26 @@ onUnmounted(() => {
.tasks-list,
.history-list,
.files-list {
padding: 1rem;
padding: 1.5rem;
}
.task-card,
.history-card,
.file-card {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
background: white;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
}
.task-card:hover,
.history-card:hover,
.file-card:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
border-color: #d1d5db;
}
.task-header,