下载模组更新,新增下载组件,下载监听改为全局,全量改为增量监听

This commit is contained in:
2025-08-31 06:41:46 +08:00
parent aa04f9d03f
commit ad5dfc64cb
17 changed files with 1662 additions and 285 deletions
+20
View File
@@ -1,16 +1,33 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useDownloadStore } from '@/stores/download'
import SettingsWidget from '@/components/common/SettingsWidget.vue'
import DownloadProgressWidget from '@/components/common/DownloadProgressWidget.vue'
const route = useRoute()
const authStore = useAuthStore()
const downloadStore = useDownloadStore()
const isLoggedIn = computed(() => authStore.isLoggedIn)
const username = computed(() => authStore.username)
// 在下载管理页面隐藏下载进度小组件
const showDownloadWidget = computed(() => {
return isLoggedIn.value && route.path !== '/downloads'
})
onMounted(async () => {
await authStore.fetchLoginStatus()
// 如果已登录,初始化下载store
if (authStore.isLoggedIn) {
await downloadStore.fetchTasks()
// 启动定期刷新
downloadStore.startRefreshInterval()
}
})
</script>
@@ -72,6 +89,9 @@ onMounted(async () => {
<!-- 设置小组件 - 只在登录时显示 -->
<SettingsWidget v-if="isLoggedIn" />
<!-- 下载进度小组件 - 只在登录时显示在下载管理页面隐藏 -->
<DownloadProgressWidget v-if="showDownloadWidget" />
</div>
</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>
+31
View File
@@ -28,6 +28,37 @@ class DownloadService {
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 }
});
}
/**
* 暂停任务
*/
+413
View File
@@ -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
};
});
+95 -6
View File
@@ -60,12 +60,30 @@
<div class="artworks-section">
<div class="section-header">
<h2>作品列表</h2>
<div class="artwork-filters">
<select v-model="artworkType" @change="handleTypeChange" class="filter-select">
<option value="art">插画</option>
<option value="manga"></option>
<option value="novel">小说</option>
</select>
<div class="header-controls">
<div class="artwork-filters">
<select v-model="artworkType" @change="handleTypeChange" class="filter-select">
<option value="art"></option>
<option value="manga">漫画</option>
<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>
@@ -704,6 +722,7 @@ onMounted(async () => {
border-radius: 1rem;
padding: 2rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
position: relative;
}
.section-header {
@@ -720,6 +739,12 @@ onMounted(async () => {
margin: 0;
}
.header-controls {
display: flex;
align-items: center;
gap: 1rem;
}
.artwork-filters {
display: flex;
gap: 1rem;
@@ -734,6 +759,58 @@ onMounted(async () => {
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 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
@@ -901,6 +978,12 @@ onMounted(async () => {
align-items: stretch;
}
/* 移动端简洁分页导航样式调整 */
.simple-pagination {
justify-content: center;
margin-bottom: 1rem;
}
.artist-profile {
flex-direction: column;
text-align: center;
@@ -930,6 +1013,12 @@ onMounted(async () => {
align-items: stretch;
}
.header-controls {
flex-direction: column;
gap: 1rem;
align-items: stretch;
}
.artworks-grid {
grid-template-columns: 1fr;
}
+41 -115
View File
@@ -25,7 +25,7 @@
<ArtworkInfoPanel :artwork="artwork" :downloading="downloading" :is-downloaded="isDownloaded"
:current-task="currentTask" :loading="loading" :show-navigation="showNavigation"
: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"
@tag-click="handleTagClick" />
</div>
@@ -38,6 +38,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { useRepositoryStore } from '@/stores/repository';
import { useDownloadStore } from '@/stores/download';
import artworkService from '@/services/artwork';
import artistService from '@/services/artist';
import downloadService from '@/services/download';
@@ -53,6 +54,7 @@ const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const repositoryStore = useRepositoryStore();
const downloadStore = useDownloadStore();
// 状态
const artwork = ref<Artwork | null>(null);
@@ -62,9 +64,11 @@ const currentPage = ref(0);
const downloading = ref(false);
const isDownloaded = ref(false);
// 下载任务状态
const currentTask = ref<DownloadTask | null>(null);
const sseConnection = ref<(() => void) | null>(null);
// 下载任务状态 - 使用Pinia store
const currentTask = computed(() => {
if (!artwork.value) return null;
return downloadStore.getArtworkTask(artwork.value.id);
});
// 收藏错误状态
const bookmarkError = ref<string | null>(null);
@@ -105,10 +109,8 @@ const fetchArtworkDetail = async () => {
loading.value = true;
error.value = null;
// 立即清理所有下载相关状态
currentTask.value = null;
// 清理下载状态
downloading.value = false;
stopTaskStreaming();
const response = await artworkService.getArtworkDetail(artworkId);
@@ -137,19 +139,19 @@ const checkDownloadStatus = async (artworkId: number, retryCount = 0) => {
try {
const response = await repositoryStore.checkArtworkDownloaded(artworkId);
console.log('下载状态检查响应:', response);
// console.log('下载状态检查响应:', response);
// repository store的apiCall返回的是data.data,所以response直接是数据对象
if (response && typeof response === 'object') {
const newStatus = response.is_downloaded || false;
// 如果状态发生变化,记录日志
if (isDownloaded.value !== newStatus) {
console.log(`作品下载状态变化: ${isDownloaded.value} -> ${newStatus}`);
}
// if (isDownloaded.value !== newStatus) {
// console.log(`作品下载状态变化: ${isDownloaded.value} -> ${newStatus}`);
// }
isDownloaded.value = newStatus;
console.log('作品下载状态:', isDownloaded.value);
// console.log('作品下载状态:', isDownloaded.value);
}
} catch (err) {
console.error('检查下载状态失败:', err);
@@ -170,8 +172,7 @@ const handleDownload = async () => {
if (!artwork.value) return;
try {
// 清理之前的任务状态
currentTask.value = null;
// 清理下载状态
downloading.value = true;
// 如果已经下载过,则强制重新下载(跳过现有文件检查)
@@ -191,10 +192,10 @@ const handleDownload = async () => {
return;
}
// 如果是新任务,立即创建任务状态并开始监听进度
// 如果是新任务,立即添加到store
if (response.data.task_id) {
// 立即创建任务状态,让进度条立即显示
currentTask.value = {
const newTask: DownloadTask = {
id: response.data.task_id,
type: 'artwork',
status: 'downloading',
@@ -208,8 +209,8 @@ const handleDownload = async () => {
start_time: new Date().toISOString()
};
// 立即开始SSE监听任务进度
startTaskStreaming(response.data.task_id);
// 添加到storestore会自动管理SSE连接
downloadStore.addTask(newTask);
}
} else {
throw new Error(response.error || '下载失败');
@@ -222,93 +223,25 @@ 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();
// 延迟检查下载状态,确保文件写入完成
// 减少延迟时间,提高响应速度
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();
// 监听任务完成状态
watch(currentTask, (newTask, oldTask) => {
if (oldTask && !newTask) {
// 任务被移除,检查下载状态
if (artwork.value) {
setTimeout(async () => {
await checkDownloadStatus(artwork.value!.id);
}, 1000);
}
} else if (newTask && ['completed', 'failed', 'cancelled', 'partial'].includes(newTask.status)) {
// 任务完成,延迟检查下载状态
if (artwork.value && artwork.value.id === newTask.artwork_id) {
const delay = newTask.total_files > 1 ? 1500 : 1000;
setTimeout(async () => {
await checkDownloadStatus(artwork.value!.id);
}, delay);
}
);
};
// 停止SSE监听
const stopTaskStreaming = () => {
if (sseConnection.value) {
sseConnection.value();
sseConnection.value = null;
}
// 确保清理任务状态
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();
}
};
}, { immediate: true });
// 收藏/取消收藏
const handleBookmark = async () => {
@@ -386,10 +319,8 @@ const fetchArtistArtworks = async () => {
// 导航到上一个作品
const navigateToPrevious = () => {
if (previousArtwork.value && !loading.value) {
// 立即清理下载任务状态
currentTask.value = null;
// 清理下载状态
downloading.value = false;
stopTaskStreaming();
// 立即设置加载状态
loading.value = true;
@@ -409,10 +340,8 @@ const navigateToPrevious = () => {
// 导航到下一个作品
const navigateToNext = () => {
if (nextArtwork.value && !loading.value) {
// 立即清理下载任务状态
currentTask.value = null;
// 清理下载状态
downloading.value = false;
stopTaskStreaming();
// 立即设置加载状态
loading.value = true;
@@ -538,10 +467,8 @@ watch(() => route.params.id, (newId, oldId) => {
// 确保页面滚动到顶部
window.scrollTo(0, 0);
// 立即清理所有下载相关状态
currentTask.value = null;
downloading.value = false;
stopTaskStreaming();
// 清理下载状态
downloading.value = false;
// 重新获取作品详情
fetchArtworkDetail();
@@ -583,12 +510,11 @@ onMounted(() => {
document.addEventListener('keyup', handleKeyUp);
});
// 组件卸载时移除事件监听和清理SSE连接
// 组件卸载时移除事件监听
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown);
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('keyup', handleKeyUp);
stopTaskStreaming();
});
</script>
+27 -156
View File
@@ -197,22 +197,23 @@
</template>
<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 { useDownloadStore } from '@/stores/download';
import downloadService from '@/services/download';
import type { DownloadTask } from '@/types';
const authStore = useAuthStore();
const downloadStore = useDownloadStore();
// 状态
const activeTab = ref<'tasks' | 'history'>('tasks');
const loading = ref(false);
const error = ref<string | null>(null);
const tasks = ref<DownloadTask[]>([]);
const history = ref<any[]>([]);
// SSE连接管理
const sseConnections = ref<Map<string, () => void>>(new Map());
// 使用store中的任务数据
const tasks = computed(() => downloadStore.tasks);
// 计算属性:显示活跃任务和暂停任务
const activeTasks = computed(() => {
@@ -295,32 +296,11 @@ const formatDate = (dateString: string) => {
// 刷新数据
const refreshData = async () => {
await Promise.all([
fetchTasks(),
downloadStore.fetchTasks(),
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条)
const fetchHistory = async () => {
try {
@@ -337,105 +317,25 @@ const fetchHistory = async () => {
}
};
// 开始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;
}
// 如果任务完成或暂停,清理连接
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);
}
// 监听任务完成,刷新历史记录
watch(tasks, (newTasks, oldTasks) => {
// 检查是否有任务完成
const completedTasks = newTasks.filter(task =>
['completed', 'failed', 'cancelled', 'partial'].includes(task.status)
);
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);
}
});
};
if (completedTasks.length > 0) {
// 延迟刷新历史记录
setTimeout(() => {
fetchHistory();
}, 1000);
}
}, { deep: true });
// 取消任务
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 || '取消任务失败');
}
await downloadStore.cancelTask(taskId);
} catch (err) {
error.value = err instanceof Error ? err.message : '取消任务失败';
console.error('取消任务失败:', err);
@@ -445,14 +345,7 @@ const cancelTask = async (taskId: string) => {
// 恢复任务
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 || '恢复任务失败');
}
await downloadStore.resumeTask(taskId);
} catch (err) {
error.value = err instanceof Error ? err.message : '恢复任务失败';
console.error('恢复任务失败:', err);
@@ -464,13 +357,9 @@ const cleanupHistory = async () => {
if (confirm('确定要清理下载历史吗?这将保留最新的500条记录。')) {
try {
loading.value = true;
const response = await downloadService.cleanupHistory(500);
if (response.success) {
await fetchHistory(); // 重新获取历史记录
alert('下载历史已清理!');
} else {
throw new Error(response.error || '清理历史失败');
}
await downloadStore.cleanupHistory(500);
await fetchHistory(); // 重新获取历史记录
alert('下载历史已清理!');
} catch (err) {
error.value = err instanceof Error ? err.message : '清理历史失败';
console.error('清理历史失败:', err);
@@ -485,13 +374,8 @@ const cleanupTasks = async () => {
if (confirm('确定要清理已完成的任务吗?这将保留活跃任务和最新的100个已完成任务。')) {
try {
loading.value = true;
const response = await downloadService.cleanupTasks(true, 100);
if (response.success) {
await fetchTasks(); // 重新获取任务列表
alert('下载任务已清理!');
} else {
throw new Error(response.error || '清理任务失败');
}
await downloadStore.cleanupCompletedTasks(100);
alert('下载任务已清理!');
} catch (err) {
error.value = err instanceof Error ? err.message : '清理任务失败';
console.error('清理任务失败:', err);
@@ -506,27 +390,14 @@ const clearError = () => {
error.value = null;
};
// 清理所有SSE连接
const cleanupSSEConnections = () => {
sseConnections.value.forEach(closeConnection => {
closeConnection();
});
sseConnections.value.clear();
};
onMounted(async () => {
loading.value = true;
try {
// 先获取数据,不阻塞页面渲染
await Promise.all([
fetchTasks(),
downloadStore.fetchTasks(),
fetchHistory()
]);
// 数据加载完成后,异步管理SSE连接
setTimeout(() => {
manageSSEConnections();
}, 100);
} catch (err) {
error.value = err instanceof Error ? err.message : '加载数据失败';
} finally {
@@ -535,7 +406,7 @@ onMounted(async () => {
});
onUnmounted(() => {
cleanupSSEConnections();
// 组件卸载时不需要清理SSE连接,因为store会统一管理
});
</script>