crosssubtitle-ai/src/App.vue
2026-05-04 14:49:15 +08:00

573 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { invoke } from '@tauri-apps/api/core'
import { open, save } from '@tauri-apps/plugin-dialog'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import TaskQueue from './components/TaskQueue.vue'
import SubtitleEditor from './components/SubtitleEditor.vue'
import { useTaskStore } from './stores/tasks'
import type { DefaultModelPaths, OutputMode, TargetLanguage, TranslationConfig } from './lib/types'
const { t, locale } = useI18n()
const SOURCE_LANGUAGE_OPTIONS = [
{ value: 'zh', label: '中文' },
{ value: 'en', label: 'English' },
{ value: 'ja', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'fr', label: 'Français' },
{ value: 'de', label: 'Deutsch' },
{ value: 'es', label: 'Español' },
{ value: 'ru', label: 'Русский' },
{ value: 'it', label: 'Italiano' },
{ value: 'pt', label: 'Português' },
{ value: 'ar', label: 'العربية' },
{ value: 'tr', label: 'Türkçe' },
{ value: 'vi', label: 'Tiếng Việt' },
{ value: 'id', label: 'Bahasa Indonesia' },
{ value: 'th', label: 'ไทย' },
{ value: 'hi', label: 'हिन्दी' },
{ value: 'uk', label: 'Українська' },
{ value: 'pl', label: 'Polski' },
{ value: 'nl', label: 'Nederlands' },
]
const MEDIA_EXTENSIONS = [
'mp3',
'wav',
'm4a',
'flac',
'aac',
'ogg',
'opus',
'mp4',
'm4v',
'mkv',
'flv',
'mov',
'avi',
'wmv',
'webm',
'ts',
'm2ts',
'mpeg',
'mpg',
] as const
const FREE_API_KEY_URL = 'https://docs.bigmodel.cn/cn/guide/models/free/glm-4.7-flash'
const taskStore = useTaskStore()
const targetLang = ref<TargetLanguage>('zh')
const outputMode = ref<OutputMode>('translate')
const sourceLang = ref('auto')
const bilingualOutput = ref(true)
const whisperModelPath = ref('')
const vadModelPath = ref('')
const defaultModelPaths = ref<DefaultModelPaths | null>(null)
const translationConfig = ref<TranslationConfig>({
apiBase: localStorage.getItem('llm.apiBase') ?? 'https://open.bigmodel.cn/api/paas/v4',
apiKey: localStorage.getItem('llm.apiKey') ?? '',
model: localStorage.getItem('llm.model') ?? 'GLM-4.7-Flash',
batchSize: Number(localStorage.getItem('llm.batchSize') ?? '12'),
contextSize: Number(localStorage.getItem('llm.contextSize') ?? '5'),
})
const pending = ref(false)
const feedback = ref('')
const feedbackTone = ref<'normal' | 'error'>('normal')
const showAdvanced = ref(false)
const isDragging = ref(false)
const showApiKeyDialog = ref(false)
const apiKeyUrlCopied = ref(false)
const authorUrlCopied = ref(false)
let unlistenMenuAction: UnlistenFn | null = null
let dragDropUnlistenFn: UnlistenFn | null = null
let authorCopyTimer: ReturnType<typeof setTimeout> | null = null
const selectedTask = computed(() => taskStore.selectedTask)
const hasTranslationKey = computed(() => translationConfig.value.apiKey.trim().length > 0)
onMounted(() => {
taskStore.initialize()
void loadDefaultModelPaths()
void bindMenuActions()
void initDragDrop()
})
onUnmounted(() => {
if (unlistenMenuAction) {
unlistenMenuAction()
unlistenMenuAction = null
}
if (dragDropUnlistenFn) {
dragDropUnlistenFn()
dragDropUnlistenFn = null
}
if (authorCopyTimer) {
clearTimeout(authorCopyTimer)
authorCopyTimer = null
}
})
watch(locale, (newLocale) => {
localStorage.setItem('locale', newLocale)
if (outputMode.value === 'translate' && !hasTranslationKey.value) {
showMissingApiKeyFeedback()
}
})
watch([outputMode, hasTranslationKey], ([mode, hasKey]) => {
if (mode === 'translate' && !hasKey) {
showMissingApiKeyFeedback()
return
}
if (feedbackTone.value === 'error' && feedback.value === t('app.feedback.noApiKey')) {
feedbackTone.value = 'normal'
feedback.value = ''
}
})
function persistTranslationConfig() {
localStorage.setItem('llm.apiBase', translationConfig.value.apiBase)
localStorage.setItem('llm.apiKey', translationConfig.value.apiKey)
localStorage.setItem('llm.model', translationConfig.value.model)
localStorage.setItem('llm.batchSize', String(translationConfig.value.batchSize))
localStorage.setItem('llm.contextSize', String(translationConfig.value.contextSize))
}
function resetModelPaths() {
whisperModelPath.value = defaultModelPaths.value?.whisperModelPath ?? ''
vadModelPath.value = defaultModelPaths.value?.vadModelPath ?? ''
feedbackTone.value = 'normal'
feedback.value = t('app.feedback.restoredDefaults')
}
function showMissingApiKeyFeedback() {
feedbackTone.value = 'error'
feedback.value = t('app.feedback.noApiKey')
}
function freeApiKeyHint() {
if (locale.value === 'zh-CN') {
return '开始翻译任务前需要先填写 LLM API Key。你可以选择下面任意一种方式。'
}
return 'An LLM API Key is required before starting a translation task. Choose any of the options below.'
}
function openApiKeyDialog() {
showMissingApiKeyFeedback()
apiKeyUrlCopied.value = false
showApiKeyDialog.value = true
}
async function copyFreeApiKeyUrl() {
await navigator.clipboard.writeText(FREE_API_KEY_URL)
apiKeyUrlCopied.value = true
}
async function copyAuthorUrl() {
await navigator.clipboard.writeText('https://kuraa.cc')
authorUrlCopied.value = true
if (authorCopyTimer) {
clearTimeout(authorCopyTimer)
}
authorCopyTimer = setTimeout(() => {
authorUrlCopied.value = false
authorCopyTimer = null
}, 1400)
}
function acknowledgeApiKeyDialog() {
showApiKeyDialog.value = false
outputMode.value = 'source'
}
async function loadDefaultModelPaths() {
try {
const paths = await invoke<DefaultModelPaths>('get_default_model_paths')
defaultModelPaths.value = paths
if (!whisperModelPath.value) {
whisperModelPath.value = paths.whisperModelPath
}
if (!vadModelPath.value) {
vadModelPath.value = paths.vadModelPath
}
} catch (error) {
feedback.value =
error instanceof Error ? `${t('app.feedback.loadModelError')}${error.message}` : t('app.feedback.loadModelErrorFallback')
}
}
async function bindMenuActions() {
unlistenMenuAction = await listen<string>('menu:action', async ({ payload }) => {
switch (payload) {
case 'pick-files':
await handlePickFiles()
break
case 'export-srt':
await handleExport('srt')
break
case 'export-vtt':
await handleExport('vtt')
break
case 'export-ass':
await handleExport('ass')
break
case 'toggle-advanced':
showAdvanced.value = !showAdvanced.value
break
case 'toggle-bilingual':
bilingualOutput.value = !bilingualOutput.value
feedback.value = bilingualOutput.value ? t('app.feedback.bilingualOn') : t('app.feedback.bilingualOff')
break
case 'reset-models':
resetModelPaths()
break
case 'set-lang-zh-CN':
locale.value = 'zh-CN'
break
case 'set-lang-en':
locale.value = 'en'
break
default:
break
}
})
}
async function submitFiles(filePaths: string[]) {
if (outputMode.value === 'translate' && !hasTranslationKey.value) {
openApiKeyDialog()
return
}
pending.value = true
feedbackTone.value = 'normal'
feedback.value = ''
try {
for (const filePath of filePaths) {
await taskStore.startTask({
filePath,
sourceLang: sourceLang.value === 'auto' ? null : sourceLang.value,
targetLang: targetLang.value,
outputMode: outputMode.value,
bilingualOutput: bilingualOutput.value,
translationConfig: outputMode.value === 'translate' ? translationConfig.value : null,
whisperModelPath: whisperModelPath.value || null,
vadModelPath: vadModelPath.value || null,
})
}
feedback.value = t('app.feedback.submitted', { count: filePaths.length })
} catch (error) {
feedback.value = error instanceof Error ? error.message : t('app.feedback.submitFailed')
} finally {
pending.value = false
}
}
async function handlePickFiles() {
try {
feedbackTone.value = 'normal'
feedback.value = ''
persistTranslationConfig()
const selected = await open({
multiple: true,
directory: false,
title: t('app.pickFiles'),
filters: [
{
name: t('app.mediaFiles'),
extensions: [...MEDIA_EXTENSIONS],
},
],
})
if (!selected) return
const filePaths = Array.isArray(selected) ? selected : [selected]
if (filePaths.length === 0) return
await submitFiles(filePaths)
} catch (error) {
feedback.value = error instanceof Error ? `${t('app.feedback.dialogError')}${error.message}` : t('app.feedback.dialogErrorFallback')
}
}
function getFileExtension(filePath: string): string {
const lastDot = filePath.lastIndexOf('.')
if (lastDot === -1) return ''
return filePath.slice(lastDot + 1).toLowerCase()
}
function isMediaFile(filePath: string): boolean {
const ext = getFileExtension(filePath)
return MEDIA_EXTENSIONS.includes(ext as typeof MEDIA_EXTENSIONS[number])
}
async function initDragDrop() {
try {
dragDropUnlistenFn = await getCurrentWebviewWindow().onDragDropEvent((event) => {
if (event.payload.type === 'enter' || event.payload.type === 'over') {
isDragging.value = true
} else if (event.payload.type === 'leave') {
isDragging.value = false
} else if (event.payload.type === 'drop') {
isDragging.value = false
const mediaFiles = event.payload.paths.filter(isMediaFile)
if (mediaFiles.length === 0) {
feedback.value = t('app.feedback.noMediaFiles')
return
}
if (mediaFiles.length !== event.payload.paths.length) {
const skipped = event.payload.paths.length - mediaFiles.length
feedback.value = t('app.feedback.someFilesSkipped', { count: skipped })
}
void submitFiles(mediaFiles)
}
})
} catch (error) {
console.error('Failed to initialize drag-drop:', error)
}
}
async function handleFiles(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
const filePaths = files
.map((file) => (file as File & { path?: string }).path)
.filter((path): path is string => Boolean(path))
if (filePaths.length === 0) {
await handlePickFiles()
input.value = ''
return
}
await submitFiles(filePaths)
input.value = ''
}
function getDefaultExportPath(filePath: string, format: 'srt' | 'vtt' | 'ass') {
const separatorIndex = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'))
const directory = separatorIndex >= 0 ? filePath.slice(0, separatorIndex + 1) : ''
const fileName = separatorIndex >= 0 ? filePath.slice(separatorIndex + 1) : filePath
const stem = fileName.replace(/\.[^./\\]*$/, '') || 'subtitle'
return `${directory}${stem}.${format}`
}
async function handleExport(format: 'srt' | 'vtt' | 'ass') {
if (!selectedTask.value) return
const outputPath = await save({
title: t('app.exportSubtitles'),
defaultPath: getDefaultExportPath(selectedTask.value.filePath, format),
filters: [
{
name: format.toUpperCase(),
extensions: [format],
},
],
})
if (!outputPath) return
const output = await taskStore.exportTask(selectedTask.value.id, format, outputPath)
feedback.value = output
}
async function handleRetryTranslate(taskId: string) {
persistTranslationConfig()
if (!translationConfig.value.apiKey.trim()) {
feedbackTone.value = 'error'
feedback.value = t('app.feedback.noApiKey')
return
}
try {
await taskStore.retryTranslation(taskId, translationConfig.value)
feedbackTone.value = 'normal'
feedback.value = t('app.feedback.translationStarted')
} catch (error) {
feedback.value = error instanceof Error ? error.message : t('app.feedback.translationFailed')
}
}
async function handleTranslateFromEditor() {
if (!selectedTask.value) return
await handleRetryTranslate(selectedTask.value.id)
}
</script>
<template>
<main class="app-shell">
<Transition name="drag">
<div v-if="isDragging" class="drag-overlay">
<div class="drag-overlay-content">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<span>{{ $t('app.feedback.dropHint') }}</span>
</div>
</div>
</Transition>
<Transition name="dialog">
<div v-if="showApiKeyDialog" class="dialog-overlay" @click.self="showApiKeyDialog = false">
<section class="api-key-dialog" role="dialog" aria-modal="true" :aria-label="$t('app.llm.apiKey')">
<div class="dialog-header">
<div>
<strong>{{ $t('app.llm.apiKey') }}</strong>
<p>{{ freeApiKeyHint() }}</p>
<ol class="api-key-options">
<li>
<span class="option-icon self-host"></span>
<span>{{ locale === 'zh-CN' ? '自建大模型服务,填入兼容 OpenAI 的 API 地址和 Key。' : 'Host your own model service with an OpenAI-compatible API URL and key.' }}</span>
</li>
<li>
<span class="option-icon provider"></span>
<span>{{ locale === 'zh-CN' ? '购买成熟服务DeepSeek、ChatGPT、Gemini 都可以。' : 'Use a mature paid service such as DeepSeek, ChatGPT, or Gemini.' }}</span>
</li>
<li>
<span class="option-icon free"></span>
<span>
{{ locale === 'zh-CN' ? '推荐免费的' : 'Recommended free option:' }}
<button class="api-key-link" type="button" @click="copyFreeApiKeyUrl">GLM-4.7-Flash</button>
<span class="copy-hint" :class="{ copied: apiKeyUrlCopied }">
{{ apiKeyUrlCopied ? (locale === 'zh-CN' ? '已复制链接' : 'Link copied') : (locale === 'zh-CN' ? '点击复制申请链接' : 'click to copy link') }}
</span>
</span>
</li>
</ol>
</div>
<button class="dialog-close" type="button" aria-label="Close" @click="showApiKeyDialog = false">×</button>
</div>
<div class="dialog-actions">
<button class="button" type="button" @click="acknowledgeApiKeyDialog">
{{ locale === 'zh-CN' ? '明白了,先转原文' : 'Got it, use source mode' }}
</button>
</div>
</section>
</div>
</Transition>
<section class="topbar panel">
<div class="toolbar-main">
<div class="toolbar-title">
<strong>CrossSubtitle</strong>
<span class="credit-line">
by <button class="author-link" type="button" @click="copyAuthorUrl">kuraa</button>
<span v-if="authorUrlCopied" class="author-copy-hint">{{ $t('app.feedback.copied') }}</span>
</span>
</div>
<div class="toolbar-actions">
<button class="button secondary toggle-button" type="button" @click="showAdvanced = !showAdvanced">
{{ showAdvanced ? $t('app.collapse') : $t('app.settings') }}
</button>
<button class="button" type="button" :disabled="pending" @click="handlePickFiles">
{{ pending ? $t('app.submitting') : $t('app.addTask') }}
</button>
</div>
</div>
<div class="workspace-toolbar">
<div class="toolbar-group">
<div class="form-grid">
<label class="field">
<span>{{ $t('app.mode') }}</span>
<select v-model="outputMode">
<option value="source">{{ $t('app.source') }}</option>
<option value="translate">{{ $t('app.translate') }}</option>
</select>
</label>
<label v-if="outputMode === 'translate'" class="field">
<span>{{ $t('app.targetLang') }}</span>
<select v-model="targetLang">
<option value="zh">{{ $t('app.chinese') }}</option>
<option value="en">{{ $t('app.english') }}</option>
</select>
</label>
<label class="field">
<span>{{ $t('app.sourceLang') }}</span>
<select v-model="sourceLang">
<option value="auto">{{ $t('app.autoDetect') }}</option>
<option
v-for="option in SOURCE_LANGUAGE_OPTIONS"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</label>
<label class="check desktop-check">
<input v-model="bilingualOutput" type="checkbox" />
<span>{{ $t('app.bilingualExport') }}</span>
</label>
</div>
</div>
<p v-if="feedback" class="feedback status-text" :class="{ error: feedbackTone === 'error' }">{{ feedback }}</p>
</div>
<div v-if="showAdvanced" class="advanced-shell">
<span class="group-title">{{ $t('app.advanced') }}</span>
<div class="advanced-grid">
<template v-if="outputMode === 'translate'">
<label class="field wide">
<span>{{ $t('app.llm.apiBase') }}</span>
<input v-model="translationConfig.apiBase" placeholder="https://api.openai.com/v1" />
</label>
<label class="field wide">
<span>{{ $t('app.llm.apiKey') }}</span>
<input v-model="translationConfig.apiKey" type="password" placeholder="sk-..." />
</label>
<label class="field">
<span>{{ $t('app.llm.model') }}</span>
<input v-model="translationConfig.model" placeholder="GLM-4-Flash-250414" />
</label>
<label class="field">
<span>{{ $t('app.llm.batchSize') }}</span>
<input v-model.number="translationConfig.batchSize" type="number" min="10" max="15" />
</label>
<label class="field">
<span>{{ $t('app.llm.contextSize') }}</span>
<input v-model.number="translationConfig.contextSize" type="number" min="0" max="5" />
</label>
</template>
<label class="field wide">
<span>{{ $t('app.model.whisper') }}</span>
<input
v-model="whisperModelPath"
:placeholder="defaultModelPaths?.whisperModelPath || $t('app.model.defaultWhisper')"
/>
</label>
<label class="field wide">
<span>{{ $t('app.model.vad') }}</span>
<input
v-model="vadModelPath"
:placeholder="defaultModelPaths?.vadModelPath || $t('app.model.defaultVad')"
/>
</label>
</div>
</div>
</section>
<section class="content-grid">
<TaskQueue
:tasks="taskStore.tasks"
:selected-task-id="taskStore.selectedTaskId"
@select="taskStore.selectTask"
@retry="taskStore.retryTask"
@retry-translate="handleRetryTranslate"
@delete="taskStore.deleteTask"
/>
<SubtitleEditor
:task="selectedTask"
:logs="taskStore.selectedTaskLogs"
@save="taskStore.updateSegment"
@translate="handleTranslateFromEditor"
@export="handleExport"
/>
</section>
</main>
</template>