573 lines
20 KiB
Vue
573 lines
20 KiB
Vue
<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>
|