mirror of
https://github.com/YILS-LIN/short-video-factory.git
synced 2025-11-25 03:15:03 +08:00
重构i18n,支持全局多语言切换
This commit is contained in:
200
src/i18n.ts
200
src/i18n.ts
@@ -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
|
||||
@@ -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
27
src/lib/i18n.ts
Normal 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
|
||||
37
src/main.ts
37
src/main.ts
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>()
|
||||
|
||||
Reference in New Issue
Block a user