diff --git a/package.json b/package.json index 072a657..198113d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e2c509..34f1d7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': {} diff --git a/src/App.vue b/src/App.vue index 576b663..60bb969 100644 --- a/src/App.vue +++ b/src/App.vue @@ -57,6 +57,9 @@ :is-visible="uiStore.showUpdateToast" :on-update="handleSwUpdate" /> + + + @@ -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' diff --git a/src/api/search.ts b/src/api/search.ts index 6d7d2fb..f168e3f 100644 --- a/src/api/search.ts +++ b/src/api/search.ts @@ -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 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 } // 提取开发商信息 - 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 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 diff --git a/src/components/ImageViewer.vue b/src/components/ImageViewer.vue new file mode 100644 index 0000000..db38c3b --- /dev/null +++ b/src/components/ImageViewer.vue @@ -0,0 +1,817 @@ + + + + + diff --git a/src/components/VndbPanel.vue b/src/components/VndbPanel.vue index 0bf6864..8b841bb 100644 --- a/src/components/VndbPanel.vue +++ b/src/components/VndbPanel.vue @@ -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 @@
- - +
@@ -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(', ') }}

+ + +
+
+ +
+
+

开发状态

+

+ {{ formatDevStatus(searchStore.vndbInfo.devstatus) }} +

+
+
+ + +
+
+ +
+
+

原始语言

+

+ {{ formatLanguage(searchStore.vndbInfo.olang) }} +

+
+
+ + + +
+
+ +

支持语言

+
+
+ + {{ formatLanguage(lang) }} + +
@@ -204,6 +246,154 @@ + +
+
+
+ +

标签

+ (按相关性排序) +
+ +
+ + + + +
+
+
+ + {{ getTagDisplayName(tag) }} + +
+
+ 分类: + + 内容 + + + 技术 + + + 色情 + +
+
+ + +
+
+ +

声优

+ ({{ searchStore.vndbInfo.va.length }}) +
+ +

+ 还有 {{ searchStore.vndbInfo.va.length - 10 }} 位声优... +

+
+ + +
+
+ +

相关作品

+
+ +

+ 还有 {{ searchStore.vndbInfo.relations.length - 8 }} 个相关作品... +

+
+ + +
+
+ +

外部链接

+
+ +
+
@@ -269,13 +459,11 @@

游戏截图

- - +
@@ -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(null) const showOriginal = ref(false) const translateError = ref(false) +// 标签翻译状态 +const isTranslatingTags = ref(false) +const translatedTags = ref>(new Map()) +const showOriginalTags = ref(false) +const translateTagsError = ref(false) + // 窗口管理 const modalRef = ref(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() + 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 = { + '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 = { + 0: '已完成', + 1: '开发中', + 2: '已取消', + } + return statusMap[status] || '未知' +} + +// 获取开发状态颜色 +function getDevStatusColor(status: number): string { + const colorMap: Record = { + 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 = { + '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 = { + 'cont': '内容', + 'tech': '技术', + 'ero': '色情', + } + return categoryMap[category] || category +} + +// 格式化关系类型 +function formatRelation(relation: string): string { + const relationMap: Record = { + 'seq': '续作', + 'preq': '前作', + 'set': '同一设定', + 'alt': '替代版本', + 'char': '角色共享', + 'side': '外传', + 'par': '父作品', + 'fan': '同人作品', + 'orig': '原作', + 'ser': '同系列', + } + return relationMap[relation] || relation +}