| <template> |
| <div class="sora-gallery-page"> |
| |
| <div class="sora-gallery-filter-bar"> |
| <div class="sora-gallery-filters"> |
| <button |
| v-for="f in filters" |
| :key="f.value" |
| :class="['sora-gallery-filter', activeFilter === f.value && 'active']" |
| @click="activeFilter = f.value" |
| > |
| {{ f.label }} |
| </button> |
| </div> |
| <span class="sora-gallery-count"> |
| {{ t('sora.galleryCount', { count: filteredItems.length }) }} |
| </span> |
| </div> |
| |
| |
| <div v-if="filteredItems.length > 0" class="sora-gallery-grid"> |
| <div |
| v-for="item in filteredItems" |
| :key="item.id" |
| class="sora-gallery-card" |
| @click="openPreview(item)" |
| > |
| <div class="sora-gallery-card-thumb"> |
| |
| <video |
| v-if="item.media_type === 'video' && item.media_url" |
| :src="item.media_url" |
| class="sora-gallery-card-image" |
| muted |
| loop |
| @mouseenter="($event.target as HTMLVideoElement).play()" |
| @mouseleave="($event.target as HTMLVideoElement).pause()" |
| /> |
| <img |
| v-else-if="item.media_url" |
| :src="item.media_url" |
| class="sora-gallery-card-image" |
| alt="" |
| /> |
| <div v-else class="sora-gallery-card-image sora-gallery-card-placeholder" :class="getGradientClass(item.id)"> |
| {{ item.media_type === 'video' ? '🎬' : '🎨' }} |
| </div> |
| |
| |
| <span |
| class="sora-gallery-card-badge" |
| :class="item.media_type === 'video' ? 'video' : 'image'" |
| > |
| {{ item.media_type === 'video' ? 'VIDEO' : 'IMAGE' }} |
| </span> |
| |
| |
| <div class="sora-gallery-card-overlay"> |
| <button |
| v-if="item.media_url" |
| class="sora-gallery-card-action" |
| title="下载" |
| @click.stop="handleDownload(item)" |
| > |
| 📥 |
| </button> |
| <button |
| class="sora-gallery-card-action" |
| title="删除" |
| @click.stop="handleDelete(item.id)" |
| > |
| 🗑 |
| </button> |
| </div> |
| |
| |
| <div v-if="item.media_type === 'video'" class="sora-gallery-card-play">▶</div> |
| |
| |
| <span v-if="item.media_type === 'video'" class="sora-gallery-card-duration"> |
| {{ formatDuration(item) }} |
| </span> |
| </div> |
| |
| |
| <div class="sora-gallery-card-info"> |
| <div class="sora-gallery-card-model">{{ item.model }}</div> |
| <div class="sora-gallery-card-time">{{ formatTime(item.created_at) }}</div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div v-else-if="!loading" class="sora-gallery-empty"> |
| <div class="sora-gallery-empty-icon">🎬</div> |
| <h2 class="sora-gallery-empty-title">{{ t('sora.galleryEmptyTitle') }}</h2> |
| <p class="sora-gallery-empty-desc">{{ t('sora.galleryEmptyDesc') }}</p> |
| <button class="sora-gallery-empty-btn" @click="emit('switchToGenerate')"> |
| {{ t('sora.startCreating') }} |
| </button> |
| </div> |
| |
| |
| <div v-if="hasMore && filteredItems.length > 0" class="sora-gallery-load-more"> |
| <button |
| class="sora-gallery-load-more-btn" |
| :disabled="loading" |
| @click="loadMore" |
| > |
| {{ loading ? t('sora.loading') : t('sora.loadMore') }} |
| </button> |
| </div> |
| |
| |
| <SoraMediaPreview |
| :visible="previewVisible" |
| :generation="previewItem" |
| @close="previewVisible = false" |
| @save="handleSaveFromPreview" |
| @download="handleDownloadUrl" |
| /> |
| </div> |
| </template> |
| |
| <script setup lang="ts"> |
| import { ref, computed, onMounted } from 'vue' |
| import { useI18n } from 'vue-i18n' |
| import soraAPI, { type SoraGeneration } from '@/api/sora' |
| import { getPersistedPageSize } from '@/composables/usePersistedPageSize' |
| import SoraMediaPreview from './SoraMediaPreview.vue' |
| |
| const emit = defineEmits<{ |
| 'switchToGenerate': [] |
| }>() |
| |
| const { t } = useI18n() |
| |
| const items = ref<SoraGeneration[]>([]) |
| const loading = ref(false) |
| const page = ref(1) |
| const hasMore = ref(true) |
| const activeFilter = ref('all') |
| const previewVisible = ref(false) |
| const previewItem = ref<SoraGeneration | null>(null) |
| |
| const filters = computed(() => [ |
| { value: 'all', label: t('sora.filterAll') }, |
| { value: 'video', label: t('sora.filterVideo') }, |
| { value: 'image', label: t('sora.filterImage') } |
| ]) |
| |
| const filteredItems = computed(() => { |
| if (activeFilter.value === 'all') return items.value |
| return items.value.filter(i => i.media_type === activeFilter.value) |
| }) |
| |
| const gradientClasses = [ |
| 'gradient-bg-1', 'gradient-bg-2', 'gradient-bg-3', 'gradient-bg-4', |
| 'gradient-bg-5', 'gradient-bg-6', 'gradient-bg-7', 'gradient-bg-8' |
| ] |
| |
| function getGradientClass(id: number): string { |
| return gradientClasses[id % gradientClasses.length] |
| } |
| |
| function formatTime(iso: string): string { |
| const d = new Date(iso) |
| const now = new Date() |
| const diff = now.getTime() - d.getTime() |
| if (diff < 60000) return t('sora.justNow') |
| if (diff < 3600000) return t('sora.minutesAgo', { n: Math.floor(diff / 60000) }) |
| if (diff < 86400000) return t('sora.hoursAgo', { n: Math.floor(diff / 3600000) }) |
| if (diff < 2 * 86400000) return t('sora.yesterday') |
| return d.toLocaleDateString() |
| } |
| |
| function formatDuration(item: SoraGeneration): string { |
| |
| const match = item.model.match(/(\d+)s$/) |
| if (match) { |
| const sec = parseInt(match[1]) |
| return `0:${sec.toString().padStart(2, '0')}` |
| } |
| return '0:10' |
| } |
| |
| async function loadItems(pageNum: number) { |
| loading.value = true |
| try { |
| const res = await soraAPI.listGenerations({ |
| status: 'completed', |
| storage_type: 's3,local', |
| page: pageNum, |
| page_size: getPersistedPageSize() |
| }) |
| const rows = Array.isArray(res.data) ? res.data : [] |
| if (pageNum === 1) { |
| items.value = rows |
| } else { |
| items.value.push(...rows) |
| } |
| hasMore.value = items.value.length < res.total |
| } catch (e) { |
| console.error('Failed to load library:', e) |
| } finally { |
| loading.value = false |
| } |
| } |
| |
| function loadMore() { |
| page.value++ |
| loadItems(page.value) |
| } |
| |
| function openPreview(item: SoraGeneration) { |
| previewItem.value = item |
| previewVisible.value = true |
| } |
| |
| async function handleDelete(id: number) { |
| if (!confirm(t('sora.confirmDelete'))) return |
| try { |
| await soraAPI.deleteGeneration(id) |
| items.value = items.value.filter(i => i.id !== id) |
| } catch (e) { |
| console.error('Delete failed:', e) |
| } |
| } |
| |
| function handleDownload(item: SoraGeneration) { |
| if (item.media_url) { |
| window.open(item.media_url, '_blank') |
| } |
| } |
| |
| function handleDownloadUrl(url: string) { |
| window.open(url, '_blank') |
| } |
| |
| async function handleSaveFromPreview(id: number) { |
| try { |
| await soraAPI.saveToStorage(id) |
| const gen = await soraAPI.getGeneration(id) |
| const idx = items.value.findIndex(i => i.id === id) |
| if (idx >= 0) items.value[idx] = gen |
| } catch (e) { |
| console.error('Save failed:', e) |
| } |
| } |
| |
| onMounted(() => loadItems(1)) |
| </script> |
| |
| <style scoped> |
| .sora-gallery-page { |
| padding: 24px; |
| padding-bottom: 40px; |
| } |
| |
| |
| .sora-gallery-filter-bar { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| margin-bottom: 24px; |
| } |
| |
| .sora-gallery-filters { |
| display: flex; |
| gap: 4px; |
| background: var(--sora-bg-secondary, #1A1A1A); |
| border-radius: var(--sora-radius-full, 9999px); |
| padding: 3px; |
| } |
| |
| .sora-gallery-filter { |
| padding: 6px 18px; |
| border-radius: var(--sora-radius-full, 9999px); |
| font-size: 13px; |
| font-weight: 500; |
| color: var(--sora-text-secondary, #A0A0A0); |
| background: none; |
| border: none; |
| cursor: pointer; |
| transition: all 150ms ease; |
| user-select: none; |
| } |
| |
| .sora-gallery-filter:hover { |
| color: var(--sora-text-primary, #FFF); |
| } |
| |
| .sora-gallery-filter.active { |
| background: var(--sora-bg-tertiary, #242424); |
| color: var(--sora-text-primary, #FFF); |
| } |
| |
| .sora-gallery-count { |
| font-size: 13px; |
| color: var(--sora-text-tertiary, #666); |
| } |
| |
| |
| .sora-gallery-grid { |
| display: grid; |
| grid-template-columns: repeat(4, 1fr); |
| gap: 16px; |
| } |
| |
| |
| .sora-gallery-card { |
| position: relative; |
| border-radius: var(--sora-radius-md, 12px); |
| overflow: hidden; |
| background: var(--sora-bg-secondary, #1A1A1A); |
| border: 1px solid var(--sora-border-color, #2A2A2A); |
| cursor: pointer; |
| transition: all 250ms ease; |
| } |
| |
| .sora-gallery-card:hover { |
| border-color: var(--sora-bg-hover, #333); |
| transform: translateY(-2px); |
| box-shadow: var(--sora-shadow-lg, 0 8px 32px rgba(0,0,0,0.5)); |
| } |
| |
| .sora-gallery-card-thumb { |
| position: relative; |
| width: 100%; |
| aspect-ratio: 16/9; |
| overflow: hidden; |
| } |
| |
| .sora-gallery-card-image { |
| width: 100%; |
| height: 100%; |
| object-fit: cover; |
| display: block; |
| transition: transform 400ms ease; |
| } |
| |
| .sora-gallery-card:hover .sora-gallery-card-image { |
| transform: scale(1.05); |
| } |
| |
| .sora-gallery-card-placeholder { |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-size: 32px; |
| } |
| |
| |
| .gradient-bg-1 { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); } |
| .gradient-bg-2 { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); } |
| .gradient-bg-3 { background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); } |
| .gradient-bg-4 { background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); } |
| .gradient-bg-5 { background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); } |
| .gradient-bg-6 { background: linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%); } |
| .gradient-bg-7 { background: linear-gradient(135deg, #fccb90 0%, #d57eeb 100%); } |
| .gradient-bg-8 { background: linear-gradient(135deg, #e0c3fc 0%, #8ec5fc 100%); } |
| |
| |
| .sora-gallery-card-badge { |
| position: absolute; |
| top: 8px; |
| left: 8px; |
| padding: 3px 8px; |
| border-radius: var(--sora-radius-sm, 8px); |
| font-size: 10px; |
| font-weight: 600; |
| text-transform: uppercase; |
| letter-spacing: 0.05em; |
| backdrop-filter: blur(8px); |
| } |
| |
| .sora-gallery-card-badge.video { |
| background: rgba(20, 184, 166, 0.8); |
| color: white; |
| } |
| |
| .sora-gallery-card-badge.image { |
| background: rgba(16, 185, 129, 0.8); |
| color: white; |
| } |
| |
| |
| .sora-gallery-card-overlay { |
| position: absolute; |
| inset: 0; |
| background: rgba(0, 0, 0, 0.6); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 12px; |
| opacity: 0; |
| transition: opacity 150ms ease; |
| } |
| |
| .sora-gallery-card:hover .sora-gallery-card-overlay { |
| opacity: 1; |
| } |
| |
| .sora-gallery-card-action { |
| width: 40px; |
| height: 40px; |
| border-radius: 50%; |
| background: rgba(255, 255, 255, 0.15); |
| backdrop-filter: blur(8px); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-size: 16px; |
| color: white; |
| border: none; |
| cursor: pointer; |
| transition: all 150ms ease; |
| } |
| |
| .sora-gallery-card-action:hover { |
| background: rgba(255, 255, 255, 0.25); |
| transform: scale(1.1); |
| } |
| |
| |
| .sora-gallery-card-play { |
| position: absolute; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| width: 48px; |
| height: 48px; |
| border-radius: 50%; |
| background: rgba(255, 255, 255, 0.2); |
| backdrop-filter: blur(8px); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-size: 20px; |
| color: white; |
| opacity: 0; |
| transition: all 150ms ease; |
| pointer-events: none; |
| } |
| |
| .sora-gallery-card:hover .sora-gallery-card-play { |
| opacity: 1; |
| } |
| |
| |
| .sora-gallery-card-duration { |
| position: absolute; |
| bottom: 8px; |
| right: 8px; |
| padding: 2px 6px; |
| border-radius: 4px; |
| background: rgba(0, 0, 0, 0.7); |
| font-size: 11px; |
| font-family: "SF Mono", "Fira Code", monospace; |
| color: white; |
| } |
| |
| |
| .sora-gallery-card-info { |
| padding: 12px; |
| } |
| |
| .sora-gallery-card-model { |
| font-size: 11px; |
| font-family: "SF Mono", "Fira Code", monospace; |
| color: var(--sora-text-tertiary, #666); |
| margin-bottom: 4px; |
| } |
| |
| .sora-gallery-card-time { |
| font-size: 12px; |
| color: var(--sora-text-muted, #4A4A4A); |
| } |
| |
| |
| .sora-gallery-empty { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| padding: 120px 40px; |
| text-align: center; |
| } |
| |
| .sora-gallery-empty-icon { |
| font-size: 64px; |
| margin-bottom: 24px; |
| opacity: 0.3; |
| } |
| |
| .sora-gallery-empty-title { |
| font-size: 20px; |
| font-weight: 600; |
| margin-bottom: 8px; |
| color: var(--sora-text-secondary, #A0A0A0); |
| } |
| |
| .sora-gallery-empty-desc { |
| font-size: 14px; |
| color: var(--sora-text-tertiary, #666); |
| max-width: 360px; |
| line-height: 1.6; |
| } |
| |
| .sora-gallery-empty-btn { |
| margin-top: 24px; |
| padding: 10px 28px; |
| background: var(--sora-accent-gradient, linear-gradient(135deg, #14b8a6, #0d9488)); |
| border-radius: var(--sora-radius-full, 9999px); |
| font-size: 14px; |
| font-weight: 500; |
| color: white; |
| border: none; |
| cursor: pointer; |
| transition: all 150ms ease; |
| } |
| |
| .sora-gallery-empty-btn:hover { |
| box-shadow: var(--sora-shadow-glow, 0 0 20px rgba(20,184,166,0.3)); |
| } |
| |
| |
| .sora-gallery-load-more { |
| display: flex; |
| justify-content: center; |
| margin-top: 24px; |
| } |
| |
| .sora-gallery-load-more-btn { |
| padding: 10px 28px; |
| background: var(--sora-bg-secondary, #1A1A1A); |
| border: 1px solid var(--sora-border-color, #2A2A2A); |
| border-radius: var(--sora-radius-full, 9999px); |
| font-size: 13px; |
| color: var(--sora-text-secondary, #A0A0A0); |
| cursor: pointer; |
| transition: all 150ms ease; |
| } |
| |
| .sora-gallery-load-more-btn:hover { |
| background: var(--sora-bg-tertiary, #242424); |
| color: var(--sora-text-primary, #FFF); |
| } |
| |
| .sora-gallery-load-more-btn:disabled { |
| opacity: 0.5; |
| cursor: not-allowed; |
| } |
| |
| |
| @media (max-width: 1200px) { |
| .sora-gallery-grid { |
| grid-template-columns: repeat(3, 1fr); |
| } |
| } |
| |
| @media (max-width: 900px) { |
| .sora-gallery-grid { |
| grid-template-columns: repeat(2, 1fr); |
| } |
| } |
| |
| @media (max-width: 600px) { |
| .sora-gallery-page { |
| padding: 16px; |
| } |
| |
| .sora-gallery-grid { |
| grid-template-columns: 1fr; |
| } |
| } |
| </style> |
| |