mirror of
https://github.com/Moe-Sakura/frontend.git
synced 2026-05-08 00:24:17 +08:00
Add video parsing API and enhance translation functionality in VndbPanel.vue
- Introduced a new API for fetching game PV video URLs, improving multimedia support. - Added a video player component in VndbPanel.vue to display PV videos, enhancing user engagement. - Refactored translation logic to allow for a single API request to translate descriptions, tags, and quotes simultaneously, improving efficiency. - Updated loading states and error handling for translations and video fetching, ensuring a smoother user experience.
This commit is contained in:
@@ -47,6 +47,48 @@ export function getVndbImageProxyUrl(): string {
|
||||
}
|
||||
}
|
||||
|
||||
// TouchGal 视频解析 API
|
||||
const VIDEO_PARSE_API_URL = 'https://vp.searchgal.homes/'
|
||||
|
||||
export interface VideoParseResult {
|
||||
success: boolean
|
||||
game_result: number
|
||||
video_url: string
|
||||
error: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取游戏 PV 视频 URL
|
||||
* @param vndbId VNDB ID (如 "v12345")
|
||||
*/
|
||||
export async function fetchGameVideoUrl(vndbId: string): Promise<string | null> {
|
||||
if (!vndbId) return null
|
||||
|
||||
try {
|
||||
const response = await fetch(VIDEO_PARSE_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ vndb_id: vndbId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
const data: VideoParseResult = await response.json()
|
||||
|
||||
if (data.success && data.video_url) {
|
||||
return data.video_url
|
||||
}
|
||||
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const ENABLE_VNDB_IMAGE_PROXY = true
|
||||
|
||||
let isProxyAvailable = false
|
||||
@@ -619,7 +661,7 @@ export async function fetchVndbCharacters(vnId: string): Promise<VndbCharacter[]
|
||||
if (ENABLE_VNDB_IMAGE_PROXY && isProxyAvailable) {
|
||||
characters.forEach((char) => {
|
||||
if (char.image && char.image.startsWith('https://t.vndb.org/')) {
|
||||
char.image = getVndbImageProxyUrl() + char.image
|
||||
char.image = proxyUrl(char.image)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -727,6 +769,30 @@ const QUOTES_PROMPT = `你是一名专业的视觉小说(Galgame/AVG)本地
|
||||
每行输出一条翻译结果,与输入行数严格一一对应
|
||||
仅输出译文,无需编号、原文或解释`
|
||||
|
||||
// 合并翻译提示词
|
||||
const COMBINED_PROMPT = `你是一名专业的视觉小说(Galgame/AVG)本地化专家。请将以下内容翻译为简体中文。
|
||||
|
||||
输入格式使用 ===SECTION=== 分隔三个部分:
|
||||
1. 游戏简介
|
||||
2. 游戏标签(每行一个)
|
||||
3. 经典台词(每行一条)
|
||||
|
||||
【翻译规范】
|
||||
- 简介:清除HTML标记,保持段落结构,使用通用中文术语
|
||||
- 标签:使用二次元圈常用说法,保持简洁
|
||||
- 台词:保留情感色彩和语气,注意口语化
|
||||
|
||||
【输出格式】
|
||||
严格按以下格式输出,使用相同的分隔符:
|
||||
===SECTION===
|
||||
翻译后的简介
|
||||
===SECTION===
|
||||
翻译后的标签(每行一个,与输入行数对应)
|
||||
===SECTION===
|
||||
翻译后的台词(每行一条,与输入行数对应)
|
||||
|
||||
仅输出翻译结果,无需任何说明`
|
||||
|
||||
/**
|
||||
* AI 翻译文本
|
||||
* @param text - 要翻译的文本
|
||||
@@ -743,11 +809,11 @@ export async function translateText(
|
||||
return null
|
||||
}
|
||||
|
||||
// 根据模式选择提示词和参数
|
||||
// 根据模式选择提示词和参数(针对 Qwen2.5 优化)
|
||||
const modeConfig = {
|
||||
description: { prompt: DESCRIPTION_PROMPT, maxLength: 3000, maxTokens: 2000, temperature: 0.3 },
|
||||
tags: { prompt: TAGS_PROMPT, maxLength: 1500, maxTokens: 1000, temperature: 0.2 },
|
||||
quotes: { prompt: QUOTES_PROMPT, maxLength: 2000, maxTokens: 1500, temperature: 0.4 },
|
||||
description: { prompt: DESCRIPTION_PROMPT, maxLength: 3000, maxTokens: 2000, temperature: 0.1 },
|
||||
tags: { prompt: TAGS_PROMPT, maxLength: 1500, maxTokens: 1000, temperature: 0.05 },
|
||||
quotes: { prompt: QUOTES_PROMPT, maxLength: 2000, maxTokens: 1500, temperature: 0.2 },
|
||||
}
|
||||
|
||||
const config = modeConfig[mode]
|
||||
@@ -780,6 +846,9 @@ export async function translateText(
|
||||
],
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
top_p: 0.9,
|
||||
top_k: 50,
|
||||
repetition_penalty: 1.05,
|
||||
stream: false,
|
||||
}),
|
||||
})
|
||||
@@ -823,32 +892,140 @@ export async function translateText(
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并翻译:一次 API 请求翻译描述、标签和名言
|
||||
*/
|
||||
export interface TranslateAllResult {
|
||||
description: string | null
|
||||
tags: string[] | null
|
||||
quotes: string[] | null
|
||||
}
|
||||
|
||||
export async function translateAllContent(
|
||||
description: string | null,
|
||||
tags: string[] | null,
|
||||
quotes: string[] | null,
|
||||
maxRetries: number = 2,
|
||||
): Promise<TranslateAllResult> {
|
||||
const result: TranslateAllResult = {
|
||||
description: null,
|
||||
tags: null,
|
||||
quotes: null,
|
||||
}
|
||||
|
||||
// 构建输入文本
|
||||
const descText = description?.trim() || ''
|
||||
const tagsText = tags?.join('\n') || ''
|
||||
const quotesText = quotes?.join('\n') || ''
|
||||
|
||||
// 如果没有任何内容需要翻译
|
||||
if (!descText && !tagsText && !quotesText) {
|
||||
return result
|
||||
}
|
||||
|
||||
const inputText = [descText, tagsText, quotesText].join('\n===SECTION===\n')
|
||||
|
||||
// 限制总长度
|
||||
const maxLength = 6000
|
||||
const textToTranslate = inputText.length > maxLength
|
||||
? inputText.substring(0, maxLength) + '...'
|
||||
: inputText
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(getAiTranslateApiUrl(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${getAiTranslateApiKey()}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: getAiTranslateModel(),
|
||||
messages: [
|
||||
{ role: 'system', content: COMBINED_PROMPT },
|
||||
{ role: 'user', content: textToTranslate },
|
||||
],
|
||||
temperature: 0.15, // 低温度保证翻译准确性
|
||||
max_tokens: 4000,
|
||||
top_p: 0.9, // 核采样,平衡多样性和准确性
|
||||
top_k: 50, // 限制候选词范围
|
||||
repetition_penalty: 1.05, // 轻微惩罚重复
|
||||
stream: false,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (attempt === maxRetries) return result
|
||||
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
|
||||
continue
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const content = data.choices?.[0]?.message?.content?.trim()
|
||||
|
||||
if (content) {
|
||||
// 解析返回结果
|
||||
const parts = content.split(/===SECTION===/).map((s: string) => s.trim()).filter((s: string) => s)
|
||||
|
||||
if (parts.length >= 1 && descText) {
|
||||
result.description = parts[0] || null
|
||||
}
|
||||
if (parts.length >= 2 && tagsText) {
|
||||
result.tags = parts[1]?.split('\n').map((s: string) => s.trim()).filter((s: string) => s) || null
|
||||
}
|
||||
if (parts.length >= 3 && quotesText) {
|
||||
result.quotes = parts[2]?.split('\n').map((s: string) => s.trim()).filter((s: string) => s) || null
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
|
||||
continue
|
||||
}
|
||||
|
||||
return result
|
||||
} catch {
|
||||
if (attempt === maxRetries) return result
|
||||
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function checkProxyAvailability() {
|
||||
// 始终启用代理,不进行可用性检查
|
||||
// 因为代理服务器可能不支持 HEAD 请求
|
||||
isProxyAvailable = true
|
||||
}
|
||||
|
||||
// 生成代理 URL
|
||||
function proxyUrl(url: string): string {
|
||||
return getVndbImageProxyUrl() + url
|
||||
}
|
||||
|
||||
function replaceVndbUrls(vndbInfo: VndbInfo) {
|
||||
if (!ENABLE_VNDB_IMAGE_PROXY || !isProxyAvailable) {
|
||||
return
|
||||
}
|
||||
|
||||
// 替换封面图片 URL - 代理需要完整的原始 URL
|
||||
// 替换封面图片 URL
|
||||
if (vndbInfo.mainImageUrl && vndbInfo.mainImageUrl.startsWith('https://t.vndb.org/')) {
|
||||
vndbInfo.mainImageUrl = getVndbImageProxyUrl() + vndbInfo.mainImageUrl
|
||||
vndbInfo.mainImageUrl = proxyUrl(vndbInfo.mainImageUrl)
|
||||
}
|
||||
|
||||
// 替换主截图 URL - 代理需要完整的原始 URL
|
||||
// 替换主截图 URL
|
||||
if (vndbInfo.screenshotUrl && vndbInfo.screenshotUrl.startsWith('https://t.vndb.org/')) {
|
||||
vndbInfo.screenshotUrl = getVndbImageProxyUrl() + vndbInfo.screenshotUrl
|
||||
vndbInfo.screenshotUrl = proxyUrl(vndbInfo.screenshotUrl)
|
||||
}
|
||||
|
||||
// 替换所有截图 URL
|
||||
if (vndbInfo.screenshots && vndbInfo.screenshots.length > 0) {
|
||||
vndbInfo.screenshots = vndbInfo.screenshots.map((url) => {
|
||||
if (url.startsWith('https://t.vndb.org/')) {
|
||||
return getVndbImageProxyUrl() + url
|
||||
return proxyUrl(url)
|
||||
}
|
||||
return url
|
||||
})
|
||||
|
||||
@@ -275,8 +275,8 @@
|
||||
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)}`"
|
||||
:class="getTagCategoryClass(tag.category || '')"
|
||||
:title="`${tag.name}${translatedTags.get(tag.name) ? ' → ' + translatedTags.get(tag.name) : ''} | 相关性: ${Math.round((tag.rating || 0) * 10) / 10} | 分类: ${formatTagCategory(tag.category || '')}`"
|
||||
>
|
||||
{{ getTagDisplayName(tag) }}
|
||||
</span>
|
||||
@@ -477,6 +477,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PV 鉴赏 -->
|
||||
<div v-if="pvVideoUrl" class="vndb-card">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<Play :size="18" class="text-rose-500" />
|
||||
<h3 class="font-bold text-gray-800 dark:text-white">PV 鉴赏</h3>
|
||||
</div>
|
||||
<div class="relative rounded-xl overflow-hidden bg-black">
|
||||
<video
|
||||
ref="pvVideoRef"
|
||||
:src="pvVideoUrl"
|
||||
controls
|
||||
playsinline
|
||||
preload="auto"
|
||||
class="w-full h-auto max-h-[400px] object-contain"
|
||||
@loadeddata="handleVideoLoaded"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-2 text-center">
|
||||
视频来源:TouchGal
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- PV 加载中 -->
|
||||
<div v-else-if="isPvLoading" class="vndb-card">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<Play :size="18" class="text-rose-500" />
|
||||
<h3 class="font-bold text-gray-800 dark:text-white">PV 鉴赏</h3>
|
||||
</div>
|
||||
<div class="flex items-center justify-center py-8 text-gray-400">
|
||||
<Loader :size="20" class="animate-spin mr-2" />
|
||||
<span class="text-sm">正在获取视频...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 游戏截图 (等待首张图片加载后显示) -->
|
||||
<div
|
||||
v-if="searchStore.vndbInfo.screenshots && searchStore.vndbInfo.screenshots.length > 0"
|
||||
@@ -515,7 +551,7 @@
|
||||
import { ref, watch, computed, nextTick } from 'vue'
|
||||
import { useSearchStore, type VndbCharacter, type VndbQuote } from '@/stores/search'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { translateText, fetchVndbCharacters, fetchVndbQuotes } from '@/api/search'
|
||||
import { translateAllContent, fetchVndbCharacters, fetchVndbQuotes, fetchGameVideoUrl } from '@/api/search'
|
||||
import { playClick, playSuccess, playError, playToggle, playTransitionUp, playTransitionDown } from '@/composables/useSound'
|
||||
import { useImageViewer } from '@/composables/useImageViewer'
|
||||
import {
|
||||
@@ -541,6 +577,7 @@ import {
|
||||
Gamepad2,
|
||||
Users,
|
||||
Quote,
|
||||
Play,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
// 图片预览
|
||||
@@ -572,8 +609,9 @@ const translateQuotesError = ref(false)
|
||||
const showOriginalQuotes = ref(false)
|
||||
|
||||
// 一键翻译状态
|
||||
const isTranslatingAllRef = ref(false)
|
||||
const isTranslatingAll = computed(() =>
|
||||
isTranslating.value || isTranslatingTags.value || isTranslatingQuotes.value,
|
||||
isTranslatingAllRef.value || isTranslating.value || isTranslatingTags.value || isTranslatingQuotes.value,
|
||||
)
|
||||
const hasAnyTranslation = computed(() =>
|
||||
translatedDescription.value || translatedTags.value.size > 0 || translatedQuotes.value.size > 0,
|
||||
@@ -582,6 +620,19 @@ const hasAnyTranslation = computed(() =>
|
||||
// 截图加载状态
|
||||
const screenshotsReady = ref(false)
|
||||
|
||||
// PV 视频状态
|
||||
const pvVideoUrl = ref<string | null>(null)
|
||||
const isPvLoading = ref(false)
|
||||
const pvVideoRef = ref<HTMLVideoElement | null>(null)
|
||||
|
||||
// 视频加载完成后暂停在第一帧
|
||||
function handleVideoLoaded() {
|
||||
if (pvVideoRef.value) {
|
||||
pvVideoRef.value.currentTime = 0
|
||||
pvVideoRef.value.pause()
|
||||
}
|
||||
}
|
||||
|
||||
// 当前游戏 ID(用于防止切换游戏时数据错乱)
|
||||
const currentVnId = ref<string | null>(null)
|
||||
|
||||
@@ -631,6 +682,9 @@ watch(() => searchStore.vndbInfo, async (newInfo) => {
|
||||
// 重置角色和名言
|
||||
characters.value = []
|
||||
quotes.value = []
|
||||
// 重置 PV 视频状态
|
||||
pvVideoUrl.value = null
|
||||
isPvLoading.value = false
|
||||
// 重置展开状态
|
||||
expandedSections.value = {
|
||||
names: false,
|
||||
@@ -680,21 +734,25 @@ watch(() => searchStore.vndbInfo, async (newInfo) => {
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 加载角色和名言
|
||||
// 加载角色、名言和 PV 视频
|
||||
async function loadCharactersAndQuotes(vnId: string) {
|
||||
// 并行加载角色和名言
|
||||
// 并行加载角色、名言和 PV 视频
|
||||
isLoadingCharacters.value = true
|
||||
isLoadingQuotes.value = true
|
||||
isPvLoading.value = true
|
||||
|
||||
const [chars, quoteList] = await Promise.all([
|
||||
const [chars, quoteList, videoUrl] = await Promise.all([
|
||||
fetchVndbCharacters(vnId),
|
||||
fetchVndbQuotes(vnId),
|
||||
fetchGameVideoUrl(vnId),
|
||||
])
|
||||
|
||||
characters.value = chars
|
||||
quotes.value = quoteList
|
||||
pvVideoUrl.value = videoUrl
|
||||
isLoadingCharacters.value = false
|
||||
isLoadingQuotes.value = false
|
||||
isPvLoading.value = false
|
||||
}
|
||||
|
||||
// 监听打开状态
|
||||
@@ -704,123 +762,6 @@ watch(() => uiStore.isVndbPanelOpen, (isOpen) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 内部翻译函数(带游戏 ID 校验和静默模式)
|
||||
async function translateDescriptionInternal(vnIdAtStart: string | null, silent: boolean) {
|
||||
if (!searchStore.vndbInfo?.description || isTranslating.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isTranslating.value = true
|
||||
translateError.value = false
|
||||
|
||||
try {
|
||||
const translated = await translateText(searchStore.vndbInfo.description, 'description')
|
||||
// 检查是否仍是同一个游戏
|
||||
if (currentVnId.value !== vnIdAtStart) {
|
||||
return
|
||||
}
|
||||
if (translated) {
|
||||
translatedDescription.value = translated
|
||||
showOriginal.value = false
|
||||
translateError.value = false
|
||||
} else {
|
||||
translateError.value = true
|
||||
if (!silent) { playError() }
|
||||
}
|
||||
} catch {
|
||||
if (currentVnId.value === vnIdAtStart) {
|
||||
translateError.value = true
|
||||
if (!silent) { playError() }
|
||||
}
|
||||
} finally {
|
||||
isTranslating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function translateTagsInternal(vnIdAtStart: string | null, silent: boolean) {
|
||||
if (!searchStore.vndbInfo?.tags || searchStore.vndbInfo.tags.length === 0 || isTranslatingTags.value) {
|
||||
return
|
||||
}
|
||||
|
||||
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, 'tags')
|
||||
// 检查是否仍是同一个游戏
|
||||
if (currentVnId.value !== vnIdAtStart) {
|
||||
return
|
||||
}
|
||||
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
|
||||
} else {
|
||||
translateTagsError.value = true
|
||||
if (!silent) { playError() }
|
||||
}
|
||||
} catch {
|
||||
if (currentVnId.value === vnIdAtStart) {
|
||||
translateTagsError.value = true
|
||||
if (!silent) { playError() }
|
||||
}
|
||||
} finally {
|
||||
isTranslatingTags.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function translateQuotesInternal(vnIdAtStart: string | null, silent: boolean) {
|
||||
if (quotes.value.length === 0 || isTranslatingQuotes.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isTranslatingQuotes.value = true
|
||||
translateQuotesError.value = false
|
||||
|
||||
try {
|
||||
const quoteTexts = quotes.value.map(q => q.quote)
|
||||
const textToTranslate = quoteTexts.join('\n')
|
||||
|
||||
const translated = await translateText(textToTranslate, 'quotes')
|
||||
// 检查是否仍是同一个游戏
|
||||
if (currentVnId.value !== vnIdAtStart) {
|
||||
return
|
||||
}
|
||||
if (translated) {
|
||||
const translatedTexts = translated.split('\n').map(s => s.trim()).filter(s => s)
|
||||
const newMap = new Map<string, string>()
|
||||
quoteTexts.forEach((original, index) => {
|
||||
if (translatedTexts[index]) {
|
||||
newMap.set(original, translatedTexts[index])
|
||||
}
|
||||
})
|
||||
translatedQuotes.value = newMap
|
||||
showOriginalQuotes.value = false
|
||||
translateQuotesError.value = false
|
||||
} else {
|
||||
translateQuotesError.value = true
|
||||
if (!silent) { playError() }
|
||||
}
|
||||
} catch {
|
||||
if (currentVnId.value === vnIdAtStart) {
|
||||
translateQuotesError.value = true
|
||||
if (!silent) { playError() }
|
||||
}
|
||||
} finally {
|
||||
isTranslatingQuotes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取名言显示文本
|
||||
function getQuoteDisplayText(quote: string): string {
|
||||
if (showOriginalQuotes.value || translatedQuotes.value.size === 0) {
|
||||
@@ -829,7 +770,7 @@ function getQuoteDisplayText(quote: string): string {
|
||||
return translatedQuotes.value.get(quote) || quote
|
||||
}
|
||||
|
||||
// 一键翻译全部(内部实现)
|
||||
// 一键翻译全部(内部实现)- 合并为单次 API 请求
|
||||
async function translateAllInternal(silent = false) {
|
||||
if (isTranslatingAll.value) {
|
||||
return
|
||||
@@ -839,39 +780,86 @@ async function translateAllInternal(silent = false) {
|
||||
playClick()
|
||||
}
|
||||
|
||||
isTranslatingAllRef.value = true
|
||||
const vnIdAtStart = currentVnId.value
|
||||
|
||||
// 在调用前捕获当前数据状态,避免翻译期间数据被重置
|
||||
const hasDescription = !!searchStore.vndbInfo?.description && !translatedDescription.value
|
||||
const hasTags = !!searchStore.vndbInfo?.tags && searchStore.vndbInfo.tags.length > 0 && translatedTags.value.size === 0
|
||||
const hasQuotes = quotes.value.length > 0 && translatedQuotes.value.size === 0
|
||||
|
||||
// 并行执行所有翻译任务
|
||||
const tasks: Promise<void>[] = []
|
||||
|
||||
// 翻译简介
|
||||
if (hasDescription) {
|
||||
tasks.push(translateDescriptionInternal(vnIdAtStart, silent))
|
||||
}
|
||||
|
||||
// 翻译标签
|
||||
if (hasTags) {
|
||||
tasks.push(translateTagsInternal(vnIdAtStart, silent))
|
||||
}
|
||||
|
||||
// 翻译名言
|
||||
if (hasQuotes) {
|
||||
tasks.push(translateQuotesInternal(vnIdAtStart, silent))
|
||||
}
|
||||
|
||||
// 使用 allSettled 确保所有任务完成,即使某些失败
|
||||
await Promise.allSettled(tasks)
|
||||
|
||||
// 如果有任何翻译成功且是当前游戏,播放成功音效
|
||||
if (!silent && currentVnId.value === vnIdAtStart) {
|
||||
if (translatedDescription.value || translatedTags.value.size > 0 || translatedQuotes.value.size > 0) {
|
||||
playSuccess()
|
||||
try {
|
||||
// 收集需要翻译的内容
|
||||
const descText = (!translatedDescription.value && searchStore.vndbInfo?.description)
|
||||
? searchStore.vndbInfo.description
|
||||
: null
|
||||
|
||||
const tagNames = (translatedTags.value.size === 0 && searchStore.vndbInfo?.tags?.length)
|
||||
? searchStore.vndbInfo.tags.map(t => t.name)
|
||||
: null
|
||||
|
||||
const quoteTexts = (translatedQuotes.value.size === 0 && quotes.value.length > 0)
|
||||
? quotes.value.map(q => q.quote)
|
||||
: null
|
||||
|
||||
// 如果没有任何需要翻译的内容
|
||||
if (!descText && !tagNames && !quoteTexts) {
|
||||
return
|
||||
}
|
||||
|
||||
// 单次 API 请求翻译所有内容
|
||||
const result = await translateAllContent(descText, tagNames, quoteTexts)
|
||||
|
||||
// 检查是否仍是同一个游戏
|
||||
if (currentVnId.value !== vnIdAtStart) {
|
||||
return
|
||||
}
|
||||
|
||||
// 应用翻译结果
|
||||
let hasSuccess = false
|
||||
|
||||
if (result.description && descText) {
|
||||
translatedDescription.value = result.description
|
||||
showOriginal.value = false
|
||||
hasSuccess = true
|
||||
}
|
||||
|
||||
if (result.tags && tagNames) {
|
||||
const newMap = new Map<string, string>()
|
||||
tagNames.forEach((original, index) => {
|
||||
if (result.tags![index]) {
|
||||
newMap.set(original, result.tags![index])
|
||||
}
|
||||
})
|
||||
if (newMap.size > 0) {
|
||||
translatedTags.value = newMap
|
||||
showOriginalTags.value = false
|
||||
hasSuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
if (result.quotes && quoteTexts) {
|
||||
const newMap = new Map<string, string>()
|
||||
quoteTexts.forEach((original, index) => {
|
||||
if (result.quotes![index]) {
|
||||
newMap.set(original, result.quotes![index])
|
||||
}
|
||||
})
|
||||
if (newMap.size > 0) {
|
||||
translatedQuotes.value = newMap
|
||||
showOriginalQuotes.value = false
|
||||
hasSuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!silent && hasSuccess) {
|
||||
playSuccess()
|
||||
} else if (!silent && !hasSuccess) {
|
||||
translateError.value = true
|
||||
playError()
|
||||
}
|
||||
} catch {
|
||||
if (!silent) {
|
||||
translateError.value = true
|
||||
playError()
|
||||
}
|
||||
} finally {
|
||||
isTranslatingAllRef.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user