mirror of
https://github.com/Moe-Sakura/frontend.git
synced 2026-03-26 06:59:44 +08:00
Merge pull request #39 from Moe-Sakura/dev
Refactor VndbPanel and related components to enhance functionality an…
This commit is contained in:
@@ -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
8
pnpm-lock.yaml
generated
@@ -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': {}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
817
src/components/ImageViewer.vue
Normal file
817
src/components/ImageViewer.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 加载 CSS(Vite 会处理)
|
||||
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()
|
||||
}
|
||||
}
|
||||
161
src/composables/useImageViewer.ts
Normal file
161
src/composables/useImageViewer.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user