mirror of
https://github.com/Moe-Sakura/frontend.git
synced 2026-03-19 05:39:45 +08:00
Enhance VndbPanel and SettingsModal components with improved UI and functionality
- Added a text scrolling feature to the API URL display in SettingsModal.vue for better visibility. - Updated VndbPanel.vue to implement skeleton loading animations for images, enhancing user experience during content loading. - Refactored image loading logic to ensure proper display and handling of loading states for screenshots and character images.
This commit is contained in:
@@ -139,7 +139,8 @@
|
||||
<!-- 移动端:URL 显示在第二行;桌面端:显示在右侧 -->
|
||||
<span
|
||||
v-if="option.value !== 'custom'"
|
||||
class="text-xs text-gray-400 dark:text-slate-500 font-mono mt-1.5 sm:mt-0 ml-8 sm:ml-0 truncate"
|
||||
v-text-scroll
|
||||
class="text-xs text-gray-400 dark:text-slate-500 font-mono mt-1.5 sm:mt-0 ml-8 sm:ml-0 flex-1 min-w-0"
|
||||
>
|
||||
{{ getApiUrl(option.value) }}
|
||||
</span>
|
||||
|
||||
@@ -113,17 +113,14 @@
|
||||
<!-- 封面图 -->
|
||||
<div v-if="searchStore.vndbInfo.mainImageUrl" class="mb-4">
|
||||
<button
|
||||
class="block w-full max-w-sm mx-auto relative"
|
||||
class="block w-full max-w-sm mx-auto"
|
||||
@click="openGallery(0)"
|
||||
>
|
||||
<!-- 占位符 -->
|
||||
<div class="w-full aspect-[2/3] rounded-2xl bg-gradient-to-br from-pink-100 to-purple-100 dark:from-pink-900/30 dark:to-purple-900/30 animate-pulse absolute inset-0" />
|
||||
<img
|
||||
:src="searchStore.vndbInfo.mainImageUrl"
|
||||
:alt="searchStore.vndbInfo.mainName"
|
||||
class="relative w-full h-auto rounded-2xl shadow-lg cursor-pointer hover:opacity-90 hover:scale-[1.02] transition-all"
|
||||
class="w-full h-auto rounded-2xl shadow-lg cursor-pointer hover:opacity-90 hover:scale-[1.02] transition-all"
|
||||
loading="lazy"
|
||||
@load="($event.target as HTMLElement).parentElement?.querySelector('.animate-pulse')?.classList.add('hidden')"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</button>
|
||||
@@ -339,18 +336,18 @@
|
||||
class="relative rounded-xl overflow-hidden shadow-md hover:shadow-lg hover:scale-105 transition-all group"
|
||||
>
|
||||
<!-- 图片区域 -->
|
||||
<div class="aspect-[3/4] w-full">
|
||||
<!-- 占位符 -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-cyan-200 to-cyan-300 dark:from-cyan-800 dark:to-cyan-900 animate-pulse" />
|
||||
<div class="w-full relative">
|
||||
<!-- 骨架屏占位 (padding-bottom: 133.33% = 3:4 比例) -->
|
||||
<div class="w-full pb-[133.33%] skeleton bg-cyan-100 dark:bg-cyan-900/30" />
|
||||
<img
|
||||
v-if="getCharacterImage(voiceActor.character?.id)"
|
||||
:src="getCharacterImage(voiceActor.character?.id)!"
|
||||
:alt="voiceActor.character?.name"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
@load="($event.target as HTMLElement).parentElement?.querySelector('.animate-pulse')?.classList.add('hidden')"
|
||||
@load="($event.target as HTMLElement).parentElement?.querySelector('.skeleton')?.classList.add('hidden')"
|
||||
/>
|
||||
<div v-else class="absolute inset-0 flex items-center justify-center">
|
||||
<div v-else class="absolute inset-0 flex items-center justify-center bg-cyan-50 dark:bg-cyan-900/30">
|
||||
<Users :size="24" class="text-cyan-400 dark:text-cyan-600" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -445,16 +442,16 @@
|
||||
class="relative rounded-xl overflow-hidden shadow-md hover:shadow-lg hover:scale-105 transition-all group"
|
||||
>
|
||||
<!-- 图片区域 -->
|
||||
<div class="aspect-[3/4] w-full">
|
||||
<!-- 占位符 -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-rose-200 to-rose-300 dark:from-rose-800 dark:to-rose-900 animate-pulse" />
|
||||
<div class="w-full relative">
|
||||
<!-- 骨架屏占位 (padding-bottom: 133.33% = 3:4 比例) -->
|
||||
<div class="w-full pb-[133.33%] skeleton bg-rose-100 dark:bg-rose-900/30" />
|
||||
<img
|
||||
v-if="char.image"
|
||||
:src="char.image"
|
||||
:alt="char.name"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
@load="($event.target as HTMLElement).parentElement?.querySelector('.animate-pulse')?.classList.add('hidden')"
|
||||
@load="($event.target as HTMLElement).parentElement?.querySelector('.skeleton')?.classList.add('hidden')"
|
||||
/>
|
||||
<div v-else class="absolute inset-0 flex items-center justify-center">
|
||||
<Users :size="24" class="text-rose-400 dark:text-rose-600" />
|
||||
@@ -552,8 +549,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 游戏截图 -->
|
||||
<div v-if="searchStore.vndbInfo.screenshots && searchStore.vndbInfo.screenshots.length > 0" class="vndb-card">
|
||||
<!-- 游戏截图 (等待首张图片加载后显示) -->
|
||||
<div
|
||||
v-if="searchStore.vndbInfo.screenshots && searchStore.vndbInfo.screenshots.length > 0"
|
||||
v-show="screenshotsReady"
|
||||
class="vndb-card"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<Image :size="18" class="text-[#d946ef]" />
|
||||
<h3 class="font-bold text-gray-800 dark:text-white">游戏截图</h3>
|
||||
@@ -562,17 +563,15 @@
|
||||
<button
|
||||
v-for="(screenshot, index) in searchStore.vndbInfo.screenshots"
|
||||
:key="index"
|
||||
class="group relative block overflow-hidden rounded-xl shadow-md hover:shadow-xl transition-all"
|
||||
class="group block overflow-hidden rounded-xl shadow-md hover:shadow-xl transition-all"
|
||||
@click="openGallery(index + 1)"
|
||||
>
|
||||
<!-- 占位符 -->
|
||||
<div class="aspect-video w-full bg-gradient-to-br from-purple-100 to-pink-100 dark:from-purple-900/30 dark:to-pink-900/30 animate-pulse" />
|
||||
<img
|
||||
:src="screenshot"
|
||||
:alt="`${searchStore.vndbInfo.mainName} 截图 ${index + 1}`"
|
||||
class="absolute inset-0 w-full h-full object-cover cursor-pointer group-hover:scale-105 transition-transform duration-300"
|
||||
class="w-full h-auto cursor-pointer group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
@load="($event.target as HTMLElement).parentElement?.querySelector('.animate-pulse')?.classList.add('hidden')"
|
||||
@load="screenshotsReady = true"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</button>
|
||||
@@ -586,7 +585,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { ref, watch, computed, nextTick } from 'vue'
|
||||
import { useSearchStore, type VndbCharacter, type VndbQuote } from '@/stores/search'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { translateText, fetchVndbCharacters, fetchVndbQuotes } from '@/api/search'
|
||||
@@ -682,6 +681,9 @@ const hasAnyTranslation = computed(() =>
|
||||
translatedDescription.value || translatedTags.value.size > 0 || translatedQuotes.value.size > 0,
|
||||
)
|
||||
|
||||
// 截图加载状态
|
||||
const screenshotsReady = ref(false)
|
||||
|
||||
// 展开/收起状态
|
||||
const expandedSections = ref({
|
||||
names: false,
|
||||
@@ -746,6 +748,8 @@ watch(() => searchStore.vndbInfo, async (newInfo) => {
|
||||
showOriginalQuotes.value = false
|
||||
isTranslatingQuotes.value = false
|
||||
translateQuotesError.value = false
|
||||
// 重置截图加载状态
|
||||
screenshotsReady.value = false
|
||||
// 重置角色和名言
|
||||
characters.value = []
|
||||
quotes.value = []
|
||||
@@ -762,6 +766,22 @@ watch(() => searchStore.vndbInfo, async (newInfo) => {
|
||||
if (newInfo?.id) {
|
||||
loadCharactersAndQuotes(newInfo.id)
|
||||
}
|
||||
|
||||
// 检查缓存的截图是否已加载(nextTick 后检查 img.complete)
|
||||
if (newInfo?.screenshots && newInfo.screenshots.length > 0) {
|
||||
nextTick(() => {
|
||||
// 延迟一帧确保 DOM 已渲染
|
||||
requestAnimationFrame(() => {
|
||||
const vndbContent = document.querySelector('.vndb-content')
|
||||
if (vndbContent) {
|
||||
const firstScreenshot = vndbContent.querySelector('img[loading="lazy"]') as HTMLImageElement
|
||||
if (firstScreenshot?.complete && firstScreenshot.naturalHeight > 0) {
|
||||
screenshotsReady.value = true
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 加载角色和名言
|
||||
@@ -868,6 +888,7 @@ async function handleTranslateQuotes() {
|
||||
return
|
||||
}
|
||||
|
||||
playClick()
|
||||
isTranslatingQuotes.value = true
|
||||
translateQuotesError.value = false
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { ref, onMounted, onUnmounted, watchEffect, type Ref } from 'vue'
|
||||
|
||||
// 扩展 HTMLElement 类型,添加自定义属性
|
||||
interface TextScrollElement extends HTMLElement {
|
||||
_textScrollObserver?: ResizeObserver
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测元素文本是否溢出,并设置滚动动画
|
||||
@@ -65,27 +61,39 @@ export function useTextScroll(elementRef: Ref<HTMLElement | null>) {
|
||||
* 指令:自动检测溢出并添加滚动效果
|
||||
* 使用方式:v-text-scroll
|
||||
*/
|
||||
// 扩展类型以存储原始内容和检查函数
|
||||
interface TextScrollElementExtended extends HTMLElement {
|
||||
_textScrollObserver?: ResizeObserver
|
||||
_textScrollContent?: string
|
||||
_checkOverflow?: () => void
|
||||
}
|
||||
|
||||
export const vTextScroll = {
|
||||
mounted(el: HTMLElement) {
|
||||
const extEl = el as TextScrollElementExtended
|
||||
|
||||
// 添加滚动容器类
|
||||
el.classList.add('text-scroll')
|
||||
|
||||
// 包装内容
|
||||
const content = el.innerHTML
|
||||
// 保存并包装内容
|
||||
const content = el.textContent || ''
|
||||
extEl._textScrollContent = content
|
||||
el.innerHTML = `<span class="text-scroll-inner">${content}</span>`
|
||||
|
||||
// 检查溢出
|
||||
const checkOverflow = () => {
|
||||
const inner = el.querySelector('.text-scroll-inner') as HTMLElement
|
||||
if (!inner) {return}
|
||||
if (!inner) { return }
|
||||
|
||||
const isOver = inner.scrollWidth > el.clientWidth
|
||||
if (isOver) {
|
||||
el.classList.add('is-overflowing')
|
||||
// 复制内容用于无缝滚动
|
||||
if (!el.querySelector('.text-scroll-clone')) {
|
||||
const clone = inner.cloneNode(true) as HTMLElement
|
||||
clone.classList.add('text-scroll-clone')
|
||||
if (!inner.querySelector('.text-scroll-clone')) {
|
||||
const clone = document.createElement('span')
|
||||
clone.className = 'text-scroll-clone'
|
||||
clone.textContent = extEl._textScrollContent || ''
|
||||
clone.style.paddingLeft = '2rem'
|
||||
inner.appendChild(clone)
|
||||
}
|
||||
// 计算滚动时长
|
||||
@@ -94,11 +102,13 @@ export const vTextScroll = {
|
||||
} else {
|
||||
el.classList.remove('is-overflowing')
|
||||
// 移除克隆
|
||||
const clone = el.querySelector('.text-scroll-clone')
|
||||
if (clone) {clone.remove()}
|
||||
const clone = inner.querySelector('.text-scroll-clone')
|
||||
if (clone) { clone.remove() }
|
||||
}
|
||||
}
|
||||
|
||||
extEl._checkOverflow = checkOverflow
|
||||
|
||||
// 初始检查
|
||||
requestAnimationFrame(checkOverflow)
|
||||
|
||||
@@ -106,29 +116,50 @@ export const vTextScroll = {
|
||||
if ('ResizeObserver' in window) {
|
||||
const observer = new ResizeObserver(checkOverflow)
|
||||
observer.observe(el)
|
||||
;(el as TextScrollElement)._textScrollObserver = observer
|
||||
extEl._textScrollObserver = observer
|
||||
}
|
||||
},
|
||||
|
||||
updated(el: HTMLElement) {
|
||||
// 内容更新时重新检查
|
||||
const extEl = el as TextScrollElementExtended
|
||||
|
||||
// 检查内容是否变化
|
||||
requestAnimationFrame(() => {
|
||||
// 获取当前实际文本内容(排除克隆的内容)
|
||||
const inner = el.querySelector('.text-scroll-inner') as HTMLElement
|
||||
if (!inner) {return}
|
||||
let currentContent = ''
|
||||
|
||||
const isOver = inner.scrollWidth > el.clientWidth
|
||||
if (isOver) {
|
||||
el.classList.add('is-overflowing')
|
||||
if (inner) {
|
||||
// 获取不包含克隆的文本
|
||||
const clone = inner.querySelector('.text-scroll-clone')
|
||||
if (clone) {
|
||||
currentContent = inner.textContent?.replace(clone.textContent || '', '') || ''
|
||||
} else {
|
||||
currentContent = inner.textContent || ''
|
||||
}
|
||||
} else {
|
||||
el.classList.remove('is-overflowing')
|
||||
// 内容被 Vue 重新渲染,需要重新包装
|
||||
currentContent = el.textContent || ''
|
||||
}
|
||||
|
||||
// 如果内容变化或结构被破坏,重新初始化
|
||||
if (!inner || currentContent !== extEl._textScrollContent) {
|
||||
const newContent = el.textContent || ''
|
||||
extEl._textScrollContent = newContent
|
||||
el.innerHTML = `<span class="text-scroll-inner">${newContent}</span>`
|
||||
}
|
||||
|
||||
// 重新检查溢出
|
||||
if (extEl._checkOverflow) {
|
||||
extEl._checkOverflow()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
unmounted(el: HTMLElement) {
|
||||
const observer = (el as TextScrollElement)._textScrollObserver
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
const extEl = el as TextScrollElementExtended
|
||||
if (extEl._textScrollObserver) {
|
||||
extEl._textScrollObserver.disconnect()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -314,5 +314,67 @@
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
骨架屏加载动画
|
||||
============================================ */
|
||||
|
||||
.skeleton {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(200, 200, 200, 0.1) 0%,
|
||||
rgba(200, 200, 200, 0.3) 50%,
|
||||
rgba(200, 200, 200, 0.1) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.skeleton::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.4) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.dark .skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(100, 100, 100, 0.1) 0%,
|
||||
rgba(100, 100, 100, 0.3) 50%,
|
||||
rgba(100, 100, 100, 0.1) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.dark .skeleton::after {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 减少动画偏好时禁用骨架屏动画 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user