新增i18n

This commit is contained in:
kura 2026-04-30 17:56:20 +08:00
parent 42d233c0d7
commit bba78d1dca
10 changed files with 319 additions and 64 deletions

67
package-lock.json generated
View File

@ -11,7 +11,8 @@
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"pinia": "^2.1.7",
"vue": "^3.5.13"
"vue": "^3.5.13",
"vue-i18n": "^9.14.4"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.4",
@ -525,6 +526,50 @@
"node": ">=18"
}
},
"node_modules/@intlify/core-base": {
"version": "9.14.4",
"resolved": "https://registry.npmmirror.com/@intlify/core-base/-/core-base-9.14.4.tgz",
"integrity": "sha512-vtZCt7NqWhKEtHa3SD/322DlgP5uR9MqWxnE0y8Q0tjDs9H5Lxhss+b5wv8rmuXRoHKLESNgw9d+EN9ybBbj9g==",
"license": "MIT",
"dependencies": {
"@intlify/message-compiler": "9.14.4",
"@intlify/shared": "9.14.4"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/message-compiler": {
"version": "9.14.4",
"resolved": "https://registry.npmmirror.com/@intlify/message-compiler/-/message-compiler-9.14.4.tgz",
"integrity": "sha512-vcyCLiVRN628U38c3PbahrhbbXrckrM9zpy0KZVlDk2Z0OnGwv8uQNNXP3twwGtfLsCf4gu3ci6FMIZnPaqZsw==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "9.14.4",
"source-map-js": "^1.0.2"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/shared": {
"version": "9.14.4",
"resolved": "https://registry.npmmirror.com/@intlify/shared/-/shared-9.14.4.tgz",
"integrity": "sha512-P9zv6i1WvMc9qDBWvIgKkymjY2ptIiQ065PjDv7z7fDqH3J/HBRBN5IoiR46r/ujRcU7hCuSIZWvCAFCyuOYZA==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -2848,6 +2893,26 @@
}
}
},
"node_modules/vue-i18n": {
"version": "9.14.4",
"resolved": "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.4.tgz",
"integrity": "sha512-B934C8yUyWLT0EMud3DySrwSUJI7ZNiWYsEEz2gknTthqKiG4dzWE/WSa8AzCuSQzwBEv4HtG1jZDhgzPfWSKQ==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "9.14.4",
"@intlify/shared": "9.14.4",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-tsc": {
"version": "2.2.12",
"resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz",

View File

@ -18,7 +18,8 @@
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"pinia": "^2.1.7",
"vue": "^3.5.13"
"vue": "^3.5.13",
"vue-i18n": "^9.14.4"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.4",

View File

@ -146,6 +146,11 @@ fn configure_macos_menu(app: &AppHandle) -> tauri::Result<()> {
.item(&MenuItemBuilder::with_id("reset_models", "恢复默认模型路径").build(app)?)
.build()?;
let language_menu = SubmenuBuilder::new(app, "语言")
.item(&MenuItemBuilder::with_id("set_lang_zh_cn", "中文").build(app)?)
.item(&MenuItemBuilder::with_id("set_lang_en", "English").build(app)?)
.build()?;
let window_menu = SubmenuBuilder::new(app, "窗口")
.item(&PredefinedMenuItem::minimize(app, None)?)
.item(&PredefinedMenuItem::maximize(app, None)?)
@ -158,6 +163,7 @@ fn configure_macos_menu(app: &AppHandle) -> tauri::Result<()> {
.item(&file_menu)
.item(&edit_menu)
.item(&settings_menu)
.item(&language_menu)
.item(&window_menu)
.build()?;
@ -171,6 +177,8 @@ fn configure_macos_menu(app: &AppHandle) -> tauri::Result<()> {
"toggle_advanced" => Some("toggle-advanced"),
"toggle_bilingual" => Some("toggle-bilingual"),
"reset_models" => Some("reset-models"),
"set_lang_zh_cn" => Some("set-lang-zh-CN"),
"set_lang_en" => Some("set-lang-en"),
_ => None,
};

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { invoke } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
@ -8,10 +9,8 @@ import SubtitleEditor from './components/SubtitleEditor.vue'
import { useTaskStore } from './stores/tasks'
import type { DefaultModelPaths, OutputMode, TargetLanguage, TranslationConfig } from './lib/types'
const DEFAULT_WHISPER_MODEL = '应用内置 Whisper 模型'
const DEFAULT_VAD_MODEL = '应用内置 VAD 模型'
const { t, locale } = useI18n()
const SOURCE_LANGUAGE_OPTIONS = [
{ value: 'auto', label: '自动识别' },
{ value: 'zh', label: '中文' },
{ value: 'en', label: 'English' },
{ value: 'ja', label: '日本語' },
@ -31,7 +30,7 @@ const SOURCE_LANGUAGE_OPTIONS = [
{ value: 'uk', label: 'Українська' },
{ value: 'pl', label: 'Polski' },
{ value: 'nl', label: 'Nederlands' },
] as const
]
const MEDIA_EXTENSIONS = [
'mp3',
'wav',
@ -90,6 +89,10 @@ onUnmounted(() => {
}
})
watch(locale, (newLocale) => {
localStorage.setItem('locale', newLocale)
})
function persistTranslationConfig() {
localStorage.setItem('llm.apiBase', translationConfig.value.apiBase)
localStorage.setItem('llm.apiKey', translationConfig.value.apiKey)
@ -101,7 +104,7 @@ function persistTranslationConfig() {
function resetModelPaths() {
whisperModelPath.value = defaultModelPaths.value?.whisperModelPath ?? ''
vadModelPath.value = defaultModelPaths.value?.vadModelPath ?? ''
feedback.value = '已恢复默认模型路径'
feedback.value = t('app.feedback.restoredDefaults')
}
async function loadDefaultModelPaths() {
@ -116,7 +119,7 @@ async function loadDefaultModelPaths() {
}
} catch (error) {
feedback.value =
error instanceof Error ? `读取默认模型路径失败:${error.message}` : '读取默认模型路径失败'
error instanceof Error ? `${t('app.feedback.loadModelError')}${error.message}` : t('app.feedback.loadModelErrorFallback')
}
}
@ -140,11 +143,17 @@ async function bindMenuActions() {
break
case 'toggle-bilingual':
bilingualOutput.value = !bilingualOutput.value
feedback.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
}
@ -172,12 +181,12 @@ async function submitFiles(filePaths: string[]) {
})
}
if (fallbackToSource) {
feedback.value = '未填写 LLM API Key已自动回退为原文转录'
feedback.value = t('app.feedback.fallbackSource')
} else {
feedback.value = `已提交 ${filePaths.length} 个任务`
feedback.value = t('app.feedback.submitted', { count: filePaths.length })
}
} catch (error) {
feedback.value = error instanceof Error ? error.message : '任务提交失败'
feedback.value = error instanceof Error ? error.message : t('app.feedback.submitFailed')
} finally {
pending.value = false
}
@ -190,10 +199,10 @@ async function handlePickFiles() {
const selected = await open({
multiple: true,
directory: false,
title: '选择音视频文件',
title: t('app.pickFiles'),
filters: [
{
name: '媒体文件',
name: t('app.mediaFiles'),
extensions: [...MEDIA_EXTENSIONS],
},
],
@ -206,7 +215,7 @@ async function handlePickFiles() {
await submitFiles(filePaths)
} catch (error) {
feedback.value = error instanceof Error ? `打开文件对话框失败:${error.message}` : '打开文件对话框失败'
feedback.value = error instanceof Error ? `${t('app.feedback.dialogError')}${error.message}` : t('app.feedback.dialogErrorFallback')
}
}
@ -230,7 +239,7 @@ async function handleFiles(event: Event) {
async function handleExport(format: 'srt' | 'vtt' | 'ass') {
if (!selectedTask.value) return
const output = await taskStore.exportTask(selectedTask.value.id, format)
feedback.value = `已导出到 ${output}`
feedback.value = output
}
</script>
@ -246,10 +255,10 @@ async function handleExport(format: 'srt' | 'vtt' | 'ass') {
</div>
<div class="toolbar-actions">
<button class="button secondary toggle-button" type="button" @click="showAdvanced = !showAdvanced">
{{ showAdvanced ? '收起' : '设置' }}
{{ showAdvanced ? $t('app.collapse') : $t('app.settings') }}
</button>
<button class="button" type="button" :disabled="pending" @click="handlePickFiles">
{{ pending ? '提交中...' : '添加任务' }}
{{ pending ? $t('app.submitting') : $t('app.addTask') }}
</button>
</div>
</div>
@ -258,22 +267,23 @@ async function handleExport(format: 'srt' | 'vtt' | 'ass') {
<div class="toolbar-group">
<div class="form-grid">
<label class="field">
<span>模式</span>
<span>{{ $t('app.mode') }}</span>
<select v-model="outputMode">
<option value="source">原文</option>
<option value="translate">翻译</option>
<option value="source">{{ $t('app.source') }}</option>
<option value="translate">{{ $t('app.translate') }}</option>
</select>
</label>
<label v-if="outputMode === 'translate'" class="field">
<span>目标语言</span>
<span>{{ $t('app.targetLang') }}</span>
<select v-model="targetLang">
<option value="zh">中文</option>
<option value="en">英文</option>
<option value="zh">{{ $t('app.chinese') }}</option>
<option value="en">{{ $t('app.english') }}</option>
</select>
</label>
<label class="field">
<span>源语言</span>
<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"
@ -285,7 +295,7 @@ async function handleExport(format: 'srt' | 'vtt' | 'ass') {
</label>
<label class="check desktop-check">
<input v-model="bilingualOutput" type="checkbox" />
<span>双语导出</span>
<span>{{ $t('app.bilingualExport') }}</span>
</label>
</div>
</div>
@ -294,42 +304,42 @@ async function handleExport(format: 'srt' | 'vtt' | 'ass') {
</div>
<div v-if="showAdvanced" class="advanced-shell">
<span class="group-title">高级设置</span>
<span class="group-title">{{ $t('app.advanced') }}</span>
<div class="advanced-grid">
<template v-if="outputMode === 'translate'">
<label class="field wide">
<span>LLM API Base</span>
<span>{{ $t('app.llm.apiBase') }}</span>
<input v-model="translationConfig.apiBase" placeholder="https://api.openai.com/v1" />
</label>
<label class="field wide">
<span>LLM API Key</span>
<span>{{ $t('app.llm.apiKey') }}</span>
<input v-model="translationConfig.apiKey" type="password" placeholder="sk-..." />
</label>
<label class="field">
<span>LLM Model</span>
<span>{{ $t('app.llm.model') }}</span>
<input v-model="translationConfig.model" placeholder="GLM-4-Flash-250414" />
</label>
<label class="field">
<span>批大小</span>
<span>{{ $t('app.llm.batchSize') }}</span>
<input v-model.number="translationConfig.batchSize" type="number" min="10" max="15" />
</label>
<label class="field">
<span>上下文</span>
<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>Whisper 模型</span>
<span>{{ $t('app.model.whisper') }}</span>
<input
v-model="whisperModelPath"
:placeholder="defaultModelPaths?.whisperModelPath || DEFAULT_WHISPER_MODEL"
:placeholder="defaultModelPaths?.whisperModelPath || $t('app.model.defaultWhisper')"
/>
</label>
<label class="field wide">
<span>VAD 模型</span>
<span>{{ $t('app.model.vad') }}</span>
<input
v-model="vadModelPath"
:placeholder="defaultModelPaths?.vadModelPath || DEFAULT_VAD_MODEL"
:placeholder="defaultModelPaths?.vadModelPath || $t('app.model.defaultVad')"
/>
</label>
</div>

View File

@ -48,9 +48,9 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
<section class="panel workspace-panel">
<div class="workspace-header">
<div>
<strong>{{ task?.fileName ?? '字幕工作区' }}</strong>
<strong>{{ task?.fileName ?? $t('editor.title') }}</strong>
<p class="panel-subtitle">
{{ task ? `${segments.length} 条片段` : '选择左侧任务后开始查看' }}
{{ task ? $t('editor.segments', { count: segments.length }) : $t('editor.selectTask') }}
</p>
</div>
<div v-if="task" class="export-actions">
@ -61,14 +61,14 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
</div>
<div v-if="!task" class="empty-state">
<p>选择任务后显示字幕</p>
<p style="margin-top: 6px; font-size: 11px;">点击左侧任务列表中的任务开始查看</p>
<p>{{ $t('editor.empty') }}</p>
<p style="margin-top: 6px; font-size: 11px;">{{ $t('editor.emptyHint') }}</p>
</div>
<div v-else-if="segments.length === 0" class="empty-state">
<template v-if="isProcessing">正在处理中请稍候...</template>
<template v-else-if="task?.status === 'failed'">任务处理失败无法生成字幕</template>
<template v-else>暂无字幕片段</template>
<template v-if="isProcessing">{{ $t('editor.processing') }}</template>
<template v-else-if="task?.status === 'failed'">{{ $t('editor.failed') }}</template>
<template v-else>{{ $t('editor.noSegments') }}</template>
</div>
<div v-else class="segment-list">
@ -81,11 +81,11 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
<span>{{ formatTime(segment.start) }} - {{ formatTime(segment.end) }}</span>
<span class="segment-id">{{ segment.id }}</span>
</div>
<p class="source-text">{{ segment.sourceText || '等待识别结果...' }}</p>
<p class="source-text">{{ segment.sourceText || $t('editor.waiting') }}</p>
<textarea
class="editor-input"
:value="segment.translatedText ?? ''"
:placeholder="task.outputMode === 'translate' ? '译文' : '原文'"
:placeholder="task.outputMode === 'translate' ? $t('editor.translation') : $t('editor.source')"
:disabled="task.outputMode === 'source'"
@change="updateTranslatedText(segment, ($event.target as HTMLTextAreaElement).value)"
/>
@ -94,13 +94,13 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
<div class="log-drawer" :class="{ expanded: logsExpanded }">
<button class="log-toggle" type="button" @click="logsExpanded = !logsExpanded">
<span>日志</span>
<span>{{ $t('editor.logs') }}</span>
<span class="subtle">{{ logs.length }}</span>
<span class="log-chevron">{{ logsExpanded ? '' : '+' }}</span>
</button>
<div v-if="logsExpanded" class="log-panel">
<div v-if="logs.length === 0" class="empty-state">
暂无日志
{{ $t('editor.noLogs') }}
</div>
<div v-else class="log-list">
<pre v-for="(line, index) in logs" :key="`${index}-${line}`" class="log-line">{{ line }}</pre>

View File

@ -10,30 +10,20 @@ const emit = defineEmits<{
select: [taskId: string]
retry: [taskId: string]
}>()
const statusLabel: Record<SubtitleTask['status'], string> = {
queued: '排队中',
extracting: '抽取',
vad_processing: 'VAD',
transcribing: '识别',
translating: '翻译',
completed: '完成',
failed: '失败',
}
</script>
<template>
<aside class="panel sidebar-panel">
<div class="panel-title">
<div>
<strong>任务</strong>
<p class="panel-subtitle">选择任务查看字幕</p>
<strong>{{ $t('taskQueue.title') }}</strong>
<p class="panel-subtitle">{{ $t('taskQueue.subtitle') }}</p>
</div>
<span class="badge">{{ tasks.length }}</span>
</div>
<div v-if="tasks.length === 0" class="empty-state">
暂无任务
{{ $t('taskQueue.empty') }}
</div>
<div v-else class="list-stack">
@ -53,14 +43,14 @@ const statusLabel: Record<SubtitleTask['status'], string> = {
<span
class="subtle"
:class="{ 'status-active': task.status !== 'completed' && task.status !== 'failed' && task.status !== 'queued' }"
>{{ statusLabel[task.status] }}</span>
>{{ $t(`taskQueue.status.${task.status}`) }}</span>
</div>
<div class="progress">
<div class="progress-bar" :style="{ width: `${task.progress}%` }" />
</div>
<div v-if="task.status === 'failed'" class="failed-footer">
<p class="error-text">{{ task.error }}</p>
<button class="retry-button" type="button" @click.stop="emit('retry', task.id)">重试</button>
<button class="retry-button" type="button" @click.stop="emit('retry', task.id)">{{ $t('taskQueue.retry') }}</button>
</div>
</button>
</div>

26
src/i18n.ts Normal file
View File

@ -0,0 +1,26 @@
import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN'
import en from './locales/en'
function detectSystemLocale(): string {
const saved = localStorage.getItem('locale')
if (saved) return saved
const lang = navigator.language
if (lang.startsWith('zh')) return 'zh-CN'
return 'en'
}
const defaultLocale = detectSystemLocale()
export type Locale = 'zh-CN' | 'en'
export const i18n = createI18n({
legacy: false,
locale: defaultLocale,
fallbackLocale: 'en',
messages: {
'zh-CN': zhCN,
en,
},
})

77
src/locales/en.ts Normal file
View File

@ -0,0 +1,77 @@
export default {
app: {
title: 'CrossSubtitle',
credit: 'by {author}',
collapse: 'Collapse',
settings: 'Settings',
submitting: 'Submitting...',
addTask: 'Add Task',
mode: 'Mode',
source: 'Source',
translate: 'Translate',
targetLang: 'Target Language',
chinese: 'Chinese',
english: 'English',
sourceLang: 'Source Language',
bilingualExport: 'Bilingual Export',
autoDetect: 'Auto Detect',
advanced: 'Advanced Settings',
feedback: {
restoredDefaults: 'Restored default model paths',
loadModelError: 'Failed to load default model paths: {message}',
loadModelErrorFallback: 'Failed to load default model paths',
bilingualOn: 'Bilingual export enabled',
bilingualOff: 'Bilingual export disabled',
fallbackSource: 'LLM API Key not set, falling back to source transcription',
submitted: 'Submitted {count} tasks',
submitFailed: 'Task submission failed',
dialogError: 'Failed to open file dialog: {message}',
dialogErrorFallback: 'Failed to open file dialog',
},
llm: {
apiBase: 'LLM API Base',
apiKey: 'LLM API Key',
model: 'LLM Model',
batchSize: 'Batch Size',
contextSize: 'Context Size',
},
model: {
whisper: 'Whisper Model',
vad: 'VAD Model',
defaultWhisper: 'Built-in Whisper Model',
defaultVad: 'Built-in VAD Model',
},
pickFiles: 'Select Media Files',
mediaFiles: 'Media Files',
},
taskQueue: {
title: 'Tasks',
subtitle: 'Select a task to view subtitles',
empty: 'No tasks',
retry: 'Retry',
status: {
queued: 'Queued',
extracting: 'Extracting',
vad_processing: 'VAD',
transcribing: 'Transcribing',
translating: 'Translating',
completed: 'Completed',
failed: 'Failed',
},
},
editor: {
title: 'Workspace',
segments: '{count} segments',
selectTask: 'Select a task from the left to start',
empty: 'Select a task to view subtitles',
emptyHint: 'Click a task in the list to start viewing',
processing: 'Processing, please wait...',
failed: 'Task failed, unable to generate subtitles',
noSegments: 'No subtitle segments',
waiting: 'Waiting for transcription...',
translation: 'Translation',
source: 'Source',
logs: 'Logs',
noLogs: 'No logs',
},
}

77
src/locales/zh-CN.ts Normal file
View File

@ -0,0 +1,77 @@
export default {
app: {
title: 'CrossSubtitle',
credit: 'by {author}',
collapse: '收起',
settings: '设置',
submitting: '提交中...',
addTask: '添加任务',
mode: '模式',
source: '原文',
translate: '翻译',
targetLang: '目标语言',
chinese: '中文',
english: '英文',
sourceLang: '源语言',
bilingualExport: '双语导出',
autoDetect: '自动识别',
advanced: '高级设置',
feedback: {
restoredDefaults: '已恢复默认模型路径',
loadModelError: '读取默认模型路径失败:{message}',
loadModelErrorFallback: '读取默认模型路径失败',
bilingualOn: '已开启双语导出',
bilingualOff: '已关闭双语导出',
fallbackSource: '未填写 LLM API Key已自动回退为原文转录',
submitted: '已提交 {count} 个任务',
submitFailed: '任务提交失败',
dialogError: '打开文件对话框失败:{message}',
dialogErrorFallback: '打开文件对话框失败',
},
llm: {
apiBase: 'LLM API Base',
apiKey: 'LLM API Key',
model: 'LLM Model',
batchSize: '批大小',
contextSize: '上下文',
},
model: {
whisper: 'Whisper 模型',
vad: 'VAD 模型',
defaultWhisper: '应用内置 Whisper 模型',
defaultVad: '应用内置 VAD 模型',
},
pickFiles: '选择音视频文件',
mediaFiles: '媒体文件',
},
taskQueue: {
title: '任务',
subtitle: '选择任务查看字幕',
empty: '暂无任务',
retry: '重试',
status: {
queued: '排队中',
extracting: '抽取',
vad_processing: 'VAD',
transcribing: '识别',
translating: '翻译',
completed: '完成',
failed: '失败',
},
},
editor: {
title: '字幕工作区',
segments: '{count} 条片段',
selectTask: '选择左侧任务后开始查看',
empty: '选择任务后显示字幕',
emptyHint: '点击左侧任务列表中的任务开始查看',
processing: '正在处理中,请稍候...',
failed: '任务处理失败,无法生成字幕',
noSegments: '暂无字幕片段',
waiting: '等待识别结果...',
translation: '译文',
source: '原文',
logs: '日志',
noLogs: '暂无日志',
},
}

View File

@ -1,6 +1,7 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { i18n } from './i18n'
import './style.css'
createApp(App).use(createPinia()).mount('#app')
createApp(App).use(createPinia()).use(i18n).mount('#app')