Merge pull request #39 from Moe-Sakura/dev

Refactor VndbPanel and related components to enhance functionality an…
This commit is contained in:
Asuna
2025-12-26 13:40:07 +08:00
committed by GitHub
10 changed files with 1500 additions and 221 deletions

View File

@@ -26,7 +26,6 @@
"vue-tsc": "^3.2.1"
},
"dependencies": {
"@fancyapps/ui": "^6.1.7",
"@fontsource/noto-sans-sc": "^5.2.8",
"animejs": "^4.2.2",
"artalk": "^2.9.1",

8
pnpm-lock.yaml generated
View File

@@ -8,9 +8,6 @@ importers:
.:
dependencies:
'@fancyapps/ui':
specifier: ^6.1.7
version: 6.1.7
'@fontsource/noto-sans-sc':
specifier: ^5.2.8
version: 5.2.8
@@ -292,9 +289,6 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@fancyapps/ui@6.1.7':
resolution: {integrity: sha512-KHOvuy90JBFDgbNa2V1N9Jg7PE/lSQMXN9VbhR+WQSIxIEi4PV7kndeao7ezir5WeJ8OZRyDelNKJVLicXfBIg==}
'@fontsource/noto-sans-sc@5.2.8':
resolution: {integrity: sha512-8T8HxIS3uAMCfaQawKRH/6yYZ1oRnJZB/CrGwfxGgJa+zAOBgx2lqZMiTY/WbQpLGlPRqX4zHXJYI09CI2q6tA==}
@@ -1464,8 +1458,6 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
'@fancyapps/ui@6.1.7': {}
'@fontsource/noto-sans-sc@5.2.8': {}
'@humanfs/core@0.19.1': {}

View File

@@ -57,6 +57,9 @@
:is-visible="uiStore.showUpdateToast"
:on-update="handleSwUpdate"
/>
<!-- 图片预览器 -->
<ImageViewer />
</main>
</div>
</template>
@@ -89,6 +92,7 @@ const SettingsModal = defineAsyncComponent(() => import('@/components/SettingsMo
const SearchHistoryModal = defineAsyncComponent(() => import('@/components/SearchHistoryModal.vue'))
const KeyboardHelpPanel = defineAsyncComponent(() => import('@/components/KeyboardHelpPanel.vue'))
const UpdateToast = defineAsyncComponent(() => import('@/components/UpdateToast.vue'))
const ImageViewer = defineAsyncComponent(() => import('@/components/ImageViewer.vue'))
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import { useClickEffect } from '@/composables/useClickEffect'

View File

@@ -26,13 +26,18 @@ export interface PlatformResult {
export interface VndbVoiceActor {
id: string
name: string
character?: string
character: {
id: string
name: string
}
}
export interface VndbTag {
id: string
name: string
rating?: number
rating: number
spoiler: number
category: string
}
export interface VndbTitleEntry {
@@ -48,7 +53,23 @@ export interface VndbScreenshot {
}
export interface VndbDeveloper {
id: string
name: string
original?: string
}
export interface VndbRelation {
id: string
title: string
relation: string
relation_official: boolean
}
export interface VndbExtLink {
url: string
label: string
name: string
id?: string
}
export interface VndbApiItem {
@@ -67,17 +88,23 @@ export interface VndbInfo {
description: string | null
translatedDescription: string | null
va: VndbVoiceActor[]
vntags: VndbTag[]
tags: VndbTag[]
relations: VndbRelation[]
extlinks: VndbExtLink[]
play_hours: number
length_minute: number
length_votes: number
length_color: string
book_length: string
rating?: number
average?: number
votecount?: number
released?: string
developers?: string[]
developers?: VndbDeveloper[]
platforms?: string[]
languages?: string[]
olang?: string
devstatus?: number
}
/**
@@ -264,7 +291,7 @@ export async function fetchVndbData(gameName: string): Promise<VndbInfo | null>
body: JSON.stringify({
filters: ['search', '=', gameName],
fields:
'id, title, titles.lang, titles.title, description, image.url, image.sexual, image.violence, screenshots.url, screenshots.sexual, screenshots.violence, screenshots.votecount, length_minutes, length_votes, rating, votecount, released, developers.name, platforms',
'id, title, titles.lang, titles.title, description, image.url, image.sexual, image.violence, screenshots.url, screenshots.sexual, screenshots.violence, screenshots.votecount, length_minutes, length_votes, rating, average, votecount, released, developers.id, developers.name, developers.original, platforms, languages, olang, devstatus, tags.id, tags.name, tags.rating, tags.spoiler, tags.category, relations.id, relations.title, relations.relation, relations.relation_official, extlinks.url, extlinks.label, extlinks.name',
results: 1,
}),
})
@@ -357,8 +384,49 @@ export async function fetchVndbData(gameName: string): Promise<VndbInfo | null>
}
// 提取开发商信息
const developers = result.developers
? result.developers.map((dev: VndbDeveloper) => dev.name).filter(Boolean)
const developers: VndbDeveloper[] = result.developers
? result.developers.map((dev: { id: string; name: string; original?: string }) => ({
id: dev.id,
name: dev.name,
original: dev.original,
}))
: []
// 提取标签信息 - 按评分排序,过滤掉剧透标签
const tags: VndbTag[] = result.tags
? result.tags
.filter((tag: { spoiler: number }) => tag.spoiler === 0)
.sort((a: { rating: number }, b: { rating: number }) => b.rating - a.rating)
.slice(0, 20)
.map((tag: { id: string; name: string; rating: number; spoiler: number; category: string }) => ({
id: tag.id,
name: tag.name,
rating: tag.rating,
spoiler: tag.spoiler,
category: tag.category,
}))
: []
// 声优信息暂时不从 API 获取(需要单独查询 POST /character
const va: VndbVoiceActor[] = []
// 提取相关作品
const relations: VndbRelation[] = result.relations
? result.relations.map((r: { id: string; title: string; relation: string; relation_official: boolean }) => ({
id: r.id,
title: r.title,
relation: r.relation,
relation_official: r.relation_official,
}))
: []
// 提取外部链接
const extlinks: VndbExtLink[] = result.extlinks
? result.extlinks.map((link: { url: string; label: string; name: string }) => ({
url: link.url,
label: link.label,
name: link.name,
}))
: []
const finalResult: VndbInfo = {
@@ -371,18 +439,24 @@ export async function fetchVndbData(gameName: string): Promise<VndbInfo | null>
screenshots,
description: result.description || null,
translatedDescription: null,
va: result.va || [],
vntags: [],
va,
tags,
relations,
extlinks,
play_hours,
length_minute,
length_votes,
length_color,
book_length,
rating: result.rating || undefined,
average: result.average || undefined,
votecount: result.votecount || undefined,
released: result.released || undefined,
developers: developers.length > 0 ? developers : undefined,
platforms: result.platforms || undefined,
languages: result.languages || undefined,
olang: result.olang || undefined,
devstatus: result.devstatus,
}
// 检查代理并替换 URL

View File

@@ -0,0 +1,817 @@
<script setup lang="ts">
/**
* 图片预览组件
* 替代 @fancyapps/ui使用 Vue 原生实现
*/
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { animate } from '@/composables/useAnime'
import {
X,
ChevronLeft,
ChevronRight,
ZoomIn,
ZoomOut,
RotateCw,
RotateCcw,
FlipHorizontal,
FlipVertical,
Maximize2,
Download,
} from 'lucide-vue-next'
import { useImageViewer } from '@/composables/useImageViewer'
import { playButton, playSwipe, playTransitionUp, playTransitionDown } from '@/composables/useSound'
const {
isOpen,
images,
currentIndex,
currentImage,
hasMultiple,
transform,
close,
prev,
next,
goTo,
zoomIn,
zoomOut,
toggleZoom,
rotateCW,
rotateCCW,
flipHorizontal,
flipVertical,
resetTransform,
} = useImageViewer()
// 图片加载状态
const isLoading = ref(true)
const hasError = ref(false)
// 拖拽状态
const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 })
const lastTranslate = ref({ x: 0, y: 0 })
// 双指缩放状态
const initialPinchDistance = ref(0)
const initialScale = ref(1)
// 计算变换样式
const imageStyle = computed(() => {
const scaleX = transform.flipX ? -transform.scale : transform.scale
const scaleY = transform.flipY ? -transform.scale : transform.scale
return {
transform: `translate(${transform.translateX}px, ${transform.translateY}px) scale(${scaleX}, ${scaleY}) rotate(${transform.rotate}deg)`,
transition: isDragging.value ? 'none' : 'transform 0.3s ease-out',
cursor: transform.scale > 1 ? (isDragging.value ? 'grabbing' : 'grab') : 'default',
}
})
// 动画函数
function onEnter(el: Element, done: () => void) {
playTransitionUp()
animate(el as HTMLElement, {
opacity: [0, 1],
scale: [0.95, 1],
duration: 250,
ease: 'outCubic',
complete: done,
})
}
function onLeave(el: Element, done: () => void) {
animate(el as HTMLElement, {
opacity: [1, 0],
scale: [1, 0.95],
duration: 200,
ease: 'inCubic',
complete: done,
})
}
// 监听图片切换
watch(currentIndex, () => {
isLoading.value = true
hasError.value = false
})
// 图片加载完成
function handleImageLoad() {
isLoading.value = false
}
// 图片加载失败
function handleImageError() {
isLoading.value = false
hasError.value = true
}
// 关闭预览
function handleClose() {
playTransitionDown()
close()
}
// 上一张
function handlePrev() {
playSwipe()
prev()
}
// 下一张
function handleNext() {
playSwipe()
next()
}
// 工具栏操作
function handleZoomIn() {
playButton()
zoomIn()
}
function handleZoomOut() {
playButton()
zoomOut()
}
function handleToggleZoom() {
playButton()
toggleZoom()
}
function handleRotateCW() {
playButton()
rotateCW()
}
function handleRotateCCW() {
playButton()
rotateCCW()
}
function handleFlipH() {
playButton()
flipHorizontal()
}
function handleFlipV() {
playButton()
flipVertical()
}
// 下载图片
function handleDownload() {
if (!currentImage.value) {
return
}
playButton()
const link = document.createElement('a')
link.href = currentImage.value.src
link.download = currentImage.value.caption || 'image'
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
// 鼠标拖拽
function handleMouseDown(e: MouseEvent) {
if (transform.scale <= 1) {
return
}
isDragging.value = true
dragStart.value = { x: e.clientX, y: e.clientY }
lastTranslate.value = { x: transform.translateX, y: transform.translateY }
e.preventDefault()
}
function handleMouseMove(e: MouseEvent) {
if (!isDragging.value) {
return
}
const deltaX = e.clientX - dragStart.value.x
const deltaY = e.clientY - dragStart.value.y
transform.translateX = lastTranslate.value.x + deltaX
transform.translateY = lastTranslate.value.y + deltaY
}
function handleMouseUp() {
isDragging.value = false
}
// 双击放大/还原
function handleDoubleClick(e: MouseEvent) {
e.preventDefault()
handleToggleZoom()
}
// 滚轮缩放
function handleWheel(e: globalThis.WheelEvent) {
e.preventDefault()
if (e.deltaY < 0) {
transform.scale = Math.min(transform.scale * 1.1, 10)
} else {
transform.scale = Math.max(transform.scale / 1.1, 0.5)
}
}
// 触摸事件
function handleTouchStart(e: TouchEvent) {
if (e.touches.length === 2) {
// 双指缩放开始
initialPinchDistance.value = getDistance(e.touches[0], e.touches[1])
initialScale.value = transform.scale
} else if (e.touches.length === 1 && transform.scale > 1) {
// 单指拖拽开始
isDragging.value = true
dragStart.value = { x: e.touches[0].clientX, y: e.touches[0].clientY }
lastTranslate.value = { x: transform.translateX, y: transform.translateY }
}
}
function handleTouchMove(e: TouchEvent) {
if (e.touches.length === 2 && initialPinchDistance.value > 0) {
// 双指缩放
const distance = getDistance(e.touches[0], e.touches[1])
const scale = (distance / initialPinchDistance.value) * initialScale.value
transform.scale = Math.min(Math.max(scale, 0.5), 10)
e.preventDefault()
} else if (e.touches.length === 1 && isDragging.value) {
// 单指拖拽
const deltaX = e.touches[0].clientX - dragStart.value.x
const deltaY = e.touches[0].clientY - dragStart.value.y
transform.translateX = lastTranslate.value.x + deltaX
transform.translateY = lastTranslate.value.y + deltaY
e.preventDefault()
}
}
function handleTouchEnd() {
isDragging.value = false
initialPinchDistance.value = 0
}
// 计算两点距离
function getDistance(touch1: globalThis.Touch, touch2: globalThis.Touch): number {
const dx = touch1.clientX - touch2.clientX
const dy = touch1.clientY - touch2.clientY
return Math.sqrt(dx * dx + dy * dy)
}
// 滑动切换
let touchStartX = 0
let touchStartY = 0
function handleSwipeStart(e: TouchEvent) {
if (transform.scale > 1) {
return
}
touchStartX = e.touches[0].clientX
touchStartY = e.touches[0].clientY
}
function handleSwipeEnd(e: TouchEvent) {
if (transform.scale > 1) {
return
}
const deltaX = e.changedTouches[0].clientX - touchStartX
const deltaY = e.changedTouches[0].clientY - touchStartY
// 水平滑动幅度大于垂直,且超过阈值
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 50) {
if (deltaX > 0) {
handlePrev()
} else {
handleNext()
}
}
}
// 键盘事件
function handleKeydown(e: KeyboardEvent) {
if (!isOpen.value) {
return
}
switch (e.key) {
case 'Escape':
handleClose()
break
case 'ArrowLeft':
handlePrev()
break
case 'ArrowRight':
handleNext()
break
case '+':
case '=':
handleZoomIn()
break
case '-':
handleZoomOut()
break
case '0':
resetTransform()
break
case 'r':
handleRotateCW()
break
case 'R':
handleRotateCCW()
break
}
}
// 点击背景关闭
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
handleClose()
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
window.addEventListener('mouseup', handleMouseUp)
window.addEventListener('mousemove', handleMouseMove)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
window.removeEventListener('mouseup', handleMouseUp)
window.removeEventListener('mousemove', handleMouseMove)
})
</script>
<template>
<Teleport to="body">
<Transition
:css="false"
@enter="onEnter"
@leave="onLeave"
>
<div
v-if="isOpen"
class="image-viewer-overlay"
@click="handleBackdropClick"
>
<!-- 背景 -->
<div class="image-viewer-backdrop" />
<!-- 主容器 -->
<div class="image-viewer-container">
<!-- 顶部工具栏 -->
<div class="image-viewer-toolbar top">
<!-- 左侧图片信息 -->
<div class="toolbar-left">
<span v-if="hasMultiple" class="image-counter">
{{ currentIndex + 1 }} / {{ images.length }}
</span>
</div>
<!-- 右侧操作按钮 -->
<div class="toolbar-right">
<button
class="toolbar-btn"
title="放大 (+)"
@click="handleZoomIn"
>
<ZoomIn :size="20" />
</button>
<button
class="toolbar-btn"
title="缩小 (-)"
@click="handleZoomOut"
>
<ZoomOut :size="20" />
</button>
<button
class="toolbar-btn"
title="1:1"
@click="handleToggleZoom"
>
<Maximize2 :size="20" />
</button>
<div class="toolbar-divider" />
<button
class="toolbar-btn"
title="逆时针旋转 (Shift+R)"
@click="handleRotateCCW"
>
<RotateCcw :size="20" />
</button>
<button
class="toolbar-btn"
title="顺时针旋转 (R)"
@click="handleRotateCW"
>
<RotateCw :size="20" />
</button>
<button
class="toolbar-btn"
title="水平翻转"
@click="handleFlipH"
>
<FlipHorizontal :size="20" />
</button>
<button
class="toolbar-btn"
title="垂直翻转"
@click="handleFlipV"
>
<FlipVertical :size="20" />
</button>
<div class="toolbar-divider" />
<button
class="toolbar-btn"
title="下载"
@click="handleDownload"
>
<Download :size="20" />
</button>
<button
class="toolbar-btn close-btn"
title="关闭 (Esc)"
@click="handleClose"
>
<X :size="22" />
</button>
</div>
</div>
<!-- 图片容器 -->
<div
class="image-viewer-content"
@mousedown="handleMouseDown"
@wheel="handleWheel"
@dblclick="handleDoubleClick"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<!-- 加载指示器 -->
<div v-if="isLoading" class="image-loading">
<div class="loading-spinner" />
</div>
<!-- 错误提示 -->
<div v-else-if="hasError" class="image-error">
<span>图片加载失败</span>
</div>
<!-- 图片 -->
<img
v-show="!isLoading && !hasError && currentImage"
:src="currentImage?.src"
:alt="currentImage?.caption || ''"
:style="imageStyle"
class="viewer-image"
draggable="false"
@load="handleImageLoad"
@error="handleImageError"
@touchstart="handleSwipeStart"
@touchend="handleSwipeEnd"
/>
</div>
<!-- 左右切换按钮 -->
<template v-if="hasMultiple">
<button
class="nav-btn nav-prev"
title="上一张 (←)"
@click.stop="handlePrev"
>
<ChevronLeft :size="32" />
</button>
<button
class="nav-btn nav-next"
title="下一张 (→)"
@click.stop="handleNext"
>
<ChevronRight :size="32" />
</button>
</template>
<!-- 底部标题和缩略图 -->
<div class="image-viewer-toolbar bottom">
<!-- 标题 -->
<div v-if="currentImage?.caption" class="image-caption">
{{ currentImage.caption }}
</div>
<!-- 缩略图 -->
<div v-if="hasMultiple && images.length <= 20" class="thumbnails">
<button
v-for="(img, index) in images"
:key="index"
:class="['thumbnail', { active: index === currentIndex }]"
@click="goTo(index)"
>
<img :src="img.thumbnail || img.src" :alt="`缩略图 ${index + 1}`" />
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.image-viewer-overlay {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
}
.image-viewer-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.92);
backdrop-filter: blur(8px);
}
.image-viewer-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
/* 工具栏 */
.image-viewer-toolbar {
position: absolute;
left: 0;
right: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.5), transparent);
}
.image-viewer-toolbar.top {
top: 0;
}
.image-viewer-toolbar.bottom {
bottom: 0;
flex-direction: column;
gap: 12px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.5), transparent);
padding-bottom: max(16px, env(safe-area-inset-bottom));
}
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
gap: 4px;
}
.image-counter {
color: white;
font-size: 14px;
font-weight: 500;
padding: 4px 12px;
background: rgba(255, 255, 255, 0.15);
border-radius: 20px;
backdrop-filter: blur(4px);
}
.toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
background: rgba(255, 255, 255, 0.1);
color: white;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
backdrop-filter: blur(4px);
}
.toolbar-btn:hover {
background: rgba(255, 255, 255, 0.25);
transform: scale(1.05);
}
.toolbar-btn:active {
transform: scale(0.95);
}
.toolbar-btn.close-btn {
background: rgba(239, 68, 68, 0.3);
}
.toolbar-btn.close-btn:hover {
background: rgba(239, 68, 68, 0.5);
}
.toolbar-divider {
width: 1px;
height: 24px;
background: rgba(255, 255, 255, 0.2);
margin: 0 4px;
}
/* 图片容器 */
.image-viewer-content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 60px 0;
}
.viewer-image {
max-width: 90vw;
max-height: 85vh;
object-fit: contain;
user-select: none;
-webkit-user-drag: none;
border-radius: 4px;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.4);
}
/* 加载指示器 */
.image-loading {
display: flex;
align-items: center;
justify-content: center;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: #ff1493;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 错误提示 */
.image-error {
color: rgba(255, 255, 255, 0.6);
font-size: 16px;
}
/* 导航按钮 */
.nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 80px;
border: none;
background: rgba(255, 255, 255, 0.1);
color: white;
cursor: pointer;
transition: all 0.2s ease;
backdrop-filter: blur(4px);
}
.nav-prev {
left: 0;
border-radius: 0 12px 12px 0;
}
.nav-next {
right: 0;
border-radius: 12px 0 0 12px;
}
.nav-btn:hover {
background: rgba(255, 255, 255, 0.25);
width: 56px;
}
.nav-btn:active {
background: rgba(255, 255, 255, 0.35);
}
/* 标题 */
.image-caption {
color: white;
font-size: 15px;
text-align: center;
max-width: 80%;
padding: 6px 16px;
background: rgba(0, 0, 0, 0.4);
border-radius: 8px;
backdrop-filter: blur(4px);
}
/* 缩略图 */
.thumbnails {
display: flex;
gap: 8px;
padding: 8px;
max-width: 90vw;
overflow-x: auto;
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
backdrop-filter: blur(4px);
}
.thumbnail {
flex-shrink: 0;
width: 60px;
height: 60px;
padding: 0;
border: 2px solid transparent;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
background: rgba(0, 0, 0, 0.3);
}
.thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail:hover {
border-color: rgba(255, 255, 255, 0.5);
transform: scale(1.05);
}
.thumbnail.active {
border-color: #ff1493;
box-shadow: 0 0 12px rgba(255, 20, 147, 0.5);
}
/* 移动端适配 */
@media (max-width: 768px) {
.toolbar-btn {
width: 36px;
height: 36px;
}
.toolbar-divider {
display: none;
}
.nav-btn {
width: 40px;
height: 60px;
}
.thumbnail {
width: 48px;
height: 48px;
}
.image-caption {
font-size: 13px;
}
}
/* 隐藏滚动条但保持功能 */
.thumbnails::-webkit-scrollbar {
height: 4px;
}
.thumbnails::-webkit-scrollbar-track {
background: transparent;
}
.thumbnails::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
}
</style>

View File

@@ -13,7 +13,7 @@
'fixed z-50 flex flex-col vndb-page shadow-2xl shadow-black/20',
isFullscreen
? 'inset-0'
: 'inset-0 md:inset-6 md:m-auto md:w-[600px] md:min-w-[400px] md:max-w-[800px] md:h-[500px] md:max-h-[calc(100%-3rem)] md:rounded-3xl'
: 'inset-0 md:inset-6 md:m-auto md:w-[800px] md:min-w-[700px] md:max-w-[1000px] md:h-[700px] md:max-h-[calc(100%-3rem)] md:rounded-3xl'
]"
:style="windowStyle"
>
@@ -88,19 +88,18 @@
<div class="vndb-card">
<!-- 封面图 -->
<div v-if="searchStore.vndbInfo.mainImageUrl" class="mb-4">
<a
:href="searchStore.vndbInfo.mainImageUrl"
data-fancybox="vndb-gallery"
:data-caption="searchStore.vndbInfo.mainName + ' - 游戏封面'"
<button
class="block w-full max-w-sm mx-auto"
@click="openGallery(0)"
>
<img
:src="searchStore.vndbInfo.mainImageUrl"
:alt="searchStore.vndbInfo.mainName"
class="w-full max-w-sm mx-auto h-auto rounded-2xl shadow-lg cursor-pointer hover:opacity-90 transition-opacity"
class="w-full h-auto rounded-2xl shadow-lg cursor-pointer hover:opacity-90 hover:scale-[1.02] transition-all"
loading="lazy"
@error="handleImageError"
/>
</a>
</button>
</div>
<!-- 标题 -->
@@ -181,10 +180,53 @@
v-text-scroll
class="text-base font-bold text-gray-800 dark:text-white"
>
{{ searchStore.vndbInfo.developers.join(', ') }}
{{ searchStore.vndbInfo.developers.map(d => d.name).join(', ') }}
</p>
</div>
</div>
<!-- 开发状态 -->
<div v-if="searchStore.vndbInfo.devstatus !== undefined" class="vndb-card flex items-center gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-500 to-teal-500 flex items-center justify-center shadow-lg shadow-emerald-500/30">
<Gamepad2 :size="24" class="text-white" />
</div>
<div>
<p class="text-xs text-gray-500 dark:text-slate-400">开发状态</p>
<p class="text-base font-bold" :class="getDevStatusColor(searchStore.vndbInfo.devstatus)">
{{ formatDevStatus(searchStore.vndbInfo.devstatus) }}
</p>
</div>
</div>
<!-- 原始语言 -->
<div v-if="searchStore.vndbInfo.olang" class="vndb-card flex items-center gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-rose-500 to-pink-500 flex items-center justify-center shadow-lg shadow-rose-500/30">
<Globe :size="24" class="text-white" />
</div>
<div>
<p class="text-xs text-gray-500 dark:text-slate-400">原始语言</p>
<p class="text-base font-bold text-gray-800 dark:text-white">
{{ formatLanguage(searchStore.vndbInfo.olang) }}
</p>
</div>
</div>
</div>
<!-- 支持语言 -->
<div v-if="searchStore.vndbInfo.languages && searchStore.vndbInfo.languages.length > 0" class="vndb-card">
<div class="flex items-center gap-2 mb-3">
<Languages :size="18" class="text-rose-500" />
<h3 class="font-bold text-gray-800 dark:text-white">支持语言</h3>
</div>
<div class="flex flex-wrap gap-2">
<span
v-for="(lang, index) in searchStore.vndbInfo.languages"
:key="index"
class="px-3 py-1.5 bg-rose-100 dark:bg-rose-900/30 text-rose-700 dark:text-rose-400 text-sm font-medium rounded-xl"
>
{{ formatLanguage(lang) }}
</span>
</div>
</div>
<!-- 平台 -->
@@ -204,6 +246,154 @@
</div>
</div>
<!-- 标签 -->
<div v-if="searchStore.vndbInfo.tags && searchStore.vndbInfo.tags.length > 0" class="vndb-card">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<Tag :size="18" class="text-violet-500" />
<h3 class="font-bold text-gray-800 dark:text-white">标签</h3>
<span class="text-xs text-gray-400 dark:text-slate-500">(按相关性排序)</span>
</div>
<!-- 翻译按钮 -->
<div class="flex items-center gap-2">
<!-- 切换原文/翻译 -->
<button
v-if="translatedTags.size > 0"
class="flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-lg transition-colors"
:class="showOriginalTags
? 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300'
: 'bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400'"
@click="toggleTagsLanguage"
>
<Languages :size="14" />
{{ showOriginalTags ? '原文' : '中文' }}
</button>
<!-- 翻译按钮 -->
<button
v-if="translatedTags.size === 0"
class="flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-lg transition-colors"
:class="isTranslatingTags
? 'bg-gray-100 dark:bg-slate-700 text-gray-400 dark:text-slate-500 cursor-wait'
: translateTagsError
? 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400'
: 'bg-gradient-to-r from-violet-500 to-purple-500 text-white shadow-sm hover:shadow-md'"
:disabled="isTranslatingTags"
@click="handleTranslateTags"
>
<Loader v-if="isTranslatingTags" :size="14" class="animate-spin" />
<AlertTriangle v-else-if="translateTagsError" :size="14" />
<Bot v-else :size="14" />
{{ isTranslatingTags ? '翻译中...' : translateTagsError ? '重试' : 'AI 翻译' }}
</button>
</div>
</div>
<div class="flex flex-wrap gap-2">
<span
v-for="(tag, index) in searchStore.vndbInfo.tags"
:key="index"
class="px-2.5 py-1 text-xs font-medium rounded-lg transition-colors cursor-default"
:class="getTagCategoryClass(tag.category)"
:title="`${tag.name}${translatedTags.get(tag.name) ? ' → ' + translatedTags.get(tag.name) : ''} | 相关性: ${Math.round(tag.rating * 10) / 10} | 分类: ${formatTagCategory(tag.category)}`"
>
{{ getTagDisplayName(tag) }}
</span>
</div>
<div class="flex flex-wrap items-center gap-3 mt-3 pt-3 border-t border-gray-200/50 dark:border-slate-700/50">
<span class="text-xs text-gray-400 dark:text-slate-500">分类:</span>
<span class="flex items-center gap-1 text-xs text-gray-500 dark:text-slate-400">
<span class="w-2 h-2 rounded-full bg-violet-500" />内容
</span>
<span class="flex items-center gap-1 text-xs text-gray-500 dark:text-slate-400">
<span class="w-2 h-2 rounded-full bg-blue-500" />技术
</span>
<span class="flex items-center gap-1 text-xs text-gray-500 dark:text-slate-400">
<span class="w-2 h-2 rounded-full bg-amber-500" />色情
</span>
</div>
</div>
<!-- 声优 -->
<div v-if="searchStore.vndbInfo.va && searchStore.vndbInfo.va.length > 0" class="vndb-card">
<div class="flex items-center gap-2 mb-3">
<Mic :size="18" class="text-cyan-500" />
<h3 class="font-bold text-gray-800 dark:text-white">声优</h3>
<span class="text-xs text-gray-400 dark:text-slate-500">({{ searchStore.vndbInfo.va.length }})</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<a
v-for="(voiceActor, index) in searchStore.vndbInfo.va.slice(0, 10)"
:key="index"
:href="`https://vndb.org/${voiceActor.id}`"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 p-2 rounded-xl bg-cyan-50 dark:bg-cyan-900/20 hover:bg-cyan-100 dark:hover:bg-cyan-900/30 transition-colors group"
>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-cyan-700 dark:text-cyan-400 truncate group-hover:underline">
{{ voiceActor.name }}
</p>
<p v-if="voiceActor.character?.name" class="text-xs text-gray-500 dark:text-slate-400 truncate">
{{ voiceActor.character.name }}
</p>
</div>
<ExternalLink :size="12" class="text-cyan-400 dark:text-cyan-600 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
</a>
</div>
<p v-if="searchStore.vndbInfo.va.length > 10" class="text-xs text-gray-400 dark:text-slate-500 mt-2 text-center">
还有 {{ searchStore.vndbInfo.va.length - 10 }} 位声优...
</p>
</div>
<!-- 相关作品 -->
<div v-if="searchStore.vndbInfo.relations && searchStore.vndbInfo.relations.length > 0" class="vndb-card">
<div class="flex items-center gap-2 mb-3">
<GitBranch :size="18" class="text-amber-500" />
<h3 class="font-bold text-gray-800 dark:text-white">相关作品</h3>
</div>
<div class="space-y-2">
<a
v-for="(relation, index) in searchStore.vndbInfo.relations.slice(0, 8)"
:key="index"
:href="`https://vndb.org/${relation.id}`"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 p-2 rounded-xl bg-amber-50 dark:bg-amber-900/20 hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors group"
>
<span class="px-2 py-0.5 text-xs font-medium rounded-md bg-amber-200 dark:bg-amber-800 text-amber-800 dark:text-amber-200 flex-shrink-0">
{{ formatRelation(relation.relation) }}
</span>
<span class="text-sm text-gray-700 dark:text-slate-300 truncate group-hover:underline flex-1">
{{ relation.title }}
</span>
<ExternalLink :size="12" class="text-amber-400 dark:text-amber-600 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
</a>
</div>
<p v-if="searchStore.vndbInfo.relations.length > 8" class="text-xs text-gray-400 dark:text-slate-500 mt-2 text-center">
还有 {{ searchStore.vndbInfo.relations.length - 8 }} 个相关作品...
</p>
</div>
<!-- 外部链接 -->
<div v-if="searchStore.vndbInfo.extlinks && searchStore.vndbInfo.extlinks.length > 0" class="vndb-card">
<div class="flex items-center gap-2 mb-3">
<Link2 :size="18" class="text-sky-500" />
<h3 class="font-bold text-gray-800 dark:text-white">外部链接</h3>
</div>
<div class="flex flex-wrap gap-2">
<a
v-for="(link, index) in searchStore.vndbInfo.extlinks"
:key="index"
:href="link.url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xl bg-sky-100 dark:bg-sky-900/30 text-sky-700 dark:text-sky-400 hover:bg-sky-200 dark:hover:bg-sky-900/50 transition-colors"
>
<span>{{ link.label || link.name }}</span>
<ExternalLink :size="12" />
</a>
</div>
</div>
<!-- 简介 -->
<div v-if="searchStore.vndbInfo.description" class="vndb-card">
<div class="flex items-center justify-between mb-3">
@@ -269,13 +459,11 @@
<h3 class="font-bold text-gray-800 dark:text-white">游戏截图</h3>
</div>
<div class="grid grid-cols-2 gap-3">
<a
<button
v-for="(screenshot, index) in searchStore.vndbInfo.screenshots"
:key="index"
:href="screenshot"
data-fancybox="vndb-gallery"
:data-caption="`${searchStore.vndbInfo.mainName} - 截图 ${index + 1}`"
class="group relative block overflow-hidden rounded-xl shadow-md hover:shadow-xl transition-all bg-gray-100 dark:bg-slate-700"
@click="openGallery(index + 1)"
>
<img
:src="screenshot"
@@ -284,7 +472,7 @@
loading="lazy"
@error="handleImageError"
/>
</a>
</button>
</div>
</div>
</div>
@@ -301,6 +489,7 @@ import { useUIStore } from '@/stores/ui'
import { translateText } from '@/api/search'
import { playClick, playSuccess, playError, playToggle, playTransitionUp, playTransitionDown, playSwipe } from '@/composables/useSound'
import { animate } from '@/composables/useAnime'
import { useImageViewer } from '@/composables/useImageViewer'
import {
BookOpen,
ChevronLeft,
@@ -321,10 +510,19 @@ import {
Maximize2,
Minimize2,
X,
Tag,
Mic,
Link2,
GitBranch,
Globe,
Gamepad2,
} from 'lucide-vue-next'
import { useWindowManager, type ResizeDirection } from '@/composables/useWindowManager'
import WindowResizeHandles from '@/components/WindowResizeHandles.vue'
// 图片预览
const imageViewer = useImageViewer()
// 进入/离开动画
function onEnter(el: Element, done: () => void) {
animate(el as HTMLElement, {
@@ -355,6 +553,12 @@ const translatedDescription = ref<string | null>(null)
const showOriginal = ref(false)
const translateError = ref(false)
// 标签翻译状态
const isTranslatingTags = ref(false)
const translatedTags = ref<Map<string, string>>(new Map())
const showOriginalTags = ref(false)
const translateTagsError = ref(false)
// 窗口管理
const modalRef = ref<HTMLElement | null>(null)
const { isFullscreen, windowStyle, startDrag, startResize, toggleFullscreen, reset } = useWindowManager({
@@ -395,6 +599,11 @@ watch(() => searchStore.vndbInfo, () => {
showOriginal.value = false
isTranslating.value = false
translateError.value = false
// 重置标签翻译状态
translatedTags.value = new Map()
showOriginalTags.value = false
isTranslatingTags.value = false
translateTagsError.value = false
})
// 监听打开状态
@@ -434,6 +643,64 @@ async function handleTranslate() {
}
}
// 翻译标签
async function handleTranslateTags() {
if (!searchStore.vndbInfo?.tags || searchStore.vndbInfo.tags.length === 0 || isTranslatingTags.value) {
return
}
playClick()
isTranslatingTags.value = true
translateTagsError.value = false
try {
// 收集所有标签名称,用换行符分隔
const tagNames = searchStore.vndbInfo.tags.map(tag => tag.name)
const textToTranslate = tagNames.join('\n')
const translated = await translateText(textToTranslate)
if (translated) {
// 解析翻译结果,按换行符分割
const translatedNames = translated.split('\n').map(s => s.trim()).filter(s => s)
// 创建映射
const newMap = new Map<string, string>()
tagNames.forEach((original, index) => {
if (translatedNames[index]) {
newMap.set(original, translatedNames[index])
}
})
translatedTags.value = newMap
showOriginalTags.value = false
translateTagsError.value = false
playSuccess()
} else {
translateTagsError.value = true
playError()
}
} catch {
translateTagsError.value = true
playError()
} finally {
isTranslatingTags.value = false
}
}
// 切换显示原始/翻译标签
function toggleTagsLanguage() {
playToggle()
showOriginalTags.value = !showOriginalTags.value
}
// 获取标签显示名称
function getTagDisplayName(tag: { name: string }): string {
if (showOriginalTags.value || translatedTags.value.size === 0) {
return tag.name
}
return translatedTags.value.get(tag.name) || tag.name
}
function closePanel() {
playTransitionDown()
// 关闭面板
@@ -446,6 +713,37 @@ function handleImageError(event: Event) {
img.style.display = 'none'
}
// 打开图片画廊
function openGallery(startIndex: number) {
if (!searchStore.vndbInfo) {
return
}
const images = []
// 添加封面
if (searchStore.vndbInfo.mainImageUrl) {
images.push({
src: searchStore.vndbInfo.mainImageUrl,
caption: `${searchStore.vndbInfo.mainName} - 游戏封面`,
})
}
// 添加截图
if (searchStore.vndbInfo.screenshots) {
searchStore.vndbInfo.screenshots.forEach((screenshot, index) => {
images.push({
src: screenshot,
caption: `${searchStore.vndbInfo!.mainName} - 截图 ${index + 1}`,
})
})
}
if (images.length > 0) {
imageViewer.open(images, startIndex)
}
}
// 格式化日期
function formatDate(dateString: string): string {
if (!dateString) {return '未知'}
@@ -497,6 +795,93 @@ function formatPlatform(platform: string): string {
return platformMap[platform] || platform.toUpperCase()
}
// 格式化语言名称
function formatLanguage(lang: string): string {
const langMap: Record<string, string> = {
'ja': '日语',
'en': '英语',
'zh-Hans': '简体中文',
'zh-Hant': '繁体中文',
'zh': '中文',
'ko': '韩语',
'ru': '俄语',
'de': '德语',
'fr': '法语',
'es': '西班牙语',
'it': '意大利语',
'pt-br': '葡萄牙语(巴西)',
'pt-pt': '葡萄牙语',
'vi': '越南语',
'th': '泰语',
'id': '印尼语',
'pl': '波兰语',
'tr': '土耳其语',
'uk': '乌克兰语',
'cs': '捷克语',
'hu': '匈牙利语',
'ar': '阿拉伯语',
}
return langMap[lang] || lang.toUpperCase()
}
// 格式化开发状态
function formatDevStatus(status: number): string {
const statusMap: Record<number, string> = {
0: '已完成',
1: '开发中',
2: '已取消',
}
return statusMap[status] || '未知'
}
// 获取开发状态颜色
function getDevStatusColor(status: number): string {
const colorMap: Record<number, string> = {
0: 'text-emerald-600 dark:text-emerald-400',
1: 'text-amber-600 dark:text-amber-400',
2: 'text-red-600 dark:text-red-400',
}
return colorMap[status] || 'text-gray-600 dark:text-gray-400'
}
// 获取标签分类样式
function getTagCategoryClass(category: string): string {
const categoryMap: Record<string, string> = {
'cont': 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-400',
'tech': 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400',
'ero': 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400',
}
return categoryMap[category] || 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-400'
}
// 格式化标签分类名称
function formatTagCategory(category: string): string {
const categoryMap: Record<string, string> = {
'cont': '内容',
'tech': '技术',
'ero': '色情',
}
return categoryMap[category] || category
}
// 格式化关系类型
function formatRelation(relation: string): string {
const relationMap: Record<string, string> = {
'seq': '续作',
'preq': '前作',
'set': '同一设定',
'alt': '替代版本',
'char': '角色共享',
'side': '外传',
'par': '父作品',
'fan': '同人作品',
'orig': '原作',
'ser': '同系列',
}
return relationMap[relation] || relation
}
</script>
<style>

View File

@@ -1,182 +0,0 @@
/**
* Fancybox 图片灯箱 - 懒加载优化
* 只在用户首次点击图片时加载 Fancybox
*/
let fancyboxLoaded = false
let fancyboxPromise: Promise<void> | null = null
// Fancybox 配置
const fancyboxOptions = {
// 动画效果
showClass: 'f-fadeIn',
hideClass: 'f-fadeOut',
// 工具栏
Toolbar: {
display: {
left: ['infobar'],
middle: ['zoomIn', 'zoomOut', 'toggle1to1', 'rotateCCW', 'rotateCW', 'flipX', 'flipY'],
right: ['slideshow', 'thumbs', 'close'],
},
},
// 缩略图
Thumbs: {
type: 'classic' as const,
autoStart: false,
},
// 幻灯片
Carousel: {
infinite: true,
transition: 'slide' as const,
friction: 0.8,
},
// 图片设置
Images: {
zoom: true,
protected: true,
},
// 关闭设置
closeButton: 'top' as const,
dragToClose: true,
// 键盘导航
keyboard: {
Escape: 'close',
Delete: 'close',
Backspace: 'close',
PageUp: 'next',
PageDown: 'prev',
ArrowUp: 'prev',
ArrowDown: 'next',
ArrowRight: 'next',
ArrowLeft: 'prev',
},
// 预加载
preload: 2,
// 移动端优化
touch: {
vertical: true,
momentum: true,
},
// 点击背景关闭
closeExisting: false,
trapFocus: true,
autoFocus: true,
placeFocusBack: true,
// 自定义样式
mainClass: 'fancybox-custom',
}
/**
* 懒加载 Fancybox
*/
async function loadFancybox(): Promise<typeof import('@fancyapps/ui').Fancybox> {
if (fancyboxLoaded) {
const { Fancybox } = await import('@fancyapps/ui')
return Fancybox
}
if (!fancyboxPromise) {
fancyboxPromise = (async () => {
// 动态加载 CSS
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = '/node_modules/@fancyapps/ui/dist/fancybox/fancybox.css'
// 使用 import 加载 CSSVite 会处理)
await import('@fancyapps/ui/dist/fancybox/fancybox.css')
// 动态加载 JS
const { Fancybox } = await import('@fancyapps/ui')
// 绑定到所有 data-fancybox 元素
Fancybox.bind('[data-fancybox]', fancyboxOptions)
fancyboxLoaded = true
})()
}
await fancyboxPromise
const { Fancybox } = await import('@fancyapps/ui')
return Fancybox
}
/**
* 初始化 Fancybox 懒加载
* 监听 data-fancybox 元素的点击事件
*/
export function initFancyboxLazy() {
// 使用事件委托,在点击时懒加载
document.addEventListener('click', async (e) => {
const target = (e.target as HTMLElement).closest('[data-fancybox]')
if (!target) {return}
// 阻止默认行为
e.preventDefault()
e.stopPropagation()
// 加载 Fancybox
const Fancybox = await loadFancybox()
// 如果是首次加载,需要手动触发打开
if (!fancyboxLoaded) {
// Fancybox 已经绑定,重新触发点击
;(target as HTMLElement).click()
} else {
// 获取图片源
const src = target.getAttribute('href') || target.getAttribute('data-src') || (target as HTMLImageElement).src
const caption = target.getAttribute('data-caption') || target.getAttribute('title') || ''
// 获取同组图片
const group = target.getAttribute('data-fancybox')
if (group && group !== 'true') {
// 有分组,让 Fancybox 处理
const items = document.querySelectorAll(`[data-fancybox="${group}"]`)
const gallery = Array.from(items).map(item => ({
src: item.getAttribute('href') || item.getAttribute('data-src') || (item as HTMLImageElement).src,
caption: item.getAttribute('data-caption') || item.getAttribute('title') || '',
}))
const startIndex = Array.from(items).indexOf(target)
Fancybox.show(gallery, { ...fancyboxOptions, startIndex })
} else {
// 单张图片
Fancybox.show([{ src, caption }], fancyboxOptions)
}
}
}, { capture: true })
}
/**
* 预加载 Fancybox可选用于提升用户体验
* 可以在用户悬停图片时调用
*/
export function preloadFancybox() {
if (!fancyboxLoaded && !fancyboxPromise) {
// 使用 requestIdleCallback 在空闲时预加载
if ('requestIdleCallback' in window) {
requestIdleCallback(() => loadFancybox(), { timeout: 5000 })
} else {
setTimeout(() => loadFancybox(), 2000)
}
}
}
/**
* 手动打开 Fancybox
*/
export async function openFancybox(
items: Array<{ src: string; caption?: string }>,
options?: Partial<typeof fancyboxOptions>,
) {
const Fancybox = await loadFancybox()
Fancybox.show(items, { ...fancyboxOptions, ...options })
}
/**
* 关闭 Fancybox
*/
export async function closeFancybox() {
if (fancyboxLoaded) {
const { Fancybox } = await import('@fancyapps/ui')
Fancybox.close()
}
}

View File

@@ -0,0 +1,161 @@
/**
* 图片预览 Composable
* 管理图片预览状态和交互
*/
import { ref, computed, reactive } from 'vue'
export interface ImageItem {
src: string
caption?: string
thumbnail?: string
}
// 全局状态
const isOpen = ref(false)
const images = ref<ImageItem[]>([])
const currentIndex = ref(0)
// 变换状态
const transform = reactive({
scale: 1,
rotate: 0,
flipX: false,
flipY: false,
translateX: 0,
translateY: 0,
})
// 当前图片
const currentImage = computed(() => images.value[currentIndex.value])
// 是否有多张图片
const hasMultiple = computed(() => images.value.length > 1)
// 重置变换
function resetTransform() {
transform.scale = 1
transform.rotate = 0
transform.flipX = false
transform.flipY = false
transform.translateX = 0
transform.translateY = 0
}
// 打开预览
function open(items: ImageItem | ImageItem[], startIndex = 0) {
const itemsArray = Array.isArray(items) ? items : [items]
images.value = itemsArray
currentIndex.value = Math.min(startIndex, itemsArray.length - 1)
resetTransform()
isOpen.value = true
// 禁止背景滚动
document.body.style.overflow = 'hidden'
}
// 关闭预览
function close() {
isOpen.value = false
document.body.style.overflow = ''
}
// 上一张
function prev() {
if (images.value.length <= 1) {
return
}
currentIndex.value = currentIndex.value === 0
? images.value.length - 1
: currentIndex.value - 1
resetTransform()
}
// 下一张
function next() {
if (images.value.length <= 1) {
return
}
currentIndex.value = currentIndex.value === images.value.length - 1
? 0
: currentIndex.value + 1
resetTransform()
}
// 跳转到指定图片
function goTo(index: number) {
if (index >= 0 && index < images.value.length) {
currentIndex.value = index
resetTransform()
}
}
// 缩放
function zoomIn() {
transform.scale = Math.min(transform.scale * 1.5, 10)
}
function zoomOut() {
transform.scale = Math.max(transform.scale / 1.5, 0.1)
}
function toggleZoom() {
if (transform.scale === 1) {
transform.scale = 2
} else {
transform.scale = 1
transform.translateX = 0
transform.translateY = 0
}
}
// 旋转
function rotateCW() {
transform.rotate += 90
}
function rotateCCW() {
transform.rotate -= 90
}
// 翻转
function flipHorizontal() {
transform.flipX = !transform.flipX
}
function flipVertical() {
transform.flipY = !transform.flipY
}
// 平移
function pan(deltaX: number, deltaY: number) {
transform.translateX += deltaX
transform.translateY += deltaY
}
export function useImageViewer() {
return {
// 状态
isOpen,
images,
currentIndex,
currentImage,
hasMultiple,
transform,
// 方法
open,
close,
prev,
next,
goTo,
zoomIn,
zoomOut,
toggleZoom,
rotateCW,
rotateCCW,
flipHorizontal,
flipVertical,
pan,
resetTransform,
}
}

View File

@@ -28,9 +28,6 @@ import { createProgressFetch } from './composables/useProgress'
// Artalk 评论系统
import 'artalk/dist/Artalk.css'
// Fancybox - 图片灯箱(懒加载)
import { initFancyboxLazy } from './composables/useFancybox'
// 点击涟漪指令
import { vRipple } from './directives/vRipple'
@@ -65,9 +62,6 @@ createProgressFetch()
app.mount('#app')
// 初始化 Fancybox 懒加载(首次点击图片时才加载)
initFancyboxLazy()
// Service Worker 更新检测
// 当前激活的 SW 版本(运行时获取)
let activatedSwVersion: string | null = null

View File

@@ -7,29 +7,64 @@ import { useCacheStore } from './cache'
export interface VndbVoiceActor {
id: string
name: string
character?: string
character?: { id: string; name: string }
}
export interface VndbTag {
id: string
name: string
rating?: number
spoiler?: number
category?: string
}
export interface VndbRelation {
id: string
title: string
relation: string
relation_official?: boolean
}
export interface VndbExtLink {
url: string
label: string
name: string
}
export interface VndbDeveloper {
id?: string
name: string
original?: string
}
export interface VndbInfo {
id?: string
names: string[]
mainName: string
originalTitle: string
mainImageUrl: string | null
screenshotUrl: string | null
screenshots: string[]
description: string | null
translatedDescription: string | null
va: VndbVoiceActor[]
vntags: VndbTag[]
tags: VndbTag[]
relations: VndbRelation[]
extlinks: VndbExtLink[]
play_hours: number
length_minute: number
length_votes: number
length_color: string
book_length: string
rating?: number
average?: number
votecount?: number
released?: string
developers?: VndbDeveloper[]
platforms?: string[]
languages?: string[]
olang?: string
devstatus?: number
}
export interface SearchResult {