重构i18n,支持全局多语言切换

This commit is contained in:
YILS
2025-08-23 17:35:20 +08:00
parent 6c2733b8af
commit 8ea9d06efa
28 changed files with 656 additions and 351 deletions

View File

@@ -1,200 +0,0 @@
import { createI18n } from 'vue-i18n'
const messages = {
en: {
app: {
name: 'AI Short Video Factory',
},
prompt: { label: 'Prompt' },
actions: { generate: 'Generate', stop: 'Stop', config: 'Configure', refreshAssets: 'Refresh Assets' },
llm: {
configTitle: 'Configure LLM API',
modelName: 'Model Name',
apiUrl: 'API URL',
apiKey: 'API Key',
compatibleNote: 'Compatible with any OpenAI-compatible API',
connectSuccess: 'LLM connected successfully',
connectFailedPrefix: 'LLM connection failed, please check your configuration',
},
common: { close: 'Close', test: 'Test', save: 'Save', select: 'Select', noData: 'No data' },
output: { label: 'Output Text (editable)' },
errors: {
promptRequired: 'Prompt cannot be empty',
generateFailedPrefix: 'Generation failed, please check LLM configuration',
outputFileNameRequired: 'Please set an output file name first',
outputPathRequired: 'Please set an export folder first',
outputSizeRequired: 'Please set output resolution (width x height) first',
bgmListFailed: 'Failed to load BGM list. Please check the folder exists',
ttsFailedCorrupt: 'TTS failed: audio file is corrupted',
ttsZeroDuration: 'TTS duration is 0s. Check TTS config and network connectivity',
renderFailedPrefix: 'Video rendering failed. Please check all configurations',
assetsDurationInsufficient: 'Total assets duration is insufficient',
edgeTtsListFailed: 'Failed to fetch Edge TTS voice list. Please check your network',
ttsConfigInvalid: 'TTS configuration is invalid',
ttsSynthesisFailed: 'TTS synthesis failed',
},
success: { renderSuccess: 'Video rendered successfully' },
info: { batchNext: 'Start next render', renderCanceled: 'Video rendering canceled' },
empty: {
noContent: 'No content yet',
hintSelectFolder: 'Please choose a folder above with enough storyboard assets',
},
videoManage: {
assetsFolderLabel: 'Storyboard assets folder',
noMp4InFolder: 'No MP4 video files found in the selected folder',
emptyFolder: 'The selected folder is empty',
readSuccess: 'Assets loaded successfully',
readFailed: 'Failed to read assets. Please check if the folder exists',
},
dialogs: {
selectAssetsFolderTitle: 'Select storyboard assets folder',
selectOutputFolderTitle: 'Select video export folder',
selectBgmFolderTitle: 'Select background music folder',
renderConfigTitle: 'Configure render options',
},
render: {
status: {
idle: 'Idle, ready to render',
generatingText: 'Generating script with AI LLM',
synthesizingSpeech: 'Synthesizing speech with TTS',
segmentingVideo: 'Processing video segments',
rendering: 'Rendering video',
success: 'Rendered successfully, you can start the next one',
failed: 'Render failed, please try again',
},
startRender: 'Start Rendering',
stopRender: 'Stop Rendering',
autoBatch: 'Auto batch render',
bgmFolderLabel: 'Background music folder (.mp3, pick randomly)',
output: {
width: 'Output width',
height: 'Output height',
fileName: 'Output file name',
format: 'Output format',
folder: 'Output folder',
},
},
tts: {
language: 'Language',
gender: 'Gender',
voice: 'Voice',
speed: 'Speed',
tryText: 'Try-listen text',
tryListen: 'Try listen',
selectLanguageGenderFirst: 'Please select language and gender first',
selectVoiceWarning: 'Please select a voice',
tryTextEmptyWarning: 'Try-listen text cannot be empty',
playTryAudio: 'Playing try-listen audio',
trySynthesisFailedNetwork: 'Failed to synthesize try-listen audio. Please check network',
genderMale: 'Male',
genderFemale: 'Female',
speedSlow: 'Slow',
speedMedium: 'Medium',
speedFast: 'Fast',
},
footer: { poweredBy: 'Powered by YILS (Blog: https://yils.blog)' },
},
'zh-CN': {
app: { name: 'AI Short Video Factory - 短视频工厂' },
prompt: { label: '提示词' },
actions: { generate: '生成', stop: '停止', config: '配置', refreshAssets: '刷新素材库' },
llm: {
configTitle: '配置大语言模型接口',
modelName: '模型名称',
apiUrl: 'API 地址',
apiKey: 'API Key',
compatibleNote: '兼容任意 OpenAI 标准接口',
connectSuccess: '大模型连接成功',
connectFailedPrefix: '大模型连接失败,请检查配置是否正确',
},
common: { close: '关闭', test: '测试', save: '保存', select: '选择', noData: '无数据' },
output: { label: '输出文案(可编辑)' },
errors: {
promptRequired: '提示词不能为空',
generateFailedPrefix: '生成失败,请检查大模型配置是否正确',
outputFileNameRequired: '请先配置导出文件名',
outputPathRequired: '请先配置导出文件夹',
outputSizeRequired: '请先配置导出分辨率(宽高)',
bgmListFailed: '获取背景音乐列表失败,请检查文件夹是否存在',
ttsFailedCorrupt: '语音合成失败,音频文件损坏',
ttsZeroDuration: '语音时长为0秒检查TTS语音合成配置及网络连接是否正常',
renderFailedPrefix: '视频合成失败,请检查各项配置是否正确',
assetsDurationInsufficient: '素材总时长不足',
edgeTtsListFailed: '获取EdgeTTS语音列表失败请检查网络',
ttsConfigInvalid: 'TTS语音合成配置无效',
ttsSynthesisFailed: '语音合成失败',
},
success: { renderSuccess: '视频合成成功' },
info: { batchNext: '开始合成下一个', renderCanceled: '视频合成已终止' },
empty: {
noContent: '暂无内容',
hintSelectFolder: '从上面选择一个包含足够分镜素材的文件夹',
},
videoManage: {
assetsFolderLabel: '分镜视频素材文件夹',
noMp4InFolder: '选择的文件夹中不包含MP4视频文件',
emptyFolder: '选择的文件夹为空',
readSuccess: '素材读取成功',
readFailed: '素材读取失败,请检查文件夹是否存在',
},
dialogs: {
selectAssetsFolderTitle: '选择分镜素材文件夹',
selectOutputFolderTitle: '选择视频导出文件夹',
selectBgmFolderTitle: '选择背景音乐文件夹',
renderConfigTitle: '配置合成选项',
},
render: {
status: {
idle: '空闲,可以开始合成',
generatingText: '正在使用 AI 大模型生成文案',
synthesizingSpeech: '正在使用 TTS 合成语音',
segmentingVideo: '正在处理分镜素材',
rendering: '正在渲染视频',
success: '渲染成功,可以开始下一个',
failed: '渲染失败,请重新尝试',
},
startRender: '开始合成',
stopRender: '停止合成',
autoBatch: '自动批量合成',
bgmFolderLabel: '背景音乐文件夹(.mp3格式从中随机选取',
output: {
width: '导出视频宽度',
height: '导出视频高度',
fileName: '导出文件名',
format: '导出格式',
folder: '导出文件夹',
},
},
tts: {
language: '语言',
gender: '性别',
voice: '声音',
speed: '语速',
tryText: '试听文本',
tryListen: '试听',
selectLanguageGenderFirst: '请先选择语言和性别',
selectVoiceWarning: '请选择一个声音',
tryTextEmptyWarning: '试听文本不能为空',
playTryAudio: '播放试听语音',
trySynthesisFailedNetwork: '试听语音合成失败,请检查网络',
genderMale: '男性',
genderFemale: '女性',
speedSlow: '慢',
speedMedium: '中',
speedFast: '快',
},
footer: { poweredBy: 'Powered by YILS博客地址https://yils.blog' },
},
}
const stored = (typeof localStorage !== 'undefined' && localStorage.getItem('locale')) || undefined
const locale = stored || 'en'
const i18n = createI18n({
legacy: false,
locale,
fallbackLocale: 'en',
messages,
})
export default i18n

View File

@@ -5,6 +5,32 @@
<span>{{ t('app.name') }}</span>
</div>
<div class="window-control-bar">
<div class="window-no-drag">
<v-menu location="bottom right">
<template v-slot:activator="{ props }">
<div class="control-btn control-btn-translate" v-bind="props">
<v-icon icon="mdi-translate" size="small" />
</div>
</template>
<v-list
class="p-2 space-y-1"
activatable
:activated="i18next.language"
@update:activated="handleChangeLanguage"
>
<v-list-item
v-for="(item, index) in i18nLanguages"
:key="index"
:value="item.code"
color="primary"
density="compact"
rounded
>
<v-list-item-title>{{ item.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
<div class="control-btn control-btn-min" @click="handleMin">
<v-icon icon="mdi-window-minimize" size="small" />
</div>
@@ -23,11 +49,24 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useTranslation } from 'i18next-vue'
import { i18nLanguages } from '~/electron/i18n/common-options'
const { i18next, t } = useTranslation()
// const lang = ref(i18next.language)
// console.log('i18next.language', i18next.language)
document.title = t('app.name')
const route = useRoute()
const windowIsMaxed = ref(false)
const { t } = useI18n()
const handleChangeLanguage = (lng: unknown) => {
console.log('handleChangeLanguage', lng)
if ((lng as string[])[0]) {
window.i18n.changeLanguage((lng as string[])[0])
}
}
window.addEventListener('resize', async () => {
windowIsMaxed.value = await window.electron.isWinMaxed()

27
src/lib/i18n.ts Normal file
View File

@@ -0,0 +1,27 @@
import { useAppStore } from '@/store'
import i18next from 'i18next'
import Backend from 'i18next-http-backend'
import { toRaw } from 'vue'
import { i18nCommonOptions } from '~/electron/i18n/common-options'
const i18nInitialized = async () => {
const appStore = useAppStore()
if (appStore.locale) {
await window.i18n.changeLanguage(toRaw(appStore.locale))
} else {
const systemLocale = await window.i18n.getLanguage()
appStore.updateLocale(systemLocale)
}
return i18next.use(Backend).init({
// debug: true,
...i18nCommonOptions,
lng: appStore.locale,
backend: {
loadPath: 'file:///' + (await window.i18n.getLocalesPath()),
},
})
}
export const i18n = i18next
export default i18nInitialized

View File

@@ -12,11 +12,14 @@ import 'virtual:uno.css'
import './assets/base.scss'
import { createApp } from 'vue'
import i18n from './i18n'
import router from './router/index.ts'
import store from './store/index.ts'
import store, { useAppStore } from './store/index.ts'
import App from './App.vue'
import i18next from 'i18next'
import I18NextVue from 'i18next-vue'
import i18nInitialized from './lib/i18n.ts'
const vuetify = createVuetify({
components,
directives,
@@ -29,30 +32,26 @@ const vuetify = createVuetify({
},
})
// Set initial document title from i18n
document.title = i18n.global.t('app.name') as string
const app = createApp(App)
app.use(vuetify)
app.use(Toast, { position: 'bottom-left', pauseOnFocusLoss: false } as PluginOptions)
app.use(i18n)
app.use(router)
app.use(store)
app.mount('#app').$nextTick(() => {
// Use contextBridge
window.ipcRenderer.on('main-process-message', (_event, message) => {
console.log(message)
})
// 初始化并应用国际化
i18nInitialized().then(() => {
app.use(I18NextVue, { i18next })
app.mount('#app').$nextTick(() => {
// 测试消息
window.ipcRenderer.on('main-process-message', (_event, message) => {
console.log(message)
})
// Language switching from Electron menu
window.ipcRenderer.on('set-locale', (_event, locale: string) => {
// @ts-ignore
i18n.global.locale.value = locale
try {
localStorage.setItem('locale', locale)
} catch {}
document.title = i18n.global.t('app.name') as string
// 监听主进程切换语言
window.ipcRenderer.on('i18n-changeLanguage', (_event, lng) => {
i18next.changeLanguage(lng)
useAppStore().updateLocale(lng)
})
})
})

View File

@@ -15,6 +15,12 @@ export enum RenderStatus {
export const useAppStore = defineStore(
'app',
() => {
// 国际化区域设置
const locale = ref('')
const updateLocale = (newLocale: string) => {
locale.value = newLocale
}
// 大模型文案生成
const prompt = ref('')
const llmConfig = ref({
@@ -72,6 +78,9 @@ export const useAppStore = defineStore(
}
return {
locale,
updateLocale,
prompt,
llmConfig,
updateLLMConfig,

View File

@@ -34,7 +34,9 @@
<v-dialog v-model="configDialogShow" max-width="600" persistent>
<template v-slot:activator="{ props: activatorProps }">
<v-btn v-bind="activatorProps" :disabled="disabled"> {{ t('actions.config') }} </v-btn>
<v-btn v-bind="activatorProps" :disabled="disabled">
{{ t('actions.config') }}
</v-btn>
</template>
<v-card prepend-icon="mdi-text-box-edit-outline" :title="t('llm.configTitle')">
@@ -58,7 +60,9 @@
required
clearable
></v-text-field>
<small class="text-caption text-medium-emphasis">{{ t('llm.compatibleNote') }}</small>
<small class="text-caption text-medium-emphasis">{{
t('llm.compatibleNote')
}}</small>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
@@ -102,11 +106,11 @@ import { nextTick, ref, toRaw } from 'vue'
import { createOpenAI } from '@ai-sdk/openai'
import { generateText, streamText } from 'ai'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import { useTranslation } from 'i18next-vue'
const toast = useToast()
const appStore = useAppStore()
const { t } = useI18n()
const { t } = useTranslation()
defineProps<{
disabled?: boolean
@@ -151,7 +155,9 @@ const handleGenerate = async (oprions?: { noToast?: boolean }) => {
// @ts-ignore
const errorMessage = error?.message || error?.error?.message
!oprions?.noToast &&
toast.error(`${t('errors.generateFailedPrefix')}\n${errorMessage ? 'Error: ' + errorMessage : ''}`)
toast.error(
`${t('errors.generateFailedPrefix')}\n${errorMessage ? 'Error: ' + errorMessage : ''}`,
)
throw error
}
} finally {

View File

@@ -58,13 +58,13 @@
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/store'
import { useToast } from 'vue-toastification'
import { useTranslation } from 'i18next-vue'
const toast = useToast()
const appStore = useAppStore()
const { t } = useI18n()
const { t } = useTranslation()
defineProps<{
disabled?: boolean

View File

@@ -66,7 +66,7 @@
<script lang="ts" setup>
import { ref, toRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTranslation } from 'i18next-vue'
import { useAppStore } from '@/store'
import { useToast } from 'vue-toastification'
import { ListFilesFromFolderRecord } from '~/electron/types'
@@ -76,7 +76,7 @@ import random from 'random'
const toast = useToast()
const appStore = useAppStore()
const { t } = useI18n()
const { t } = useTranslation()
defineProps<{
disabled?: boolean

View File

@@ -1,7 +1,9 @@
<template>
<div class="h-0 flex-1 relative">
<div class="absolute top-1/12 w-full flex justify-center cursor-default select-none">
<v-chip v-if="appStore.renderStatus === RenderStatus.None"> {{ t('render.status.idle') }} </v-chip>
<v-chip v-if="appStore.renderStatus === RenderStatus.None">
{{ t('render.status.idle') }}
</v-chip>
<v-chip v-if="appStore.renderStatus === RenderStatus.GenerateText" variant="elevated">
{{ t('render.status.generatingText') }}
</v-chip>
@@ -59,10 +61,15 @@
</v-btn>
<v-dialog v-model="configDialogShow" max-width="600" persistent>
<template v-slot:activator="{ props: activatorProps }">
<v-btn v-bind="activatorProps" :disabled="taskInProgress"> {{ t('actions.config') }} </v-btn>
<v-btn v-bind="activatorProps" :disabled="taskInProgress">
{{ t('actions.config') }}
</v-btn>
</template>
<v-card prepend-icon="mdi-text-box-edit-outline" :title="t('dialogs.renderConfigTitle')">
<v-card
prepend-icon="mdi-text-box-edit-outline"
:title="t('dialogs.renderConfigTitle')"
>
<v-card-text>
<div class="w-full flex gap-2 mb-4 items-center">
<v-text-field
@@ -166,11 +173,11 @@
<script lang="ts" setup>
import { ref, toRaw, nextTick, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTranslation } from 'i18next-vue'
import { RenderStatus, useAppStore } from '@/store'
const appStore = useAppStore()
const { t } = useI18n()
const { t } = useTranslation()
const emit = defineEmits<{
(e: 'renderVideo'): void

View File

@@ -35,15 +35,15 @@ import TtsControl from './components/tts-control.vue'
import VideoRender from './components/video-render.vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { RenderStatus, useAppStore } from '@/store'
import { useTranslation } from 'i18next-vue'
import { useToast } from 'vue-toastification'
import { ListFilesFromFolderRecord } from '~/electron/types'
import random from 'random'
const toast = useToast()
const appStore = useAppStore()
const { t } = useI18n()
const { t } = useTranslation()
// 渲染合成视频
const TextGenerateInstance = ref<InstanceType<typeof TextGenerate> | null>()