更新下载进度,修复不能重新下载的问题
This commit is contained in:
@@ -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` 即可启动,打开网站,按照教程登录即可
|
||||
|
||||
### 环境要求
|
||||
|
||||
|
||||
+182
-21
@@ -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'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: progress
|
||||
});
|
||||
const downloadService = req.backend.getDownloadService();
|
||||
const result = await downloadService.getTaskProgress(taskId);
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
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;
|
||||
@@ -428,12 +428,12 @@ class ArtistService {
|
||||
'User-Agent': 'PixivAndroidApp/5.0.234 (Android 9.0; Pixel 3)'
|
||||
};
|
||||
|
||||
const config = {
|
||||
method,
|
||||
url: `${this.baseURL}${endpoint}`,
|
||||
headers,
|
||||
timeout: 30000
|
||||
};
|
||||
const config = {
|
||||
method,
|
||||
url: `${this.baseURL}${endpoint}`,
|
||||
headers,
|
||||
timeout: 60000 // 增加到60秒
|
||||
};
|
||||
|
||||
if (data) {
|
||||
if (method === 'GET') {
|
||||
|
||||
@@ -331,7 +331,7 @@ class ArtworkService {
|
||||
method,
|
||||
url: `${this.baseURL}${endpoint}`,
|
||||
headers,
|
||||
timeout: 30000
|
||||
timeout: 60000 // 增加到60秒
|
||||
};
|
||||
|
||||
if (data) {
|
||||
|
||||
@@ -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;
|
||||
+239
-518
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
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>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user