下载模组更新,新增下载组件,下载监听改为全局,全量改为增量监听
This commit is contained in:
@@ -85,6 +85,16 @@ backend/
|
|||||||
- `GET /api/download/history` - 获取下载历史
|
- `GET /api/download/history` - 获取下载历史
|
||||||
- 参数: `offset`, `limit`
|
- 参数: `offset`, `limit`
|
||||||
|
|
||||||
|
#### 任务管理API(优化版本)
|
||||||
|
|
||||||
|
- `GET /api/download/tasks` - 获取所有任务(完整数据)
|
||||||
|
- `GET /api/download/tasks/active` - 获取活跃任务(下载中或暂停)
|
||||||
|
- `GET /api/download/tasks/summary` - 获取任务摘要(快速状态检查)
|
||||||
|
- `GET /api/download/tasks/changes` - 获取任务变更(增量更新)
|
||||||
|
- 参数: `since` (时间戳,获取指定时间后的变更)
|
||||||
|
- `GET /api/download/tasks/completed` - 获取已完成任务(分页)
|
||||||
|
- 参数: `offset`, `limit`
|
||||||
|
|
||||||
### 代理相关
|
### 代理相关
|
||||||
|
|
||||||
- `GET /api/proxy/image` - 图片代理服务
|
- `GET /api/proxy/image` - 图片代理服务
|
||||||
|
|||||||
@@ -18,9 +18,14 @@ class PixivAuth {
|
|||||||
this.refreshToken = null;
|
this.refreshToken = null;
|
||||||
this.user = null;
|
this.user = null;
|
||||||
this.proxy = proxy;
|
this.proxy = proxy;
|
||||||
|
this.isRefreshing = false;
|
||||||
|
this.failedQueue = [];
|
||||||
|
|
||||||
// 创建 axios 实例,支持代理
|
// 创建 axios 实例,支持代理
|
||||||
this.axiosInstance = this.createAxiosInstance();
|
this.axiosInstance = this.createAxiosInstance();
|
||||||
|
|
||||||
|
// 设置响应拦截器,自动处理token刷新
|
||||||
|
this.setupResponseInterceptor();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,12 +53,102 @@ class PixivAuth {
|
|||||||
return axios.create(config);
|
return axios.create(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置响应拦截器,自动处理token刷新
|
||||||
|
*/
|
||||||
|
setupResponseInterceptor() {
|
||||||
|
this.axiosInstance.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
|
||||||
|
// 如果是401错误且不是刷新token的请求,尝试自动刷新
|
||||||
|
if (error.response?.status === 401 &&
|
||||||
|
!originalRequest._retry &&
|
||||||
|
!originalRequest.url.includes('/auth/token') &&
|
||||||
|
this.refreshToken) {
|
||||||
|
|
||||||
|
if (this.isRefreshing) {
|
||||||
|
// 如果正在刷新,将请求加入队列
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.failedQueue.push({ resolve, reject });
|
||||||
|
}).then(() => {
|
||||||
|
return this.axiosInstance(originalRequest);
|
||||||
|
}).catch((err) => {
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRequest._retry = true;
|
||||||
|
this.isRefreshing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('检测到token过期,正在自动刷新...');
|
||||||
|
const result = await this.refreshAccessToken(this.refreshToken);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// 更新token
|
||||||
|
this.accessToken = result.access_token;
|
||||||
|
this.refreshToken = result.refresh_token;
|
||||||
|
if (result.user) {
|
||||||
|
this.user = result.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理队列中的请求
|
||||||
|
this.processQueue(null, result.access_token);
|
||||||
|
|
||||||
|
// 重试原始请求
|
||||||
|
originalRequest.headers['Authorization'] = `Bearer ${result.access_token}`;
|
||||||
|
return this.axiosInstance(originalRequest);
|
||||||
|
} else {
|
||||||
|
throw new Error('Token刷新失败');
|
||||||
|
}
|
||||||
|
} catch (refreshError) {
|
||||||
|
console.error('自动刷新token失败:', refreshError.message);
|
||||||
|
this.processQueue(refreshError, null);
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
} finally {
|
||||||
|
this.isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理失败的请求队列
|
||||||
|
*/
|
||||||
|
processQueue(error, token = null) {
|
||||||
|
this.failedQueue.forEach(({ resolve, reject }) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
resolve(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.failedQueue = [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置代理
|
* 设置代理
|
||||||
*/
|
*/
|
||||||
setProxy(proxy) {
|
setProxy(proxy) {
|
||||||
this.proxy = proxy;
|
this.proxy = proxy;
|
||||||
this.axiosInstance = this.createAxiosInstance();
|
this.axiosInstance = this.createAxiosInstance();
|
||||||
|
this.setupResponseInterceptor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步token状态(从外部配置更新)
|
||||||
|
*/
|
||||||
|
syncTokens(accessToken, refreshToken, user = null) {
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
this.refreshToken = refreshToken;
|
||||||
|
if (user) {
|
||||||
|
this.user = user;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+46
-1
@@ -41,6 +41,15 @@ class PixivBackend {
|
|||||||
// 创建认证实例,传入代理配置
|
// 创建认证实例,传入代理配置
|
||||||
this.auth = new PixivAuth(this.config.proxy);
|
this.auth = new PixivAuth(this.config.proxy);
|
||||||
|
|
||||||
|
// 同步已保存的token状态
|
||||||
|
if (this.config.access_token && this.config.refresh_token) {
|
||||||
|
this.auth.syncTokens(
|
||||||
|
this.config.access_token,
|
||||||
|
this.config.refresh_token,
|
||||||
|
this.config.user
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 创建下载服务实例
|
// 创建下载服务实例
|
||||||
this.downloadService = new DownloadService(this.auth);
|
this.downloadService = new DownloadService(this.auth);
|
||||||
await this.downloadService.init();
|
await this.downloadService.init();
|
||||||
@@ -53,9 +62,26 @@ class PixivBackend {
|
|||||||
console.log('未检测到登录信息,需要先登录');
|
console.log('未检测到登录信息,需要先登录');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 启动token同步定时任务
|
||||||
|
this.startTokenSyncTask();
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动token同步定时任务
|
||||||
|
*/
|
||||||
|
startTokenSyncTask() {
|
||||||
|
// 每5分钟同步一次token状态到配置文件
|
||||||
|
setInterval(() => {
|
||||||
|
if (this.auth && this.isLoggedIn) {
|
||||||
|
this.syncTokensToConfig();
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000); // 5分钟
|
||||||
|
|
||||||
|
console.log('Token同步定时任务已启动');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化配置文件
|
* 初始化配置文件
|
||||||
*/
|
*/
|
||||||
@@ -173,6 +199,13 @@ class PixivBackend {
|
|||||||
this.config.user = result.user;
|
this.config.user = result.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同步到auth实例
|
||||||
|
this.auth.syncTokens(
|
||||||
|
result.access_token,
|
||||||
|
result.refresh_token,
|
||||||
|
result.user
|
||||||
|
);
|
||||||
|
|
||||||
this.saveConfig();
|
this.saveConfig();
|
||||||
|
|
||||||
this.isLoggedIn = true;
|
this.isLoggedIn = true;
|
||||||
@@ -260,12 +293,24 @@ class PixivBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取认证实例(用于后续API调用)
|
* 获取认证实例
|
||||||
*/
|
*/
|
||||||
getAuth() {
|
getAuth() {
|
||||||
return this.auth;
|
return this.auth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步token状态到配置
|
||||||
|
*/
|
||||||
|
syncTokensToConfig() {
|
||||||
|
if (this.auth) {
|
||||||
|
this.config.access_token = this.auth.accessToken;
|
||||||
|
this.config.refresh_token = this.auth.refreshToken;
|
||||||
|
this.config.user = this.auth.user;
|
||||||
|
this.saveConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取下载服务实例
|
* 获取下载服务实例
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -121,4 +121,43 @@ router.post('/logout', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动刷新token
|
||||||
|
* POST /api/auth/refresh-token
|
||||||
|
*/
|
||||||
|
router.post('/refresh-token', async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.backend.config.refresh_token) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: '没有可用的刷新令牌'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await req.backend.relogin();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Token刷新成功',
|
||||||
|
data: {
|
||||||
|
isLoggedIn: req.backend.isLoggedIn,
|
||||||
|
username: req.backend.config.user?.account
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: result.error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('手动刷新token失败:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -273,6 +273,92 @@ router.get('/tasks', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取活跃任务(下载中或暂停)
|
||||||
|
* GET /api/download/tasks/active
|
||||||
|
*/
|
||||||
|
router.get('/tasks/active', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const downloadService = req.backend.getDownloadService();
|
||||||
|
const result = await downloadService.getActiveTasks();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务摘要(用于快速状态检查)
|
||||||
|
* GET /api/download/tasks/summary
|
||||||
|
*/
|
||||||
|
router.get('/tasks/summary', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const downloadService = req.backend.getDownloadService();
|
||||||
|
const result = await downloadService.getTasksSummary();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务变更(增量更新)
|
||||||
|
* GET /api/download/tasks/changes?since=timestamp
|
||||||
|
*/
|
||||||
|
router.get('/tasks/changes', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { since } = req.query;
|
||||||
|
const downloadService = req.backend.getDownloadService();
|
||||||
|
const result = await downloadService.getTasksChanges(since ? parseInt(since) : null);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已完成任务(分页)
|
||||||
|
* GET /api/download/tasks/completed?offset=0&limit=50
|
||||||
|
*/
|
||||||
|
router.get('/tasks/completed', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { offset = 0, limit = 50 } = req.query;
|
||||||
|
const downloadService = req.backend.getDownloadService();
|
||||||
|
const result = await downloadService.getCompletedTasks(parseInt(offset), parseInt(limit));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 暂停任务
|
* 暂停任务
|
||||||
* POST /api/download/pause/:taskId
|
* POST /api/download/pause/:taskId
|
||||||
@@ -548,6 +634,11 @@ router.get('/stream/:taskId', async (req, res) => {
|
|||||||
// 创建进度监听器
|
// 创建进度监听器
|
||||||
const progressListener = (task) => {
|
const progressListener = (task) => {
|
||||||
if (task.id === taskId) {
|
if (task.id === taskId) {
|
||||||
|
// 使用setImmediate避免阻塞事件循环
|
||||||
|
setImmediate(() => {
|
||||||
|
try {
|
||||||
|
// 检查连接是否仍然有效
|
||||||
|
if (!res.destroyed) {
|
||||||
res.write(`data: ${JSON.stringify({
|
res.write(`data: ${JSON.stringify({
|
||||||
type: 'progress',
|
type: 'progress',
|
||||||
data: task
|
data: task
|
||||||
@@ -562,6 +653,13 @@ router.get('/stream/:taskId', async (req, res) => {
|
|||||||
res.end();
|
res.end();
|
||||||
downloadService.removeProgressListener(taskId, progressListener);
|
downloadService.removeProgressListener(taskId, progressListener);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SSE写入失败:', error);
|
||||||
|
// 连接可能已断开,清理监听器
|
||||||
|
downloadService.removeProgressListener(taskId, progressListener);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -42,8 +42,11 @@ function customLogger(req, res, next) {
|
|||||||
// 过滤掉图片代理请求
|
// 过滤掉图片代理请求
|
||||||
const isImageProxy = req.path === '/api/proxy/image';
|
const isImageProxy = req.path === '/api/proxy/image';
|
||||||
|
|
||||||
// 只记录API请求和重要请求,排除静态资源和图片代理
|
// 过滤掉下载任务状态查询请求
|
||||||
if (!isStaticResource && !isImageProxy) {
|
const isDownloadTasksQuery = req.path === '/api/download/tasks';
|
||||||
|
|
||||||
|
// 只记录API请求和重要请求,排除静态资源、图片代理和下载任务查询
|
||||||
|
if (!isStaticResource && !isImageProxy && !isDownloadTasksQuery) {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
||||||
// 原始响应结束方法
|
// 原始响应结束方法
|
||||||
|
|||||||
@@ -370,8 +370,8 @@ class ArtistService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 发送API请求
|
// 使用auth实例的axiosInstance发送请求,这样可以利用自动token刷新机制
|
||||||
const response = await axios(config);
|
const response = await this.auth.axiosInstance(config);
|
||||||
const responseData = response.data;
|
const responseData = response.data;
|
||||||
|
|
||||||
// 对于GET请求,将响应数据缓存
|
// 对于GET请求,将响应数据缓存
|
||||||
|
|||||||
@@ -407,8 +407,8 @@ class ArtworkService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送API请求
|
// 使用auth实例的axiosInstance发送请求,这样可以利用自动token刷新机制
|
||||||
const response = await axios(config);
|
const response = await this.auth.axiosInstance(config);
|
||||||
const responseData = response.data;
|
const responseData = response.data;
|
||||||
|
|
||||||
// 对于GET请求,将响应数据缓存
|
// 对于GET请求,将响应数据缓存
|
||||||
|
|||||||
@@ -98,6 +98,106 @@ class DownloadService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取活跃任务(下载中或暂停)
|
||||||
|
*/
|
||||||
|
async getActiveTasks() {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: this.taskManager.getActiveTasks(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务摘要(用于快速状态检查)
|
||||||
|
*/
|
||||||
|
async getTasksSummary() {
|
||||||
|
const allTasks = this.taskManager.getAllTasks();
|
||||||
|
const activeTasks = this.taskManager.getActiveTasks();
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
total: allTasks.length,
|
||||||
|
active: activeTasks.length,
|
||||||
|
downloading: activeTasks.filter(t => t.status === 'downloading').length,
|
||||||
|
paused: activeTasks.filter(t => t.status === 'paused').length,
|
||||||
|
completed: allTasks.filter(t => t.status === 'completed').length,
|
||||||
|
failed: allTasks.filter(t => t.status === 'failed').length,
|
||||||
|
cancelled: allTasks.filter(t => t.status === 'cancelled').length,
|
||||||
|
partial: allTasks.filter(t => t.status === 'partial').length,
|
||||||
|
lastUpdate: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务变更(增量更新)
|
||||||
|
*/
|
||||||
|
async getTasksChanges(since = null) {
|
||||||
|
const allTasks = this.taskManager.getAllTasks();
|
||||||
|
|
||||||
|
if (!since) {
|
||||||
|
// 如果没有since参数,返回所有活跃任务
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
tasks: this.taskManager.getActiveTasks(),
|
||||||
|
lastUpdate: Date.now()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤出自指定时间后有变更的任务
|
||||||
|
const changedTasks = allTasks.filter(task => {
|
||||||
|
const lastModified = Math.max(
|
||||||
|
new Date(task.created_at).getTime(),
|
||||||
|
task.updated_at ? new Date(task.updated_at).getTime() : 0,
|
||||||
|
task.end_time ? new Date(task.end_time).getTime() : 0
|
||||||
|
);
|
||||||
|
return lastModified > since;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
tasks: changedTasks,
|
||||||
|
lastUpdate: Date.now()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已完成任务(分页)
|
||||||
|
*/
|
||||||
|
async getCompletedTasks(offset = 0, limit = 50) {
|
||||||
|
const allTasks = this.taskManager.getAllTasks();
|
||||||
|
const completedTasks = allTasks.filter(task =>
|
||||||
|
['completed', 'failed', 'cancelled', 'partial'].includes(task.status)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 按完成时间倒序排列
|
||||||
|
completedTasks.sort((a, b) => {
|
||||||
|
const timeA = a.end_time ? new Date(a.end_time).getTime() : 0;
|
||||||
|
const timeB = b.end_time ? new Date(b.end_time).getTime() : 0;
|
||||||
|
return timeB - timeA;
|
||||||
|
});
|
||||||
|
|
||||||
|
const paginatedTasks = completedTasks.slice(offset, offset + limit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
tasks: paginatedTasks,
|
||||||
|
total: completedTasks.length,
|
||||||
|
offset,
|
||||||
|
limit
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async cancelTask(taskId) {
|
async cancelTask(taskId) {
|
||||||
const task = this.taskManager.getTask(taskId);
|
const task = this.taskManager.getTask(taskId);
|
||||||
if (!task) {
|
if (!task) {
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ class ProgressManager {
|
|||||||
constructor() {
|
constructor() {
|
||||||
// 进度监听器: taskId -> listeners[]
|
// 进度监听器: taskId -> listeners[]
|
||||||
this.progressListeners = new Map();
|
this.progressListeners = new Map();
|
||||||
|
// 节流控制: taskId -> { lastUpdate, pending }
|
||||||
|
this.throttleControl = new Map();
|
||||||
|
// 节流间隔(毫秒)
|
||||||
|
this.throttleInterval = 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,14 +33,56 @@ class ProgressManager {
|
|||||||
}
|
}
|
||||||
if (listeners.length === 0) {
|
if (listeners.length === 0) {
|
||||||
this.progressListeners.delete(taskId);
|
this.progressListeners.delete(taskId);
|
||||||
|
// 清理节流控制
|
||||||
|
this.throttleControl.delete(taskId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通知进度更新
|
* 通知进度更新(带节流)
|
||||||
*/
|
*/
|
||||||
notifyProgressUpdate(taskId, task) {
|
notifyProgressUpdate(taskId, task) {
|
||||||
|
if (!this.progressListeners.has(taskId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const throttleInfo = this.throttleControl.get(taskId);
|
||||||
|
|
||||||
|
// 如果是重要状态变更(完成、失败、取消),立即通知
|
||||||
|
const isImportantStatus = ['completed', 'failed', 'cancelled', 'partial', 'paused'].includes(task.status);
|
||||||
|
|
||||||
|
if (isImportantStatus) {
|
||||||
|
// 立即通知重要状态变更
|
||||||
|
this._executeListeners(taskId, task);
|
||||||
|
// 清理节流控制
|
||||||
|
this.throttleControl.delete(taskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对于普通进度更新,使用节流
|
||||||
|
if (!throttleInfo || (now - throttleInfo.lastUpdate) >= this.throttleInterval) {
|
||||||
|
// 立即通知
|
||||||
|
this._executeListeners(taskId, task);
|
||||||
|
this.throttleControl.set(taskId, { lastUpdate: now, pending: false });
|
||||||
|
} else if (!throttleInfo.pending) {
|
||||||
|
// 延迟通知
|
||||||
|
throttleInfo.pending = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
const currentThrottleInfo = this.throttleControl.get(taskId);
|
||||||
|
if (currentThrottleInfo && currentThrottleInfo.pending) {
|
||||||
|
this._executeListeners(taskId, task);
|
||||||
|
this.throttleControl.delete(taskId);
|
||||||
|
}
|
||||||
|
}, this.throttleInterval - (now - throttleInfo.lastUpdate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行监听器(内部方法)
|
||||||
|
*/
|
||||||
|
_executeListeners(taskId, task) {
|
||||||
if (this.progressListeners.has(taskId)) {
|
if (this.progressListeners.has(taskId)) {
|
||||||
const listeners = this.progressListeners.get(taskId);
|
const listeners = this.progressListeners.get(taskId);
|
||||||
listeners.forEach(listener => {
|
listeners.forEach(listener => {
|
||||||
|
|||||||
@@ -1,16 +1,33 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink, RouterView } from 'vue-router'
|
import { RouterLink, RouterView } from 'vue-router'
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useDownloadStore } from '@/stores/download'
|
||||||
import SettingsWidget from '@/components/common/SettingsWidget.vue'
|
import SettingsWidget from '@/components/common/SettingsWidget.vue'
|
||||||
|
import DownloadProgressWidget from '@/components/common/DownloadProgressWidget.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const downloadStore = useDownloadStore()
|
||||||
|
|
||||||
const isLoggedIn = computed(() => authStore.isLoggedIn)
|
const isLoggedIn = computed(() => authStore.isLoggedIn)
|
||||||
const username = computed(() => authStore.username)
|
const username = computed(() => authStore.username)
|
||||||
|
|
||||||
|
// 在下载管理页面隐藏下载进度小组件
|
||||||
|
const showDownloadWidget = computed(() => {
|
||||||
|
return isLoggedIn.value && route.path !== '/downloads'
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await authStore.fetchLoginStatus()
|
await authStore.fetchLoginStatus()
|
||||||
|
|
||||||
|
// 如果已登录,初始化下载store
|
||||||
|
if (authStore.isLoggedIn) {
|
||||||
|
await downloadStore.fetchTasks()
|
||||||
|
// 启动定期刷新
|
||||||
|
downloadStore.startRefreshInterval()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -72,6 +89,9 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<!-- 设置小组件 - 只在登录时显示 -->
|
<!-- 设置小组件 - 只在登录时显示 -->
|
||||||
<SettingsWidget v-if="isLoggedIn" />
|
<SettingsWidget v-if="isLoggedIn" />
|
||||||
|
|
||||||
|
<!-- 下载进度小组件 - 只在登录时显示,在下载管理页面隐藏 -->
|
||||||
|
<DownloadProgressWidget v-if="showDownloadWidget" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,591 @@
|
|||||||
|
<template>
|
||||||
|
<div class="download-widget" :class="{ expanded: isExpanded }">
|
||||||
|
<!-- 小圆点指示器 -->
|
||||||
|
<div class="widget-indicator" @click="toggleExpanded" :class="indicatorClass">
|
||||||
|
<div class="indicator-icon">
|
||||||
|
<svg v-if="activeTasks.length === 0" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div v-if="activeTasks.length > 0" class="task-count">{{ activeTasks.length }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 展开的进度面板 -->
|
||||||
|
<div v-if="isExpanded" class="widget-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3>下载进度</h3>
|
||||||
|
<button @click="toggleExpanded" class="close-btn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-content">
|
||||||
|
<div v-if="downloadStore.loading" class="loading-section">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="activeTasks.length === 0" class="empty-section">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" class="empty-icon">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||||
|
</svg>
|
||||||
|
<span>暂无下载任务</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="tasks-list">
|
||||||
|
<div v-for="task in activeTasks" :key="task.id" class="task-item">
|
||||||
|
<div class="task-header">
|
||||||
|
<div class="task-info">
|
||||||
|
<h4 class="task-title">{{ getTaskTitle(task) }}</h4>
|
||||||
|
<span class="task-status" :class="task.status">
|
||||||
|
{{ getStatusText(task.status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-progress">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" :style="{ width: `${task.progress}%` }"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-text">
|
||||||
|
{{ task.completed_files }}/{{ task.total_files }} ({{ task.progress }}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 批量下载的详细进度 -->
|
||||||
|
<div v-if="task.type === 'batch' || task.type === 'artist'" class="batch-progress">
|
||||||
|
<div class="batch-stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">已完成:</span>
|
||||||
|
<span class="stat-value success">{{ task.completed_files }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">失败:</span>
|
||||||
|
<span class="stat-value error">{{ task.failed_files }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">剩余:</span>
|
||||||
|
<span class="stat-value">{{ task.total_files - task.completed_files - task.failed_files }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 最近完成的作品列表 -->
|
||||||
|
<div v-if="task.recent_completed && task.recent_completed.length > 0" class="recent-completed">
|
||||||
|
<h5>最近完成:</h5>
|
||||||
|
<div class="completed-list">
|
||||||
|
<div v-for="item in task.recent_completed.slice(0, 3)" :key="item.artwork_id" class="completed-item">
|
||||||
|
<span class="artwork-id">#{{ item.artwork_id }}</span>
|
||||||
|
<span v-if="item.artwork_title" class="artwork-title">{{ item.artwork_title }}</span>
|
||||||
|
<span v-if="item.artist_name" class="artist-name">by {{ item.artist_name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-details">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="label">类型:</span>
|
||||||
|
<span class="value">{{ getTypeText(task.type) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="label">开始时间:</span>
|
||||||
|
<span class="value">{{ formatDate(task.start_time) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="task.error" class="detail-item">
|
||||||
|
<span class="label">错误:</span>
|
||||||
|
<span class="value error">{{ task.error }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { useDownloadStore } from '@/stores/download';
|
||||||
|
import type { DownloadTask } from '@/types';
|
||||||
|
|
||||||
|
// 使用Pinia store
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const isExpanded = ref(false);
|
||||||
|
|
||||||
|
// 计算属性:显示活跃任务和暂停任务
|
||||||
|
const activeTasks = computed(() => downloadStore.activeTasks);
|
||||||
|
|
||||||
|
// 指示器样式类
|
||||||
|
const indicatorClass = computed(() => {
|
||||||
|
if (activeTasks.value.length === 0) return 'idle';
|
||||||
|
const hasDownloading = activeTasks.value.some(task => task.status === 'downloading');
|
||||||
|
return hasDownloading ? 'downloading' : 'paused';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取任务标题
|
||||||
|
const getTaskTitle = (task: DownloadTask) => {
|
||||||
|
if (task.type === 'artwork') {
|
||||||
|
return task.artwork_title || `作品 ${task.artwork_id}`;
|
||||||
|
} else if (task.type === 'artist') {
|
||||||
|
return `作者作品 - ${task.artist_name || '未知作者'}`;
|
||||||
|
} else if (task.type === 'batch') {
|
||||||
|
return `批量下载 (${task.total_files} 个作品)`;
|
||||||
|
}
|
||||||
|
return '未知任务';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取状态文本
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
const statusMap: Record<string, string> = {
|
||||||
|
'downloading': '下载中',
|
||||||
|
'completed': '已完成',
|
||||||
|
'failed': '失败',
|
||||||
|
'cancelled': '已取消',
|
||||||
|
'partial': '部分完成',
|
||||||
|
'paused': '已暂停'
|
||||||
|
};
|
||||||
|
return statusMap[status] || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取类型文本
|
||||||
|
const getTypeText = (type: string) => {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
'artwork': '单个作品',
|
||||||
|
'artist': '作者作品',
|
||||||
|
'batch': '批量下载',
|
||||||
|
'ranking': '排行榜下载'
|
||||||
|
};
|
||||||
|
return typeMap[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
if (!dateString) return '未知时间';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return '时间格式错误';
|
||||||
|
}
|
||||||
|
return date.toLocaleString('zh-CN');
|
||||||
|
} catch (error) {
|
||||||
|
return '时间解析失败';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换展开状态
|
||||||
|
const toggleExpanded = () => {
|
||||||
|
isExpanded.value = !isExpanded.value;
|
||||||
|
if (isExpanded.value) {
|
||||||
|
// 展开时刷新任务列表
|
||||||
|
downloadStore.fetchTasks();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 初始获取任务列表
|
||||||
|
await downloadStore.fetchTasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// 组件卸载时不需要清理SSE连接,因为store会统一管理
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.download-widget {
|
||||||
|
position: fixed;
|
||||||
|
top: 4.5rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 1000;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 指示器样式 */
|
||||||
|
.widget-indicator {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-indicator:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-indicator.idle {
|
||||||
|
border-color: #e5e7eb;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-indicator.downloading {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #3b82f6;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-indicator.paused {
|
||||||
|
border-color: #f59e0b;
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-icon {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-count {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.25rem;
|
||||||
|
right: -0.25rem;
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 2px solid white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 面板样式 */
|
||||||
|
.widget-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.5rem);
|
||||||
|
right: 0;
|
||||||
|
width: 400px;
|
||||||
|
max-height: 600px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: #6b7280;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn svg {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-section,
|
||||||
|
.empty-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #6b7280;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
border-top: 2px solid #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasks-list {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status.downloading {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status.completed {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status.failed {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status.cancelled {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status.partial {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status.paused {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-progress {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 0.375rem;
|
||||||
|
background: #e5e7eb;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #3b82f6;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 批量下载进度样式 */
|
||||||
|
.batch-progress {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.success {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.error {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-completed h5 {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completed-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completed-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artwork-id {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artwork-title {
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-name {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item .label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item .value {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item .value.error {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.widget-panel {
|
||||||
|
width: calc(100vw - 2rem);
|
||||||
|
right: -1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-stats {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -28,6 +28,37 @@ class DownloadService {
|
|||||||
return apiService.get('/api/download/tasks');
|
return apiService.get('/api/download/tasks');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取活跃任务(下载中或暂停)
|
||||||
|
*/
|
||||||
|
async getActiveTasks() {
|
||||||
|
return apiService.get('/api/download/tasks/active');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务摘要(用于快速状态检查)
|
||||||
|
*/
|
||||||
|
async getTasksSummary() {
|
||||||
|
return apiService.get('/api/download/tasks/summary');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务变更(增量更新)
|
||||||
|
*/
|
||||||
|
async getTasksChanges(since?: number) {
|
||||||
|
const params = since ? { since } : {};
|
||||||
|
return apiService.get('/api/download/tasks/changes', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已完成任务(分页)
|
||||||
|
*/
|
||||||
|
async getCompletedTasks(offset = 0, limit = 50) {
|
||||||
|
return apiService.get('/api/download/tasks/completed', {
|
||||||
|
params: { offset, limit }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 暂停任务
|
* 暂停任务
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import downloadService from '@/services/download';
|
||||||
|
import type { DownloadTask } from '@/types';
|
||||||
|
|
||||||
|
export const useDownloadStore = defineStore('download', () => {
|
||||||
|
// 状态
|
||||||
|
const tasks = ref<DownloadTask[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const lastUpdate = ref<number>(0);
|
||||||
|
|
||||||
|
// SSE连接管理
|
||||||
|
const sseConnections = ref<Map<string, () => void>>(new Map());
|
||||||
|
|
||||||
|
// 计算属性:显示活跃任务和暂停任务
|
||||||
|
const activeTasks = computed(() => {
|
||||||
|
return tasks.value.filter(task =>
|
||||||
|
['downloading', 'paused'].includes(task.status)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算属性:正在下载的任务
|
||||||
|
const downloadingTasks = computed(() => {
|
||||||
|
return tasks.value.filter(task => task.status === 'downloading');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算属性:暂停的任务
|
||||||
|
const pausedTasks = computed(() => {
|
||||||
|
return tasks.value.filter(task => task.status === 'paused');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取指定任务
|
||||||
|
const getTask = (taskId: string) => {
|
||||||
|
return tasks.value.find(task => task.id === taskId) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取指定作品的任务
|
||||||
|
const getArtworkTask = (artworkId: number) => {
|
||||||
|
return tasks.value.find(task =>
|
||||||
|
task.artwork_id === artworkId &&
|
||||||
|
['downloading', 'paused'].includes(task.status)
|
||||||
|
) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取任务列表(优化版本)
|
||||||
|
const fetchTasks = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
// 使用增量更新API
|
||||||
|
const response = await downloadService.getTasksChanges(lastUpdate.value);
|
||||||
|
if (response.success) {
|
||||||
|
const { tasks: changedTasks, lastUpdate: newLastUpdate } = response.data;
|
||||||
|
|
||||||
|
// 更新任务列表
|
||||||
|
changedTasks.forEach((changedTask: DownloadTask) => {
|
||||||
|
const index = tasks.value.findIndex((t: DownloadTask) => t.id === changedTask.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
tasks.value[index] = changedTask;
|
||||||
|
} else {
|
||||||
|
tasks.value.push(changedTask);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
lastUpdate.value = newLastUpdate;
|
||||||
|
|
||||||
|
// 管理SSE连接
|
||||||
|
manageSSEConnections();
|
||||||
|
} else {
|
||||||
|
throw new Error(response.error || '获取任务列表失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : '获取任务列表失败';
|
||||||
|
console.error('获取任务列表失败:', err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取活跃任务(轻量级)
|
||||||
|
const fetchActiveTasks = async () => {
|
||||||
|
try {
|
||||||
|
const response = await downloadService.getActiveTasks();
|
||||||
|
if (response.success) {
|
||||||
|
// 只更新活跃任务
|
||||||
|
const activeTaskIds = new Set(response.data.map((t: DownloadTask) => t.id));
|
||||||
|
|
||||||
|
// 移除已完成的活跃任务
|
||||||
|
tasks.value = tasks.value.filter((task: DownloadTask) =>
|
||||||
|
!activeTaskIds.has(task.id) || ['downloading', 'paused'].includes(task.status)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新或添加活跃任务
|
||||||
|
response.data.forEach((activeTask: DownloadTask) => {
|
||||||
|
const index = tasks.value.findIndex((t: DownloadTask) => t.id === activeTask.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
tasks.value[index] = activeTask;
|
||||||
|
} else {
|
||||||
|
tasks.value.push(activeTask);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 管理SSE连接
|
||||||
|
manageSSEConnections();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取活跃任务失败:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取任务摘要(用于快速状态检查)
|
||||||
|
const fetchTasksSummary = async () => {
|
||||||
|
try {
|
||||||
|
const response = await downloadService.getTasksSummary();
|
||||||
|
if (response.success) {
|
||||||
|
// 可以用于快速检查是否有新任务完成
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取任务摘要失败:', err);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 开始SSE监听任务进度
|
||||||
|
const startTaskStreaming = (taskId: string) => {
|
||||||
|
// 如果已经有连接,先关闭
|
||||||
|
if (sseConnections.value.has(taskId)) {
|
||||||
|
sseConnections.value.get(taskId)!();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('开始SSE监听任务进度:', taskId);
|
||||||
|
|
||||||
|
// 添加超时处理
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
console.warn('SSE连接超时,关闭连接:', taskId);
|
||||||
|
stopTaskStreaming(taskId);
|
||||||
|
}, 30000); // 30秒超时
|
||||||
|
|
||||||
|
const closeConnection = downloadService.streamTaskProgress(
|
||||||
|
taskId,
|
||||||
|
(task) => {
|
||||||
|
// console.log('收到SSE进度更新:', {
|
||||||
|
// taskId,
|
||||||
|
// status: task.status,
|
||||||
|
// progress: task.progress,
|
||||||
|
// completed: task.completed_files,
|
||||||
|
// total: task.total_files
|
||||||
|
// });
|
||||||
|
|
||||||
|
// 清除超时
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
// 更新任务状态
|
||||||
|
const index = tasks.value.findIndex(t => t.id === taskId);
|
||||||
|
if (index !== -1) {
|
||||||
|
tasks.value[index] = task;
|
||||||
|
} else {
|
||||||
|
// 如果是新任务,添加到列表
|
||||||
|
tasks.value.push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果任务完成或暂停,清理连接
|
||||||
|
if (['completed', 'failed', 'cancelled', 'partial', 'paused'].includes(task.status)) {
|
||||||
|
console.log('任务状态变更,关闭SSE连接:', taskId);
|
||||||
|
stopTaskStreaming(taskId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
console.log('SSE连接完成:', taskId);
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
stopTaskStreaming(taskId);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
sseConnections.value.set(taskId, closeConnection);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 停止SSE监听
|
||||||
|
const stopTaskStreaming = (taskId: string) => {
|
||||||
|
if (sseConnections.value.has(taskId)) {
|
||||||
|
sseConnections.value.get(taskId)!();
|
||||||
|
sseConnections.value.delete(taskId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 管理SSE连接
|
||||||
|
const manageSSEConnections = () => {
|
||||||
|
// 清理不需要的连接
|
||||||
|
const currentTaskIds = new Set(activeTasks.value.map(task => task.id));
|
||||||
|
|
||||||
|
// 关闭已不存在的任务的连接
|
||||||
|
sseConnections.value.forEach((closeConnection, taskId) => {
|
||||||
|
if (!currentTaskIds.has(taskId)) {
|
||||||
|
console.log('清理已不存在的任务连接:', taskId);
|
||||||
|
closeConnection();
|
||||||
|
sseConnections.value.delete(taskId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 为正在下载的任务建立连接
|
||||||
|
activeTasks.value.forEach(task => {
|
||||||
|
if (task.status === 'downloading' && !sseConnections.value.has(task.id)) {
|
||||||
|
startTaskStreaming(task.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清理所有SSE连接
|
||||||
|
const cleanupSSEConnections = () => {
|
||||||
|
sseConnections.value.forEach(closeConnection => {
|
||||||
|
closeConnection();
|
||||||
|
});
|
||||||
|
sseConnections.value.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 定期刷新任务列表
|
||||||
|
let refreshInterval: number | null = null;
|
||||||
|
let summaryInterval: number | null = null;
|
||||||
|
|
||||||
|
const startRefreshInterval = () => {
|
||||||
|
if (refreshInterval) return;
|
||||||
|
|
||||||
|
// 主要刷新:每5秒获取活跃任务(轻量级)
|
||||||
|
refreshInterval = window.setInterval(() => {
|
||||||
|
fetchActiveTasks();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// 摘要检查:每30秒检查一次任务摘要,如果有变化则获取详细信息
|
||||||
|
summaryInterval = window.setInterval(async () => {
|
||||||
|
const summary = await fetchTasksSummary();
|
||||||
|
if (summary && summary.active > 0) {
|
||||||
|
// 如果有活跃任务,确保获取最新状态
|
||||||
|
fetchActiveTasks();
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopRefreshInterval = () => {
|
||||||
|
if (refreshInterval) {
|
||||||
|
clearInterval(refreshInterval);
|
||||||
|
refreshInterval = null;
|
||||||
|
}
|
||||||
|
if (summaryInterval) {
|
||||||
|
clearInterval(summaryInterval);
|
||||||
|
summaryInterval = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加新任务(用于立即显示)
|
||||||
|
const addTask = (task: DownloadTask) => {
|
||||||
|
// 检查是否已存在
|
||||||
|
const existingIndex = tasks.value.findIndex(t => t.id === task.id);
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
tasks.value[existingIndex] = task;
|
||||||
|
} else {
|
||||||
|
tasks.value.push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是下载中的任务,立即建立SSE连接
|
||||||
|
if (task.status === 'downloading') {
|
||||||
|
startTaskStreaming(task.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新任务状态
|
||||||
|
const updateTask = (taskId: string, updates: Partial<DownloadTask>) => {
|
||||||
|
const index = tasks.value.findIndex(t => t.id === taskId);
|
||||||
|
if (index !== -1) {
|
||||||
|
tasks.value[index] = { ...tasks.value[index], ...updates };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 移除任务
|
||||||
|
const removeTask = (taskId: string) => {
|
||||||
|
const index = tasks.value.findIndex(t => t.id === taskId);
|
||||||
|
if (index !== -1) {
|
||||||
|
tasks.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
stopTaskStreaming(taskId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 取消任务
|
||||||
|
const cancelTask = async (taskId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await downloadService.cancelTask(taskId);
|
||||||
|
if (response.success) {
|
||||||
|
// 立即停止SSE连接
|
||||||
|
stopTaskStreaming(taskId);
|
||||||
|
await fetchTasks();
|
||||||
|
} else {
|
||||||
|
throw new Error(response.error || '取消任务失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : '取消任务失败';
|
||||||
|
console.error('取消任务失败:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 恢复任务
|
||||||
|
const resumeTask = async (taskId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await downloadService.resumeTask(taskId);
|
||||||
|
if (response.success) {
|
||||||
|
await fetchTasks();
|
||||||
|
// 重新管理SSE连接
|
||||||
|
manageSSEConnections();
|
||||||
|
} else {
|
||||||
|
throw new Error(response.error || '恢复任务失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : '恢复任务失败';
|
||||||
|
console.error('恢复任务失败:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 暂停任务
|
||||||
|
const pauseTask = async (taskId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await downloadService.pauseTask(taskId);
|
||||||
|
if (response.success) {
|
||||||
|
await fetchTasks();
|
||||||
|
} else {
|
||||||
|
throw new Error(response.error || '暂停任务失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : '暂停任务失败';
|
||||||
|
console.error('暂停任务失败:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清理已完成的任务
|
||||||
|
const cleanupCompletedTasks = async (keepCount = 100) => {
|
||||||
|
try {
|
||||||
|
const response = await downloadService.cleanupTasks(true, keepCount);
|
||||||
|
if (response.success) {
|
||||||
|
await fetchTasks();
|
||||||
|
} else {
|
||||||
|
throw new Error(response.error || '清理任务失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : '清理任务失败';
|
||||||
|
console.error('清理任务失败:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清理历史记录
|
||||||
|
const cleanupHistory = async (keepCount = 500) => {
|
||||||
|
try {
|
||||||
|
const response = await downloadService.cleanupHistory(keepCount);
|
||||||
|
if (response.success) {
|
||||||
|
// 历史记录清理不影响当前任务状态
|
||||||
|
return response.data;
|
||||||
|
} else {
|
||||||
|
throw new Error(response.error || '清理历史失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : '清理历史失败';
|
||||||
|
console.error('清理历史失败:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清除错误
|
||||||
|
const clearError = () => {
|
||||||
|
error.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
tasks,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
lastUpdate,
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
activeTasks,
|
||||||
|
downloadingTasks,
|
||||||
|
pausedTasks,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
getTask,
|
||||||
|
getArtworkTask,
|
||||||
|
fetchTasks,
|
||||||
|
fetchActiveTasks,
|
||||||
|
fetchTasksSummary,
|
||||||
|
addTask,
|
||||||
|
updateTask,
|
||||||
|
removeTask,
|
||||||
|
cancelTask,
|
||||||
|
resumeTask,
|
||||||
|
pauseTask,
|
||||||
|
cleanupCompletedTasks,
|
||||||
|
cleanupHistory,
|
||||||
|
clearError,
|
||||||
|
|
||||||
|
// SSE管理
|
||||||
|
startTaskStreaming,
|
||||||
|
stopTaskStreaming,
|
||||||
|
manageSSEConnections,
|
||||||
|
cleanupSSEConnections,
|
||||||
|
|
||||||
|
// 定期刷新管理
|
||||||
|
startRefreshInterval,
|
||||||
|
stopRefreshInterval
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -60,12 +60,30 @@
|
|||||||
<div class="artworks-section">
|
<div class="artworks-section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>作品列表</h2>
|
<h2>作品列表</h2>
|
||||||
<div class="artwork-filters">
|
<div class="header-controls">
|
||||||
<select v-model="artworkType" @change="handleTypeChange" class="filter-select">
|
<div class="artwork-filters">
|
||||||
<option value="art">插画</option>
|
<select v-model="artworkType" @change="handleTypeChange" class="filter-select">
|
||||||
<option value="manga">漫画</option>
|
<option value="art">插画</option>
|
||||||
<option value="novel">小说</option>
|
<option value="manga">漫画</option>
|
||||||
</select>
|
<option value="novel">小说</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 顶部分页导航 -->
|
||||||
|
<div v-if="totalPages > 1 && artworks.length > 0" class="simple-pagination">
|
||||||
|
<button @click="goToPage(currentPage - 1)" class="simple-page-btn" :disabled="currentPage <= 1">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" class="simple-page-icon">
|
||||||
|
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="simple-page-info">{{ currentPage }} / {{ totalPages }}</span>
|
||||||
|
<button @click="goToPage(currentPage + 1)" class="simple-page-btn"
|
||||||
|
:disabled="currentPage >= totalPages">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" class="simple-page-icon">
|
||||||
|
<path d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -704,6 +722,7 @@ onMounted(async () => {
|
|||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
@@ -720,6 +739,12 @@ onMounted(async () => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.artwork-filters {
|
.artwork-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -734,6 +759,58 @@ onMounted(async () => {
|
|||||||
color: #374151;
|
color: #374151;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 顶部分页导航样式 */
|
||||||
|
.simple-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-page-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: white;
|
||||||
|
color: #374151;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-page-btn:hover:not(:disabled) {
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-color: #9ca3af;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-page-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-page-icon {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-page-info {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.artworks-grid {
|
.artworks-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
@@ -901,6 +978,12 @@ onMounted(async () => {
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 移动端简洁分页导航样式调整 */
|
||||||
|
.simple-pagination {
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.artist-profile {
|
.artist-profile {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -930,6 +1013,12 @@ onMounted(async () => {
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
.artworks-grid {
|
.artworks-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-115
@@ -25,7 +25,7 @@
|
|||||||
<ArtworkInfoPanel :artwork="artwork" :downloading="downloading" :is-downloaded="isDownloaded"
|
<ArtworkInfoPanel :artwork="artwork" :downloading="downloading" :is-downloaded="isDownloaded"
|
||||||
:current-task="currentTask" :loading="loading" :show-navigation="showNavigation"
|
:current-task="currentTask" :loading="loading" :show-navigation="showNavigation"
|
||||||
:previous-artwork="previousArtwork" :next-artwork="nextArtwork" :selected-tags="selectedTags"
|
:previous-artwork="previousArtwork" :next-artwork="nextArtwork" :selected-tags="selectedTags"
|
||||||
@download="handleDownload" @bookmark="handleBookmark" @update-task="updateTask" @remove-task="removeTask"
|
@download="handleDownload" @bookmark="handleBookmark"
|
||||||
@go-back="goBackToArtist" @navigate-previous="navigateToPrevious" @navigate-next="navigateToNext"
|
@go-back="goBackToArtist" @navigate-previous="navigateToPrevious" @navigate-next="navigateToNext"
|
||||||
@tag-click="handleTagClick" />
|
@tag-click="handleTagClick" />
|
||||||
</div>
|
</div>
|
||||||
@@ -38,6 +38,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
|||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { useRepositoryStore } from '@/stores/repository';
|
import { useRepositoryStore } from '@/stores/repository';
|
||||||
|
import { useDownloadStore } from '@/stores/download';
|
||||||
import artworkService from '@/services/artwork';
|
import artworkService from '@/services/artwork';
|
||||||
import artistService from '@/services/artist';
|
import artistService from '@/services/artist';
|
||||||
import downloadService from '@/services/download';
|
import downloadService from '@/services/download';
|
||||||
@@ -53,6 +54,7 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const repositoryStore = useRepositoryStore();
|
const repositoryStore = useRepositoryStore();
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
const artwork = ref<Artwork | null>(null);
|
const artwork = ref<Artwork | null>(null);
|
||||||
@@ -62,9 +64,11 @@ const currentPage = ref(0);
|
|||||||
const downloading = ref(false);
|
const downloading = ref(false);
|
||||||
const isDownloaded = ref(false);
|
const isDownloaded = ref(false);
|
||||||
|
|
||||||
// 下载任务状态
|
// 下载任务状态 - 使用Pinia store
|
||||||
const currentTask = ref<DownloadTask | null>(null);
|
const currentTask = computed(() => {
|
||||||
const sseConnection = ref<(() => void) | null>(null);
|
if (!artwork.value) return null;
|
||||||
|
return downloadStore.getArtworkTask(artwork.value.id);
|
||||||
|
});
|
||||||
|
|
||||||
// 收藏错误状态
|
// 收藏错误状态
|
||||||
const bookmarkError = ref<string | null>(null);
|
const bookmarkError = ref<string | null>(null);
|
||||||
@@ -105,10 +109,8 @@ const fetchArtworkDetail = async () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
// 立即清理所有下载相关状态
|
// 清理下载状态
|
||||||
currentTask.value = null;
|
|
||||||
downloading.value = false;
|
downloading.value = false;
|
||||||
stopTaskStreaming();
|
|
||||||
|
|
||||||
const response = await artworkService.getArtworkDetail(artworkId);
|
const response = await artworkService.getArtworkDetail(artworkId);
|
||||||
|
|
||||||
@@ -137,19 +139,19 @@ const checkDownloadStatus = async (artworkId: number, retryCount = 0) => {
|
|||||||
try {
|
try {
|
||||||
const response = await repositoryStore.checkArtworkDownloaded(artworkId);
|
const response = await repositoryStore.checkArtworkDownloaded(artworkId);
|
||||||
|
|
||||||
console.log('下载状态检查响应:', response);
|
// console.log('下载状态检查响应:', response);
|
||||||
|
|
||||||
// repository store的apiCall返回的是data.data,所以response直接是数据对象
|
// repository store的apiCall返回的是data.data,所以response直接是数据对象
|
||||||
if (response && typeof response === 'object') {
|
if (response && typeof response === 'object') {
|
||||||
const newStatus = response.is_downloaded || false;
|
const newStatus = response.is_downloaded || false;
|
||||||
|
|
||||||
// 如果状态发生变化,记录日志
|
// 如果状态发生变化,记录日志
|
||||||
if (isDownloaded.value !== newStatus) {
|
// if (isDownloaded.value !== newStatus) {
|
||||||
console.log(`作品下载状态变化: ${isDownloaded.value} -> ${newStatus}`);
|
// console.log(`作品下载状态变化: ${isDownloaded.value} -> ${newStatus}`);
|
||||||
}
|
// }
|
||||||
|
|
||||||
isDownloaded.value = newStatus;
|
isDownloaded.value = newStatus;
|
||||||
console.log('作品下载状态:', isDownloaded.value);
|
// console.log('作品下载状态:', isDownloaded.value);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('检查下载状态失败:', err);
|
console.error('检查下载状态失败:', err);
|
||||||
@@ -170,8 +172,7 @@ const handleDownload = async () => {
|
|||||||
if (!artwork.value) return;
|
if (!artwork.value) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 清理之前的任务状态
|
// 清理下载状态
|
||||||
currentTask.value = null;
|
|
||||||
downloading.value = true;
|
downloading.value = true;
|
||||||
|
|
||||||
// 如果已经下载过,则强制重新下载(跳过现有文件检查)
|
// 如果已经下载过,则强制重新下载(跳过现有文件检查)
|
||||||
@@ -191,10 +192,10 @@ const handleDownload = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是新任务,立即创建任务状态并开始监听进度
|
// 如果是新任务,立即添加到store
|
||||||
if (response.data.task_id) {
|
if (response.data.task_id) {
|
||||||
// 立即创建任务状态,让进度条立即显示
|
// 立即创建任务状态,让进度条立即显示
|
||||||
currentTask.value = {
|
const newTask: DownloadTask = {
|
||||||
id: response.data.task_id,
|
id: response.data.task_id,
|
||||||
type: 'artwork',
|
type: 'artwork',
|
||||||
status: 'downloading',
|
status: 'downloading',
|
||||||
@@ -208,8 +209,8 @@ const handleDownload = async () => {
|
|||||||
start_time: new Date().toISOString()
|
start_time: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
// 立即开始SSE监听任务进度
|
// 添加到store,store会自动管理SSE连接
|
||||||
startTaskStreaming(response.data.task_id);
|
downloadStore.addTask(newTask);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.error || '下载失败');
|
throw new Error(response.error || '下载失败');
|
||||||
@@ -222,93 +223,25 @@ const handleDownload = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 开始SSE监听任务进度
|
// 监听任务完成状态
|
||||||
const startTaskStreaming = (taskId: string) => {
|
watch(currentTask, (newTask, oldTask) => {
|
||||||
// 清除之前的连接
|
if (oldTask && !newTask) {
|
||||||
if (sseConnection.value) {
|
// 任务被移除,检查下载状态
|
||||||
sseConnection.value();
|
if (artwork.value) {
|
||||||
}
|
setTimeout(async () => {
|
||||||
|
await checkDownloadStatus(artwork.value!.id);
|
||||||
console.log('开始SSE监听任务进度:', taskId);
|
}, 1000);
|
||||||
|
}
|
||||||
// 建立SSE连接
|
} else if (newTask && ['completed', 'failed', 'cancelled', 'partial'].includes(newTask.status)) {
|
||||||
sseConnection.value = downloadService.streamTaskProgress(
|
// 任务完成,延迟检查下载状态
|
||||||
taskId,
|
if (artwork.value && artwork.value.id === newTask.artwork_id) {
|
||||||
(task) => {
|
const delay = newTask.total_files > 1 ? 1500 : 1000;
|
||||||
console.log('收到SSE进度更新:', {
|
setTimeout(async () => {
|
||||||
taskId,
|
await checkDownloadStatus(artwork.value!.id);
|
||||||
status: task.status,
|
}, delay);
|
||||||
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();
|
|
||||||
|
|
||||||
// 延迟检查下载状态,确保文件写入完成
|
|
||||||
// 减少延迟时间,提高响应速度
|
|
||||||
const delay = task.total_files > 1 ? 1500 : 1000; // 多文件延迟1.5秒,单文件延迟1秒
|
|
||||||
|
|
||||||
setTimeout(async () => {
|
|
||||||
// 检查当前页面是否还是同一个作品,避免页面切换后的状态更新
|
|
||||||
if (artwork.value && artwork.value.id === task.artwork_id) {
|
|
||||||
console.log(`延迟 ${delay}ms 后检查下载状态`);
|
|
||||||
await checkDownloadStatus(artwork.value.id);
|
|
||||||
|
|
||||||
// 如果任务完成但状态检查显示未下载,再次延迟检查
|
|
||||||
if (task.status === 'completed' && !isDownloaded.value) {
|
|
||||||
console.log('任务完成但状态检查失败,再次延迟检查');
|
|
||||||
setTimeout(async () => {
|
|
||||||
if (artwork.value && artwork.value.id === task.artwork_id) {
|
|
||||||
await checkDownloadStatus(artwork.value.id);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清理任务状态,显示下载完成状态
|
|
||||||
currentTask.value = null;
|
|
||||||
}
|
|
||||||
}, delay);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
console.log('SSE连接完成');
|
|
||||||
stopTaskStreaming();
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 停止SSE监听
|
|
||||||
const stopTaskStreaming = () => {
|
|
||||||
if (sseConnection.value) {
|
|
||||||
sseConnection.value();
|
|
||||||
sseConnection.value = null;
|
|
||||||
}
|
}
|
||||||
// 确保清理任务状态
|
}, { immediate: true });
|
||||||
currentTask.value = null;
|
|
||||||
downloading.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 更新任务状态
|
|
||||||
const updateTask = (task: DownloadTask) => {
|
|
||||||
currentTask.value = task;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 移除任务
|
|
||||||
const removeTask = (taskId: string) => {
|
|
||||||
if (currentTask.value?.id === taskId) {
|
|
||||||
currentTask.value = null;
|
|
||||||
stopTaskStreaming();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 收藏/取消收藏
|
// 收藏/取消收藏
|
||||||
const handleBookmark = async () => {
|
const handleBookmark = async () => {
|
||||||
@@ -386,10 +319,8 @@ const fetchArtistArtworks = async () => {
|
|||||||
// 导航到上一个作品
|
// 导航到上一个作品
|
||||||
const navigateToPrevious = () => {
|
const navigateToPrevious = () => {
|
||||||
if (previousArtwork.value && !loading.value) {
|
if (previousArtwork.value && !loading.value) {
|
||||||
// 立即清理下载任务状态
|
// 清理下载状态
|
||||||
currentTask.value = null;
|
|
||||||
downloading.value = false;
|
downloading.value = false;
|
||||||
stopTaskStreaming();
|
|
||||||
|
|
||||||
// 立即设置加载状态
|
// 立即设置加载状态
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -409,10 +340,8 @@ const navigateToPrevious = () => {
|
|||||||
// 导航到下一个作品
|
// 导航到下一个作品
|
||||||
const navigateToNext = () => {
|
const navigateToNext = () => {
|
||||||
if (nextArtwork.value && !loading.value) {
|
if (nextArtwork.value && !loading.value) {
|
||||||
// 立即清理下载任务状态
|
// 清理下载状态
|
||||||
currentTask.value = null;
|
|
||||||
downloading.value = false;
|
downloading.value = false;
|
||||||
stopTaskStreaming();
|
|
||||||
|
|
||||||
// 立即设置加载状态
|
// 立即设置加载状态
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -538,10 +467,8 @@ watch(() => route.params.id, (newId, oldId) => {
|
|||||||
// 确保页面滚动到顶部
|
// 确保页面滚动到顶部
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
|
|
||||||
// 立即清理所有下载相关状态
|
// 清理下载状态
|
||||||
currentTask.value = null;
|
downloading.value = false;
|
||||||
downloading.value = false;
|
|
||||||
stopTaskStreaming();
|
|
||||||
|
|
||||||
// 重新获取作品详情
|
// 重新获取作品详情
|
||||||
fetchArtworkDetail();
|
fetchArtworkDetail();
|
||||||
@@ -583,12 +510,11 @@ onMounted(() => {
|
|||||||
document.addEventListener('keyup', handleKeyUp);
|
document.addEventListener('keyup', handleKeyUp);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 组件卸载时移除事件监听和清理SSE连接
|
// 组件卸载时移除事件监听
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('keydown', handleKeydown);
|
document.removeEventListener('keydown', handleKeydown);
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
document.removeEventListener('keyup', handleKeyUp);
|
document.removeEventListener('keyup', handleKeyUp);
|
||||||
stopTaskStreaming();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+27
-156
@@ -197,22 +197,23 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { useDownloadStore } from '@/stores/download';
|
||||||
import downloadService from '@/services/download';
|
import downloadService from '@/services/download';
|
||||||
import type { DownloadTask } from '@/types';
|
import type { DownloadTask } from '@/types';
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
const activeTab = ref<'tasks' | 'history'>('tasks');
|
const activeTab = ref<'tasks' | 'history'>('tasks');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const tasks = ref<DownloadTask[]>([]);
|
|
||||||
const history = ref<any[]>([]);
|
const history = ref<any[]>([]);
|
||||||
|
|
||||||
// SSE连接管理
|
// 使用store中的任务数据
|
||||||
const sseConnections = ref<Map<string, () => void>>(new Map());
|
const tasks = computed(() => downloadStore.tasks);
|
||||||
|
|
||||||
// 计算属性:显示活跃任务和暂停任务
|
// 计算属性:显示活跃任务和暂停任务
|
||||||
const activeTasks = computed(() => {
|
const activeTasks = computed(() => {
|
||||||
@@ -295,32 +296,11 @@ const formatDate = (dateString: string) => {
|
|||||||
// 刷新数据
|
// 刷新数据
|
||||||
const refreshData = async () => {
|
const refreshData = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchTasks(),
|
downloadStore.fetchTasks(),
|
||||||
fetchHistory()
|
fetchHistory()
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取任务列表
|
|
||||||
const fetchTasks = async () => {
|
|
||||||
try {
|
|
||||||
const response = await downloadService.getAllTasks();
|
|
||||||
if (response.success) {
|
|
||||||
tasks.value = response.data || [];
|
|
||||||
|
|
||||||
// 只为正在下载的任务建立SSE连接,避免为暂停任务建立连接
|
|
||||||
activeTasks.value.forEach(task => {
|
|
||||||
if (task.status === 'downloading' && !sseConnections.value.has(task.id)) {
|
|
||||||
startTaskStreaming(task.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new Error(response.error || '获取任务列表失败');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('获取任务列表失败:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取历史记录(只获取最近200条)
|
// 获取历史记录(只获取最近200条)
|
||||||
const fetchHistory = async () => {
|
const fetchHistory = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -337,105 +317,25 @@ const fetchHistory = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 开始SSE监听任务进度
|
// 监听任务完成,刷新历史记录
|
||||||
const startTaskStreaming = (taskId: string) => {
|
watch(tasks, (newTasks, oldTasks) => {
|
||||||
// 如果已经有连接,先关闭
|
// 检查是否有任务完成
|
||||||
if (sseConnections.value.has(taskId)) {
|
const completedTasks = newTasks.filter(task =>
|
||||||
sseConnections.value.get(taskId)!();
|
['completed', 'failed', 'cancelled', 'partial'].includes(task.status)
|
||||||
}
|
|
||||||
|
|
||||||
console.log('开始SSE监听任务进度:', taskId);
|
|
||||||
|
|
||||||
// 添加超时处理
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
console.warn('SSE连接超时,关闭连接:', taskId);
|
|
||||||
stopTaskStreaming(taskId);
|
|
||||||
}, 30000); // 30秒超时
|
|
||||||
|
|
||||||
const closeConnection = downloadService.streamTaskProgress(
|
|
||||||
taskId,
|
|
||||||
(task) => {
|
|
||||||
console.log('收到SSE进度更新:', {
|
|
||||||
taskId,
|
|
||||||
status: task.status,
|
|
||||||
progress: task.progress,
|
|
||||||
completed: task.completed_files,
|
|
||||||
total: task.total_files
|
|
||||||
});
|
|
||||||
|
|
||||||
// 清除超时
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
// 更新任务状态
|
|
||||||
const index = tasks.value.findIndex(t => t.id === taskId);
|
|
||||||
if (index !== -1) {
|
|
||||||
tasks.value[index] = task;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果任务完成或暂停,清理连接
|
|
||||||
if (['completed', 'failed', 'cancelled', 'partial', 'paused'].includes(task.status)) {
|
|
||||||
console.log('任务状态变更,关闭SSE连接:', taskId);
|
|
||||||
stopTaskStreaming(taskId);
|
|
||||||
|
|
||||||
// 延迟刷新历史记录
|
|
||||||
if (['completed', 'failed', 'cancelled', 'partial'].includes(task.status)) {
|
|
||||||
setTimeout(() => {
|
|
||||||
fetchHistory();
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
console.log('SSE连接完成:', taskId);
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
stopTaskStreaming(taskId);
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
sseConnections.value.set(taskId, closeConnection);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 停止SSE监听
|
|
||||||
const stopTaskStreaming = (taskId: string) => {
|
|
||||||
if (sseConnections.value.has(taskId)) {
|
|
||||||
sseConnections.value.get(taskId)!();
|
|
||||||
sseConnections.value.delete(taskId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 管理SSE连接
|
|
||||||
const manageSSEConnections = () => {
|
|
||||||
// 清理不需要的连接
|
|
||||||
const currentTaskIds = new Set(activeTasks.value.map(task => task.id));
|
|
||||||
|
|
||||||
// 关闭已不存在的任务的连接
|
if (completedTasks.length > 0) {
|
||||||
sseConnections.value.forEach((closeConnection, taskId) => {
|
// 延迟刷新历史记录
|
||||||
if (!currentTaskIds.has(taskId)) {
|
setTimeout(() => {
|
||||||
console.log('清理已不存在的任务连接:', taskId);
|
fetchHistory();
|
||||||
closeConnection();
|
}, 1000);
|
||||||
sseConnections.value.delete(taskId);
|
}
|
||||||
}
|
}, { deep: true });
|
||||||
});
|
|
||||||
|
|
||||||
// 为正在下载的任务建立连接
|
|
||||||
activeTasks.value.forEach(task => {
|
|
||||||
if (task.status === 'downloading' && !sseConnections.value.has(task.id)) {
|
|
||||||
startTaskStreaming(task.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 取消任务
|
// 取消任务
|
||||||
const cancelTask = async (taskId: string) => {
|
const cancelTask = async (taskId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await downloadService.cancelTask(taskId);
|
await downloadStore.cancelTask(taskId);
|
||||||
if (response.success) {
|
|
||||||
// 立即停止SSE连接
|
|
||||||
stopTaskStreaming(taskId);
|
|
||||||
await fetchTasks();
|
|
||||||
} else {
|
|
||||||
throw new Error(response.error || '取消任务失败');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : '取消任务失败';
|
error.value = err instanceof Error ? err.message : '取消任务失败';
|
||||||
console.error('取消任务失败:', err);
|
console.error('取消任务失败:', err);
|
||||||
@@ -445,14 +345,7 @@ const cancelTask = async (taskId: string) => {
|
|||||||
// 恢复任务
|
// 恢复任务
|
||||||
const resumeTask = async (taskId: string) => {
|
const resumeTask = async (taskId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await downloadService.resumeTask(taskId);
|
await downloadStore.resumeTask(taskId);
|
||||||
if (response.success) {
|
|
||||||
await fetchTasks();
|
|
||||||
// 重新管理SSE连接
|
|
||||||
manageSSEConnections();
|
|
||||||
} else {
|
|
||||||
throw new Error(response.error || '恢复任务失败');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : '恢复任务失败';
|
error.value = err instanceof Error ? err.message : '恢复任务失败';
|
||||||
console.error('恢复任务失败:', err);
|
console.error('恢复任务失败:', err);
|
||||||
@@ -464,13 +357,9 @@ const cleanupHistory = async () => {
|
|||||||
if (confirm('确定要清理下载历史吗?这将保留最新的500条记录。')) {
|
if (confirm('确定要清理下载历史吗?这将保留最新的500条记录。')) {
|
||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
const response = await downloadService.cleanupHistory(500);
|
await downloadStore.cleanupHistory(500);
|
||||||
if (response.success) {
|
await fetchHistory(); // 重新获取历史记录
|
||||||
await fetchHistory(); // 重新获取历史记录
|
alert('下载历史已清理!');
|
||||||
alert('下载历史已清理!');
|
|
||||||
} else {
|
|
||||||
throw new Error(response.error || '清理历史失败');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : '清理历史失败';
|
error.value = err instanceof Error ? err.message : '清理历史失败';
|
||||||
console.error('清理历史失败:', err);
|
console.error('清理历史失败:', err);
|
||||||
@@ -485,13 +374,8 @@ const cleanupTasks = async () => {
|
|||||||
if (confirm('确定要清理已完成的任务吗?这将保留活跃任务和最新的100个已完成任务。')) {
|
if (confirm('确定要清理已完成的任务吗?这将保留活跃任务和最新的100个已完成任务。')) {
|
||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
const response = await downloadService.cleanupTasks(true, 100);
|
await downloadStore.cleanupCompletedTasks(100);
|
||||||
if (response.success) {
|
alert('下载任务已清理!');
|
||||||
await fetchTasks(); // 重新获取任务列表
|
|
||||||
alert('下载任务已清理!');
|
|
||||||
} else {
|
|
||||||
throw new Error(response.error || '清理任务失败');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : '清理任务失败';
|
error.value = err instanceof Error ? err.message : '清理任务失败';
|
||||||
console.error('清理任务失败:', err);
|
console.error('清理任务失败:', err);
|
||||||
@@ -506,27 +390,14 @@ const clearError = () => {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 清理所有SSE连接
|
|
||||||
const cleanupSSEConnections = () => {
|
|
||||||
sseConnections.value.forEach(closeConnection => {
|
|
||||||
closeConnection();
|
|
||||||
});
|
|
||||||
sseConnections.value.clear();
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
// 先获取数据,不阻塞页面渲染
|
// 先获取数据,不阻塞页面渲染
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchTasks(),
|
downloadStore.fetchTasks(),
|
||||||
fetchHistory()
|
fetchHistory()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 数据加载完成后,异步管理SSE连接
|
|
||||||
setTimeout(() => {
|
|
||||||
manageSSEConnections();
|
|
||||||
}, 100);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : '加载数据失败';
|
error.value = err instanceof Error ? err.message : '加载数据失败';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -535,7 +406,7 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
cleanupSSEConnections();
|
// 组件卸载时不需要清理SSE连接,因为store会统一管理
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user