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:
AdingApkgg
2025-12-26 15:24:03 +08:00
parent d88f715df9
commit 2a030ea0f7
4 changed files with 160 additions and 45 deletions

View File

@@ -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>

View File

@@ -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

View File

@@ -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()
}
},
}

View File

@@ -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;
}
}
}