643 lines
18 KiB
Vue
643 lines
18 KiB
Vue
<template>
|
|
<div class="artwork-page">
|
|
<div class="container">
|
|
<!-- 收藏错误提示 -->
|
|
<div v-if="bookmarkError" class="error-section">
|
|
<ErrorMessage :error="bookmarkError" title="警告" type="warning" dismissible @dismiss="clearBookmarkError" />
|
|
</div>
|
|
|
|
<div v-if="error" class="error-section">
|
|
<ErrorMessage :error="error" @dismiss="clearError" />
|
|
</div>
|
|
|
|
<!-- 页面加载状态 -->
|
|
<div v-if="loading && !artwork" class="loading-section">
|
|
<LoadingSpinner text="加载中..." />
|
|
</div>
|
|
|
|
<!-- 作品内容 -->
|
|
<div v-if="artwork" class="artwork-content" :class="{ 'content-loading': loading }">
|
|
<!-- 左侧图片组件 -->
|
|
<ArtworkGallery :artwork="artwork" :current-page="currentPage" :loading="loading"
|
|
@page-change="currentPage = $event" />
|
|
|
|
<!-- 右侧信息面板组件 -->
|
|
<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"
|
|
@go-back="goBackToArtist" @navigate-previous="navigateToPrevious" @navigate-next="navigateToNext"
|
|
@tag-click="handleTagClick" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
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 artworkService from '@/services/artwork';
|
|
import artistService from '@/services/artist';
|
|
import downloadService from '@/services/download';
|
|
import { getApiBaseUrl, getImageProxyUrl } from '@/services/api';
|
|
import type { Artwork, DownloadTask } from '@/types';
|
|
import ErrorMessage from '@/components/common/ErrorMessage.vue';
|
|
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
|
|
import DownloadProgress from '@/components/download/DownloadProgress.vue';
|
|
import ArtworkGallery from '@/components/artwork/ArtworkGallery.vue';
|
|
import ArtworkInfoPanel from '@/components/artwork/ArtworkInfoPanel.vue';
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const authStore = useAuthStore();
|
|
const repositoryStore = useRepositoryStore();
|
|
|
|
// 状态
|
|
const artwork = ref<Artwork | null>(null);
|
|
const loading = ref(false);
|
|
const error = ref<string | null>(null);
|
|
const currentPage = ref(0);
|
|
const downloading = ref(false);
|
|
const isDownloaded = ref(false);
|
|
|
|
// 下载任务状态
|
|
const currentTask = ref<DownloadTask | null>(null);
|
|
const sseConnection = ref<(() => void) | null>(null);
|
|
|
|
// 收藏错误状态
|
|
const bookmarkError = ref<string | null>(null);
|
|
|
|
// 导航相关状态
|
|
const artistArtworks = ref<Artwork[]>([]);
|
|
const currentArtworkIndex = ref(-1);
|
|
const navigationLoading = ref(false);
|
|
|
|
// 导航相关计算属性
|
|
const showNavigation = computed(() => {
|
|
return !!(route.query.artistId && route.query.artworkType);
|
|
});
|
|
|
|
const previousArtwork = computed(() => {
|
|
if (currentArtworkIndex.value > 0) {
|
|
return artistArtworks.value[currentArtworkIndex.value - 1];
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const nextArtwork = computed(() => {
|
|
if (currentArtworkIndex.value >= 0 && currentArtworkIndex.value < artistArtworks.value.length - 1) {
|
|
return artistArtworks.value[currentArtworkIndex.value + 1];
|
|
}
|
|
return null;
|
|
});
|
|
|
|
// 获取作品详情
|
|
const fetchArtworkDetail = async () => {
|
|
const artworkId = parseInt(route.params.id as string);
|
|
if (isNaN(artworkId)) {
|
|
error.value = '无效的作品ID';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
loading.value = true;
|
|
error.value = null;
|
|
|
|
// 立即清理所有下载相关状态
|
|
currentTask.value = null;
|
|
downloading.value = false;
|
|
stopTaskStreaming();
|
|
|
|
const response = await artworkService.getArtworkDetail(artworkId);
|
|
|
|
if (response.success && response.data) {
|
|
// 重置页面状态
|
|
currentPage.value = 0;
|
|
|
|
// 更新作品数据
|
|
artwork.value = response.data;
|
|
|
|
// 检查下载状态
|
|
await checkDownloadStatus(artworkId);
|
|
} else {
|
|
throw new Error(response.error || '获取作品详情失败');
|
|
}
|
|
} catch (err) {
|
|
error.value = err instanceof Error ? err.message : '获取作品详情失败';
|
|
console.error('获取作品详情失败:', err);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
// 检查下载状态
|
|
const checkDownloadStatus = async (artworkId: number, retryCount = 0) => {
|
|
try {
|
|
const response = await repositoryStore.checkArtworkDownloaded(artworkId);
|
|
|
|
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}`);
|
|
}
|
|
|
|
isDownloaded.value = newStatus;
|
|
console.log('作品下载状态:', isDownloaded.value);
|
|
}
|
|
} catch (err) {
|
|
console.error('检查下载状态失败:', err);
|
|
isDownloaded.value = false;
|
|
|
|
// 如果是网络错误且重试次数少于3次,延迟重试
|
|
if (retryCount < 3 && (err instanceof Error && err.message.includes('network') || err instanceof TypeError)) {
|
|
console.log(`下载状态检查失败,${2000 * (retryCount + 1)}ms 后重试 (${retryCount + 1}/3)`);
|
|
setTimeout(() => {
|
|
checkDownloadStatus(artworkId, retryCount + 1);
|
|
}, 2000 * (retryCount + 1));
|
|
}
|
|
}
|
|
};
|
|
|
|
// 下载作品
|
|
const handleDownload = async () => {
|
|
if (!artwork.value) return;
|
|
|
|
try {
|
|
// 清理之前的任务状态
|
|
currentTask.value = null;
|
|
downloading.value = true;
|
|
|
|
// 如果已经下载过,则强制重新下载(跳过现有文件检查)
|
|
const skipExisting = !isDownloaded.value;
|
|
const response = await downloadService.downloadArtwork(artwork.value.id, {
|
|
skipExisting
|
|
});
|
|
|
|
if (response.success) {
|
|
console.log('下载响应:', response.data);
|
|
|
|
// 检查是否跳过下载
|
|
if (response.data.skipped) {
|
|
console.log('作品已存在,跳过下载');
|
|
// 重新检查下载状态
|
|
await checkDownloadStatus(artwork.value.id);
|
|
return;
|
|
}
|
|
|
|
// 如果是新任务,立即创建任务状态并开始监听进度
|
|
if (response.data.task_id) {
|
|
// 立即创建任务状态,让进度条立即显示
|
|
currentTask.value = {
|
|
id: response.data.task_id,
|
|
type: 'artwork',
|
|
status: 'downloading',
|
|
progress: 0,
|
|
total_files: response.data.total_files || 0,
|
|
completed_files: 0,
|
|
failed_files: 0,
|
|
artwork_id: artwork.value.id,
|
|
artist_name: response.data.artist_name,
|
|
artwork_title: response.data.artwork_title,
|
|
start_time: new Date().toISOString()
|
|
};
|
|
|
|
// 立即开始SSE监听任务进度
|
|
startTaskStreaming(response.data.task_id);
|
|
}
|
|
} else {
|
|
throw new Error(response.error || '下载失败');
|
|
}
|
|
} catch (err) {
|
|
error.value = err instanceof Error ? err.message : '下载失败';
|
|
console.error('下载失败:', err);
|
|
} finally {
|
|
downloading.value = false;
|
|
}
|
|
};
|
|
|
|
// 开始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();
|
|
}
|
|
);
|
|
};
|
|
|
|
|
|
|
|
// 停止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();
|
|
}
|
|
};
|
|
|
|
// 收藏/取消收藏
|
|
const handleBookmark = async () => {
|
|
if (!artwork.value) return;
|
|
|
|
try {
|
|
const action = artwork.value.is_bookmarked ? 'remove' : 'add';
|
|
const response = await artworkService.toggleBookmark(artwork.value.id, action);
|
|
|
|
if (response.success && response.data) {
|
|
// 更新作品状态
|
|
artwork.value.is_bookmarked = response.data.is_bookmarked;
|
|
artwork.value.total_bookmarks += artwork.value.is_bookmarked ? 1 : -1;
|
|
|
|
// 显示成功消息
|
|
console.log(response.data.message);
|
|
} else {
|
|
// 显示错误提示给用户
|
|
bookmarkError.value = response.error || '收藏操作失败';
|
|
console.error('收藏操作失败:', response.error);
|
|
}
|
|
} catch (err) {
|
|
// 显示错误提示给用户
|
|
bookmarkError.value = '藏暂时不可用,请去官方收藏或者取消收藏';
|
|
console.error('收藏操作失败:', err);
|
|
}
|
|
};
|
|
|
|
|
|
|
|
// 清除错误
|
|
const clearError = () => {
|
|
error.value = null;
|
|
};
|
|
|
|
// 清除收藏错误
|
|
const clearBookmarkError = () => {
|
|
bookmarkError.value = null;
|
|
};
|
|
|
|
// 获取作者作品列表用于导航
|
|
const fetchArtistArtworks = async () => {
|
|
const artistId = route.query.artistId;
|
|
const artworkType = route.query.artworkType;
|
|
|
|
if (!artistId || !artworkType) return;
|
|
|
|
try {
|
|
navigationLoading.value = true;
|
|
|
|
// 获取当前页面信息
|
|
const currentPage = parseInt(route.query.page as string) || 1;
|
|
const pageSize = 30;
|
|
const offset = (currentPage - 1) * pageSize;
|
|
|
|
const response = await artistService.getArtistArtworks(parseInt(artistId as string), {
|
|
type: artworkType as 'art' | 'manga' | 'novel',
|
|
offset: offset,
|
|
limit: pageSize
|
|
});
|
|
|
|
if (response.success && response.data) {
|
|
artistArtworks.value = response.data.artworks;
|
|
// 找到当前作品在列表中的位置
|
|
const currentId = parseInt(route.params.id as string);
|
|
currentArtworkIndex.value = artistArtworks.value.findIndex(art => art.id === currentId);
|
|
}
|
|
} catch (err) {
|
|
console.error('获取作者作品列表失败:', err);
|
|
} finally {
|
|
navigationLoading.value = false;
|
|
}
|
|
};
|
|
|
|
// 导航到上一个作品
|
|
const navigateToPrevious = () => {
|
|
if (previousArtwork.value && !loading.value) {
|
|
// 立即清理下载任务状态
|
|
currentTask.value = null;
|
|
downloading.value = false;
|
|
stopTaskStreaming();
|
|
|
|
// 立即设置加载状态
|
|
loading.value = true;
|
|
|
|
router.push({
|
|
path: `/artwork/${previousArtwork.value.id}`,
|
|
query: {
|
|
artistId: route.query.artistId,
|
|
artworkType: route.query.artworkType,
|
|
page: route.query.page,
|
|
returnUrl: route.query.returnUrl
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
// 导航到下一个作品
|
|
const navigateToNext = () => {
|
|
if (nextArtwork.value && !loading.value) {
|
|
// 立即清理下载任务状态
|
|
currentTask.value = null;
|
|
downloading.value = false;
|
|
stopTaskStreaming();
|
|
|
|
// 立即设置加载状态
|
|
loading.value = true;
|
|
|
|
router.push({
|
|
path: `/artwork/${nextArtwork.value.id}`,
|
|
query: {
|
|
artistId: route.query.artistId,
|
|
artworkType: route.query.artworkType,
|
|
page: route.query.page,
|
|
returnUrl: route.query.returnUrl
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
// 返回作者页面
|
|
const goBackToArtist = () => {
|
|
if (route.query.returnUrl) {
|
|
router.push(route.query.returnUrl as string);
|
|
} else if (route.query.artistId) {
|
|
router.push(`/artist/${route.query.artistId}`);
|
|
}
|
|
};
|
|
|
|
// 选中的标签状态
|
|
const selectedTags = ref<string[]>([]);
|
|
const isCtrlPressed = ref(false);
|
|
const isMultiSelecting = ref(false);
|
|
|
|
// 处理标签点击
|
|
const handleTagClick = (event: MouseEvent, tagName: string) => {
|
|
// 阻止默认行为
|
|
event.preventDefault();
|
|
|
|
console.log('标签点击事件:', {
|
|
tagName,
|
|
ctrlKey: event.ctrlKey,
|
|
metaKey: event.metaKey,
|
|
isCtrlPressed: isCtrlPressed.value
|
|
});
|
|
|
|
// 如果按住Ctrl键,则添加到选中状态(不跳转)
|
|
if (event.ctrlKey || event.metaKey) {
|
|
console.log('检测到Ctrl键,添加到选中状态');
|
|
isMultiSelecting.value = true;
|
|
|
|
// 切换标签的选中状态
|
|
const index = selectedTags.value.indexOf(tagName);
|
|
if (index > -1) {
|
|
// 如果已选中,则取消选中
|
|
selectedTags.value.splice(index, 1);
|
|
} else {
|
|
// 如果未选中,则添加到选中列表
|
|
selectedTags.value.push(tagName);
|
|
}
|
|
|
|
// console.log('当前选中的标签:', selectedTags.value);
|
|
|
|
// 保存到sessionStorage
|
|
sessionStorage.setItem('currentSearchTags', JSON.stringify(selectedTags.value));
|
|
|
|
// 不跳转,只是更新选中状态
|
|
} else {
|
|
// console.log('普通点击,执行单标签搜索');
|
|
// 普通点击,只搜索当前标签,清除之前的多标签选择
|
|
selectedTags.value = [];
|
|
sessionStorage.removeItem('currentSearchTags');
|
|
isMultiSelecting.value = false;
|
|
|
|
// 在新标签页中打开搜索
|
|
const searchUrl = router.resolve({
|
|
path: '/search',
|
|
query: {
|
|
mode: 'tags',
|
|
tag: tagName
|
|
}
|
|
}).href;
|
|
|
|
window.open(searchUrl, '_blank');
|
|
}
|
|
};
|
|
|
|
// 处理键盘事件
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Control' || event.key === 'Meta') {
|
|
isCtrlPressed.value = true;
|
|
}
|
|
};
|
|
|
|
const handleKeyUp = (event: KeyboardEvent) => {
|
|
if (event.key === 'Control' || event.key === 'Meta') {
|
|
isCtrlPressed.value = false;
|
|
|
|
// 当松开Ctrl键时,如果正在进行多选操作且有选中的标签,则在新标签页中打开搜索
|
|
if (isMultiSelecting.value && selectedTags.value.length > 0) {
|
|
console.log('松开Ctrl键,在新标签页中打开搜索,标签:', selectedTags.value);
|
|
|
|
// 在新标签页中打开搜索
|
|
const searchUrl = router.resolve({
|
|
path: '/search',
|
|
query: {
|
|
mode: 'tags',
|
|
tags: selectedTags.value
|
|
}
|
|
}).href;
|
|
|
|
window.open(searchUrl, '_blank');
|
|
|
|
// 清空选中状态
|
|
selectedTags.value = [];
|
|
sessionStorage.removeItem('currentSearchTags');
|
|
isMultiSelecting.value = false;
|
|
}
|
|
}
|
|
};
|
|
|
|
// 监听路由变化,重新获取作品详情和导航数据
|
|
watch(() => route.params.id, (newId, oldId) => {
|
|
// 如果是同一个ID,不重复加载
|
|
if (newId === oldId) return;
|
|
|
|
// 确保页面滚动到顶部
|
|
window.scrollTo(0, 0);
|
|
|
|
// 立即清理所有下载相关状态
|
|
currentTask.value = null;
|
|
downloading.value = false;
|
|
stopTaskStreaming();
|
|
|
|
// 重新获取作品详情
|
|
fetchArtworkDetail();
|
|
|
|
// 如果是从作者页面来的,重新获取导航数据
|
|
if (showNavigation.value) {
|
|
fetchArtistArtworks();
|
|
}
|
|
});
|
|
|
|
// 键盘快捷键支持
|
|
const handleKeydown = (event: KeyboardEvent) => {
|
|
if (!showNavigation.value) return;
|
|
|
|
if (event.key === 'ArrowLeft' && previousArtwork.value) {
|
|
event.preventDefault();
|
|
navigateToPrevious();
|
|
} else if (event.key === 'ArrowRight' && nextArtwork.value) {
|
|
event.preventDefault();
|
|
navigateToNext();
|
|
} else if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
goBackToArtist();
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
// 确保页面滚动到顶部
|
|
window.scrollTo(0, 0);
|
|
|
|
fetchArtworkDetail();
|
|
if (showNavigation.value) {
|
|
fetchArtistArtworks();
|
|
}
|
|
|
|
// 添加键盘事件监听
|
|
document.addEventListener('keydown', handleKeydown);
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
document.addEventListener('keyup', handleKeyUp);
|
|
});
|
|
|
|
// 组件卸载时移除事件监听和清理SSE连接
|
|
onUnmounted(() => {
|
|
document.removeEventListener('keydown', handleKeydown);
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
document.removeEventListener('keyup', handleKeyUp);
|
|
stopTaskStreaming();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.artwork-page {
|
|
min-height: 100vh;
|
|
background: #f8fafc;
|
|
padding: 2rem 0;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 0 2rem;
|
|
}
|
|
|
|
.loading-section,
|
|
.error-section {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 400px;
|
|
}
|
|
|
|
.artwork-content {
|
|
display: grid;
|
|
grid-template-columns: 1fr 460px;
|
|
gap: 3rem;
|
|
align-items: start;
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
|
|
.content-loading {
|
|
opacity: 0.7;
|
|
pointer-events: none;
|
|
}
|
|
|
|
|
|
|
|
@media (max-width: 1024px) {
|
|
.artwork-content {
|
|
grid-template-columns: 1fr;
|
|
gap: 2rem;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.container {
|
|
padding: 0 1rem;
|
|
}
|
|
}
|
|
</style> |