diff --git a/src/api/search.ts b/src/api/search.ts index d9ab05a..c49084a 100644 --- a/src/api/search.ts +++ b/src/api/search.ts @@ -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 { + 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 { 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 { + 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 }) diff --git a/src/components/VndbPanel.vue b/src/components/VndbPanel.vue index 8e0a667..584ebdd 100644 --- a/src/components/VndbPanel.vue +++ b/src/components/VndbPanel.vue @@ -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) }} @@ -477,6 +477,42 @@ + +
+
+ +

PV 鉴赏

+
+
+ +
+

+ 视频来源:TouchGal +

+
+ + +
+
+ +

PV 鉴赏

+
+
+ + 正在获取视频... +
+
+
- 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(null) +const isPvLoading = ref(false) +const pvVideoRef = ref(null) + +// 视频加载完成后暂停在第一帧 +function handleVideoLoaded() { + if (pvVideoRef.value) { + pvVideoRef.value.currentTime = 0 + pvVideoRef.value.pause() + } +} + // 当前游戏 ID(用于防止切换游戏时数据错乱) const currentVnId = ref(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() - 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() - 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[] = [] - - // 翻译简介 - 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() + 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() + 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 } }