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:
AdingApkgg
2025-12-27 06:23:27 +08:00
parent aeaa9b6651
commit d785fb59da
2 changed files with 330 additions and 165 deletions

View File

@@ -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
})

View File

@@ -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
}
}