支持动图下载和预览

This commit is contained in:
2025-10-13 15:43:18 +08:00
parent e85f959fa6
commit 5be8ae9520
18 changed files with 909 additions and 32 deletions
+1
View File
@@ -18,6 +18,7 @@
"axios": "^1.11.0",
"pinia": "^3.0.3",
"vue": "^3.5.18",
"jszip": "^3.10.1",
"vue-router": "^4.5.1"
},
"devDependencies": {
+6 -3
View File
@@ -1,20 +1,22 @@
<template>
<div class="artwork-gallery">
<div class="main-image">
<div v-if="artwork.type !== 'ugoira'" class="main-image">
<img :src="getImageUrl(currentImageUrl)" :alt="artwork.title" @load="imageLoaded = true"
@error="imageError = true" :class="{ loaded: imageLoaded, error: imageError }" crossorigin="anonymous" />
@error="imageError = true" :class="{ loaded: imageLoaded, error: imageError }" crossorigin="anonymous" />
<div v-if="!imageLoaded && !imageError" class="image-placeholder">
<LoadingSpinner text="图片加载中..." />
</div>
<div v-if="imageError" class="image-error">
<span>图片加载失败</span>
</div>
<!-- 页面切换时的遮罩层 -->
<div v-if="loading" class="image-overlay">
<LoadingSpinner text="切换中..." />
</div>
</div>
<!-- Ugoira动图播放器 -->
<UgoiraPlayer v-else :artwork="artwork" />
<!-- 多页作品缩略图 -->
<div v-if="artwork.page_count > 1" class="thumbnails">
<button v-for="(page, index) in artwork.meta_pages" :key="index" @click="$emit('pageChange', index)"
@@ -40,6 +42,7 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
import { getImageProxyUrl } from '@/services/api';
import type { Artwork } from '@/types';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
import UgoiraPlayer from '@/components/artwork/UgoiraPlayer.vue';
interface Props {
artwork: Artwork;
@@ -740,7 +740,6 @@ input:checked+.slider:before {
margin: 0 auto;
}
/* 移动端导航优化 */
.artwork-navigation {
position: sticky;
bottom: 0;
@@ -751,13 +750,16 @@ input:checked+.slider:before {
border-radius: var(--radius-xl);
padding: var(--spacing-lg);
margin: var(--spacing-xl) 0 0 0;
display: grid;
grid-template-columns: auto 1fr auto;
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-md);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.15);
z-index: 1001;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
flex-wrap: nowrap;
min-height: 56px; /* 确保最小高度以适应按钮 */
}
.nav-back {
@@ -767,6 +769,7 @@ input:checked+.slider:before {
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
border-color: var(--color-border);
flex-shrink: 0;
}
.nav-back span {
@@ -780,6 +783,9 @@ input:checked+.slider:before {
font-weight: 600;
border-radius: var(--radius-lg);
height: 44px;
flex: 1;
flex-shrink: 1;
min-width: 100px; /* 确保按钮最小宽度 */
}
.nav-prev {
+171
View File
@@ -0,0 +1,171 @@
<template>
<div class="ugoira-player">
<div class="player-stage">
<img v-if="currentFrameUrl" :src="currentFrameUrl" class="stage-image" crossorigin="anonymous" />
<div v-else class="stage-placeholder">
<LoadingSpinner text="动图加载中..." />
</div>
<div v-if="error" class="stage-error">{{ error }}</div>
</div>
<div class="player-controls">
<button class="btn btn-primary btn-small" @click="togglePlay" :disabled="loading || !!error">
{{ playing ? '暂停' : '播放' }}
</button>
<span class="status-text" v-if="loading">预加载帧 {{ loadedCount }}/{{ frames.length }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import JSZip from 'jszip';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
import artworkService from '@/services/artwork';
import { getPximgFileProxyUrl } from '@/services/api';
import type { Artwork } from '@/types';
interface Props {
artwork: Artwork;
}
const props = defineProps<Props>();
const loading = ref(true);
const error = ref<string | null>(null);
const frames = ref<{ file: string; delay: number }[]>([]);
const frameUrls = ref<string[]>([]);
const currentFrameIndex = ref(0);
const playing = ref(true);
let timer: number | null = null;
const loadedCount = ref(0);
const currentFrameUrl = ref<string>('');
const clearTimer = () => {
if (timer) {
window.clearTimeout(timer);
timer = null;
}
};
const cleanupUrls = () => {
frameUrls.value.forEach((url) => URL.revokeObjectURL(url));
frameUrls.value = [];
};
const scheduleNextFrame = () => {
clearTimer();
if (!playing.value || frames.value.length === 0) return;
const delay = frames.value[currentFrameIndex.value]?.delay || 60;
timer = window.setTimeout(() => {
currentFrameIndex.value = (currentFrameIndex.value + 1) % frames.value.length;
currentFrameUrl.value = frameUrls.value[currentFrameIndex.value] || '';
scheduleNextFrame();
}, delay);
};
const togglePlay = () => {
playing.value = !playing.value;
if (playing.value) scheduleNextFrame();
else clearTimer();
};
const loadUgoira = async () => {
try {
loading.value = true;
error.value = null;
// 获取元数据
const metaResp = await artworkService.getUgoiraMeta(props.artwork.id);
if (!metaResp.success || !metaResp.data) throw new Error(metaResp.error || '获取ugoira元数据失败');
frames.value = metaResp.data.frames || [];
// 优先使用原始zip,如果没有则用medium
const zipUrl = metaResp.data.zip_urls.original || metaResp.data.zip_urls.medium || '';
if (!zipUrl) throw new Error('缺少Ugoira ZIP地址');
const proxied = getPximgFileProxyUrl(zipUrl);
// 下载ZIP
const resp = await fetch(proxied);
if (!resp.ok) throw new Error(`下载ZIP失败: ${resp.status}`);
const buf = await resp.arrayBuffer();
const zip = await JSZip.loadAsync(buf);
// 预加载帧
const orderedFrames = frames.value.slice().sort((a, b) => a.file.localeCompare(b.file));
for (const fr of orderedFrames) {
const fileEntry = zip.file(fr.file);
if (!fileEntry) continue;
const blob = await fileEntry.async('blob');
const url = URL.createObjectURL(blob);
frameUrls.value.push(url);
loadedCount.value = frameUrls.value.length;
}
if (frameUrls.value.length === 0) throw new Error('ZIP中未找到帧图片');
currentFrameIndex.value = 0;
currentFrameUrl.value = frameUrls.value[0];
loading.value = false;
playing.value = true;
scheduleNextFrame();
} catch (e: any) {
error.value = e?.message || '加载ugoira失败';
loading.value = false;
playing.value = false;
clearTimer();
}
};
onMounted(() => {
loadUgoira();
});
onUnmounted(() => {
clearTimer();
cleanupUrls();
});
// 当artwork变化时重新加载
watch(() => props.artwork.id, () => {
clearTimer();
cleanupUrls();
loadUgoira();
});
</script>
<style scoped>
.ugoira-player {
background: var(--color-bg-primary);
border-radius: var(--radius-xl);
overflow: hidden;
}
.player-stage {
position: relative;
aspect-ratio: 1;
background: var(--color-bg-tertiary);
}
.stage-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.stage-placeholder,
.stage-error {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.player-controls {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
}
.status-text {
color: var(--color-text-secondary);
font-size: 12px;
}
</style>
+2 -2
View File
@@ -494,10 +494,10 @@ const rebuildRegistry = async () => {
// 开始轮询进度
const startProgressPolling = () => {
if (progressPollingInterval.value) {
clearInterval(progressPollingInterval.value);
window.clearInterval(progressPollingInterval.value);
}
progressPollingInterval.value = setInterval(async () => {
progressPollingInterval.value = window.setInterval(async () => {
if (!rebuildTaskId.value) return;
try {
@@ -58,8 +58,8 @@ const filterBy = ref(props.initialFilter)
// 防抖搜索
let searchTimeout: number
const debounceSearch = () => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
window.clearTimeout(searchTimeout)
searchTimeout = window.setTimeout(() => {
emit('search', searchQuery.value)
}, 300)
}
+11 -1
View File
@@ -20,6 +20,16 @@ export const getImageProxyUrl = (originalUrl: string) => {
return originalUrl;
};
// 获取Pximg资源(包括ZIP等文件)的代理URL
export const getPximgFileProxyUrl = (originalUrl: string) => {
if (!originalUrl) return '';
if (originalUrl.includes('i.pximg.net')) {
const encodedUrl = encodeURIComponent(originalUrl);
return `${getApiBaseUrl()}/api/proxy/file?url=${encodedUrl}`;
}
return originalUrl;
};
class ApiService {
private client: AxiosInstance;
@@ -104,4 +114,4 @@ class ApiService {
}
export const apiService = new ApiService();
export default apiService;
export default apiService;
+8 -1
View File
@@ -51,6 +51,13 @@ class ArtworkService {
return apiService.get<ArtworkImagesResponse>(`/api/artwork/${id}/images?size=${size}`);
}
/**
* 获取Ugoira元数据(包含zip_urls和frames
*/
async getUgoiraMeta(id: number): Promise<ApiResponse<{ artwork_id: number; zip_urls: { medium?: string; original?: string }; frames: { file: string; delay: number }[] }>> {
return apiService.get(`/api/artwork/${id}/ugoira`);
}
/**
* 搜索作品
*/
@@ -112,4 +119,4 @@ class ArtworkService {
}
export const artworkService = new ArtworkService();
export default artworkService;
export default artworkService;