下载模组更新,新增下载组件,下载监听改为全局,全量改为增量监听
This commit is contained in:
@@ -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>
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停任务
|
||||
*/
|
||||
|
||||
@@ -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="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
@@ -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);
|
||||
// 添加到store,store会自动管理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
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user