feat(i18n): Implement internationalization support for your application

- Integrate the vue-i18n plugin and configure multilingual support
- Add English and Chinese translation files
This commit is contained in:
2025-10-15 15:07:47 +08:00
parent 29843bb5e5
commit 2b8f09b4b2
16 changed files with 307 additions and 26 deletions

View File

@@ -16,6 +16,7 @@
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-sql": "~2",
"vue": "^3.5.21",
"vue-i18n": "^11.1.12",
"vue-router": "^4.5.1",
"vuetify": "^3.10.1",
"zod": "^4.1.12"

View File

@@ -14,7 +14,7 @@
<v-list-item
prepend-avatar="/src/assets/logo.svg"
subtitle=""
title="Spary"
:title="$t('app.title')"
></v-list-item>
</v-list>
@@ -29,16 +29,20 @@
></v-list-item>
<v-list-item
prepend-icon="mdi-airport"
title="Nodes"
:title="$t('app.nodes')"
value="nodes"
@click="router.push('/nodes')"
></v-list-item>
<v-list-item
prepend-icon="mdi-cog"
title="Settings"
:title="$t('app.settings')"
value="settings"
@click="router.push('/settings')"
></v-list-item>
>
<template v-slot:append>
<language-switcher />
</template>
</v-list-item>
</v-list>
</v-navigation-drawer>
@@ -54,8 +58,11 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import LanguageSwitcher from './components/LanguageSwitcher.vue'
import notificationProvider from './components/notify/notificationProvider.vue'
const drawer = ref(true)
const router = useRouter()
useI18n()
</script>

1
src/components.d.ts vendored
View File

@@ -10,6 +10,7 @@ declare module 'vue' {
export interface GlobalComponents {
AddGroup: typeof import('./components/nodeEdit/addGroup.vue')['default']
AddNode: typeof import('./components/nodeEdit/addNode.vue')['default']
LanguageSwitcher: typeof import('./components/LanguageSwitcher.vue')['default']
NodeList: typeof import('./components/nodeEdit/nodeList.vue')['default']
NodesFloatButton: typeof import('./components/nodeEdit/nodesFloatButton.vue')['default']
NotificationProvider: typeof import('./components/notify/notificationProvider.vue')['default']

View File

@@ -0,0 +1,48 @@
<template>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
>
<v-icon>mdi-translate</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
v-for="(item, i) in languages"
:key="i"
@click="changeLanguage(item.code)"
:active="currentLanguage === item.code"
>
<v-list-item-title>{{ item.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const { locale } = useI18n();
const languages = [
{ code: 'en', name: 'English' },
{ code: 'zh', name: '中文' }
];
const currentLanguage = computed(() => locale.value);
const changeLanguage = (lang: string) => {
locale.value = lang;
// Store the selected language in localStorage so it persists across sessions
localStorage.setItem('language', lang);
};
// Set the initial language from localStorage if available
const savedLanguage = localStorage.getItem('language');
if (savedLanguage && languages.some(lang => lang.code === savedLanguage)) {
locale.value = savedLanguage;
}
</script>

View File

@@ -2,7 +2,7 @@
<v-container class="fill-height d-flex align-center justify-center">
<div>
<v-switch
:label="String(functionStatus)"
:label="functionStatus === 'On' ? $t('spary.functionStatus.on') : $t('spary.functionStatus.off')"
:model-value="functionStatus === 'On'"
@update:model-value="toggleFunctionStatus"
></v-switch>

View File

@@ -1,14 +1,17 @@
<script setup lang="ts">
import {ref, computed} from 'vue'
import {invoke} from '@tauri-apps/api/core'
import { useI18n } from 'vue-i18n';
import {groupRepository} from "@/entities/group.ts";
import {notify} from "@/components/notify/notifyStore.ts";
const { t } = useI18n()
const groupName = ref('')
const groupNameRule = [
(value: string): boolean | string => {
if (value?.length >= 1 && value?.length <= 15) return true
return 'Group name must be between 1 and 15 characters.'
return t('addGroup.groupNameRule')
},
]
@@ -22,7 +25,7 @@ const groupSubscribeUrlRule = [
new URL(value)
return true
} catch (e) {
return 'URL is not valid.'
return t('addGroup.urlInvalid')
}
},
]
@@ -41,7 +44,7 @@ async function addGroup() {
const check_repeat_one = await groupRepository.findByName(groupName.value)
console.log(check_repeat_one)
if (check_repeat_one.length > 0) {
notify("Group already exists.", {
notify(t('addGroup.groupExists'), {
color: "error"
})
return
@@ -55,7 +58,7 @@ async function addGroup() {
arguments: groupArguments.value
}
)
notify("Group added.")
notify(t('addGroup.groupAdded'))
}
}
@@ -77,17 +80,17 @@ async function add_group(groupName: string, groupSubscribeUrl: string | null, gr
<v-text-field
v-model="groupName"
:rules="groupNameRule"
label="Group name"
:label="$t('addGroup.groupName')"
></v-text-field>
<v-text-field
v-model="groupSubscribeUrl"
:rules="groupSubscribeUrlRule"
label="Subscribe URL"
:label="$t('addGroup.subscribeUrl')"
></v-text-field>
<v-textarea
v-model="groupArguments"
label="Arguments"
:label="$t('addGroup.arguments')"
></v-textarea>
<v-btn
class="mt-2"
@@ -96,7 +99,7 @@ async function add_group(groupName: string, groupSubscribeUrl: string | null, gr
:disabled="isAddDisabled"
:loading="isAdding"
@click="addGroup">
Add
{{ $t('addGroup.addGroupButton') }}
</v-btn>
</v-form>
</v-sheet>

View File

@@ -1,9 +1,12 @@
<script setup lang="ts">
import {ref, onMounted, computed} from 'vue'
import { useI18n } from 'vue-i18n';
import {Group, groupRepository} from "@/entities/group.ts";
import {nodeRepository} from "@/entities/node.ts";
import {notify} from "@/components/notify/notifyStore.ts";
const { t } = useI18n()
const props = defineProps<{ groupId: string }>()
const allGroups = ref<Group[]>([])
@@ -36,7 +39,7 @@ async function addNode() {
arguments: nodeArguments.value,
group_id: selectedGroupId.value
})
notify("Node added successfully")
notify(t('addNode.nodeAddedSuccess'))
isAdding.value = false
}
}
@@ -47,7 +50,7 @@ async function addNode() {
<v-form fast-fail @submit.prevent>
<v-select
v-model="selectedGroupId"
label="Group"
:label="$t('addNode.group')"
:items="allGroups"
item-title="name"
item-value="id"
@@ -56,12 +59,12 @@ async function addNode() {
<v-text-field
v-model="nodeAlias"
label="Node alias"
:label="$t('addNode.nodeAlias')"
></v-text-field>
<v-textarea
v-model="nodeArguments"
label="Arguments"
:label="$t('addNode.arguments')"
></v-textarea>
<v-btn
class="mt-2"
@@ -70,7 +73,7 @@ async function addNode() {
:disabled="isAddDisabled"
:loading="isAdding"
@click="addNode">
Add
{{ $t('addNode.addNodeButton') }}
</v-btn>
</v-form>
</v-sheet>

View File

@@ -1,10 +1,12 @@
<script setup lang="ts">
import {ref} from 'vue'
import {useRouter} from "vue-router";
import { useI18n } from 'vue-i18n';
import {groupRepository} from "@/entities/group.ts";
import {nodeRepository} from "@/entities/node.ts";
const router = useRouter()
useI18n()
interface GroupItem {
name: string
@@ -69,7 +71,7 @@ loadData()
prepend-icon="mdi-plus"
@click.stop="addItem(group)"
>
add
{{ $t('nodeList.add') }}
</v-btn>
</div>
</v-expansion-panel-title>
@@ -90,7 +92,7 @@ loadData()
</v-card-title>
<v-card-text>
<div>{{ item.url }}</div>
<div>usage: {{ item.traffic }}</div>
<div>{{ $t('nodeList.usage', { traffic: item.traffic }) }}</div>
</v-card-text>
</v-card>
</v-col>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import {useRouter} from "vue-router";
import { useI18n } from 'vue-i18n';
const router = useRouter()
useI18n()
</script>
@@ -15,13 +17,13 @@ const router = useRouter()
<template v-slot:prepend>
<v-icon color="success"></v-icon>
</template>
add group
{{ $t('nodesFloatButton.addGroup') }}
</v-btn>
<v-btn key="2" prepend-icon="mdi-airplane-marker">
<template v-slot:prepend>
<v-icon color="success"></v-icon>
</template>
add node
{{ $t('nodesFloatButton.addNode') }}
</v-btn>
</v-speed-dial>
</div>

56
src/locales/en.json Normal file
View File

@@ -0,0 +1,56 @@
{
"app": {
"title": "Spary",
"nodes": "Nodes",
"settings": "Settings",
"add": "Add",
"group": "Group",
"node": "Node"
},
"spary": {
"functionStatus": {
"on": "On",
"off": "Off"
}
},
"nodeList": {
"add": "add",
"usage": "usage: {traffic}",
"noNodes": "No nodes available"
},
"nodesFloatButton": {
"addGroup": "add group",
"addNode": "add node"
},
"addGroup": {
"title": "Add Group",
"groupName": "Group name",
"groupNameRule": "Group name must be between 1 and 15 characters.",
"subscribeUrl": "Subscribe URL",
"urlInvalid": "URL is not valid.",
"arguments": "Arguments",
"addGroupButton": "Add",
"groupExists": "Group already exists.",
"groupAdded": "Group added."
},
"addNode": {
"title": "Add Node",
"group": "Group",
"nodeAlias": "Node alias",
"arguments": "Arguments",
"addNodeButton": "Add",
"nodeAddedSuccess": "Node added successfully"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"confirm": "Confirm",
"darkModeOn": "Dark Mode On",
"darkModeOff": "Dark Mode Off",
"language": "Language",
"english": "English",
"chinese": "Chinese"
}
}

56
src/locales/zh.json Normal file
View File

@@ -0,0 +1,56 @@
{
"app": {
"title": "Spary",
"nodes": "节点",
"settings": "设置",
"add": "添加",
"group": "组",
"node": "节点"
},
"spary": {
"functionStatus": {
"on": "开",
"off": "关"
}
},
"nodeList": {
"add": "添加",
"usage": "使用量: {traffic}",
"noNodes": "没有可用节点"
},
"nodesFloatButton": {
"addGroup": "添加组",
"addNode": "添加节点"
},
"addGroup": {
"title": "添加组",
"groupName": "组名称",
"groupNameRule": "组名称必须在1到15个字符之间。",
"subscribeUrl": "订阅链接",
"urlInvalid": "URL无效。",
"arguments": "参数",
"addGroupButton": "添加",
"groupExists": "组已存在。",
"groupAdded": "组已添加。"
},
"addNode": {
"title": "添加节点",
"group": "组",
"nodeAlias": "节点别名",
"arguments": "参数",
"addNodeButton": "添加",
"nodeAddedSuccess": "节点添加成功"
},
"common": {
"save": "保存",
"cancel": "取消",
"delete": "删除",
"edit": "编辑",
"confirm": "确认",
"darkModeOn": "深色模式开",
"darkModeOff": "深色模式关",
"language": "语言",
"english": "英语",
"chinese": "中文"
}
}

View File

@@ -1,7 +1,53 @@
<template>
<v-container>
<v-card class="mx-auto" max-width="600">
<v-card-title>{{ $t('app.settings') }}</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12">
<v-btn @click="toggleDarkMode" disabled>
{{ darkMode ? $t('common.darkModeOff') : $t('common.darkModeOn') }}
</v-btn>
</v-col>
<v-col cols="12">
<h3>{{ $t('common.language') }}</h3>
<v-btn-toggle v-model="selectedLanguage" mandatory>
<v-btn value="en">{{ $t('common.english') }}</v-btn>
<v-btn value="zh">{{ $t('common.chinese') }}</v-btn>
</v-btn-toggle>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-container>
</template>
<script lang="ts" setup>
//
import { ref, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
const { locale } = useI18n();
const selectedLanguage = ref(locale.value);
const darkMode = ref(false);
// Update the translation when the language changes
watch(selectedLanguage, (newLang) => {
locale.value = newLang;
localStorage.setItem('language', newLang);
});
// Initialize the selected language from localStorage
onMounted(() => {
const savedLanguage = localStorage.getItem('language');
if (savedLanguage) {
selectedLanguage.value = savedLanguage;
locale.value = savedLanguage;
}
});
// Toggle dark mode
const toggleDarkMode = () => {
darkMode.value = !darkMode.value;
// Here you would typically update a theme preference
};
</script>

17
src/plugins/i18n.ts Normal file
View File

@@ -0,0 +1,17 @@
import { createI18n } from 'vue-i18n'
import en from '@/locales/en.json'
import zh from '@/locales/zh.json'
const messages = {
en: en,
zh: zh
}
const i18n = createI18n({
legacy: false, // Use composition API mode
locale: 'en', // Default locale
fallbackLocale: 'en', // Fallback locale
messages
})
export default i18n

View File

@@ -7,6 +7,7 @@
// Plugins
import vuetify from './vuetify'
import router from '../router'
import i18n from './i18n'
// Types
import type { App } from 'vue'
@@ -15,4 +16,5 @@ export function registerPlugins (app: App) {
app
.use(vuetify)
.use(router)
.use(i18n)
}

7
src/vue-i18n.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/* eslint-disable */
import Vue from "vue";
declare module "@vue/runtime-core" {
export interface ComponentCustomProperties {
$t: (key: string, ...args: any[]) => string;
}
}

View File

@@ -304,6 +304,27 @@
resolved "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz"
integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==
"@intlify/core-base@11.1.12":
version "11.1.12"
resolved "https://registry.yarnpkg.com/@intlify/core-base/-/core-base-11.1.12.tgz#e02529021a4e69f8a1adcca5ce61963c71cd72ba"
integrity sha512-whh0trqRsSqVLNEUCwU59pyJZYpU8AmSWl8M3Jz2Mv5ESPP6kFh4juas2NpZ1iCvy7GlNRffUD1xr84gceimjg==
dependencies:
"@intlify/message-compiler" "11.1.12"
"@intlify/shared" "11.1.12"
"@intlify/message-compiler@11.1.12":
version "11.1.12"
resolved "https://registry.yarnpkg.com/@intlify/message-compiler/-/message-compiler-11.1.12.tgz#27e69790b711a92cddb07175187dd09a7b270b55"
integrity sha512-Fv9iQSJoJaXl4ZGkOCN1LDM3trzze0AS2zRz2EHLiwenwL6t0Ki9KySYlyr27yVOj5aVz0e55JePO+kELIvfdQ==
dependencies:
"@intlify/shared" "11.1.12"
source-map-js "^1.0.2"
"@intlify/shared@11.1.12":
version "11.1.12"
resolved "https://registry.yarnpkg.com/@intlify/shared/-/shared-11.1.12.tgz#ab41083e017d622cf63c7dc88a0ee0bc27f4127a"
integrity sha512-Om86EjuQtA69hdNj3GQec9ZC0L0vPSAnXzB3gP/gyJ7+mA7t06d9aOAiqMZ+xEOsumGP4eEBlfl8zF2LOTzf2A==
"@jridgewell/gen-mapping@^0.3.5":
version "0.3.13"
resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz"
@@ -877,7 +898,7 @@
"@vue/compiler-dom" "3.5.22"
"@vue/shared" "3.5.22"
"@vue/devtools-api@^6.6.4":
"@vue/devtools-api@^6.5.0", "@vue/devtools-api@^6.6.4":
version "6.6.4"
resolved "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz"
integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
@@ -2429,7 +2450,7 @@ sisteransi@^1.0.5:
resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz"
integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.2.1:
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2, source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
@@ -2688,6 +2709,15 @@ vue-eslint-parser@^10.2.0:
esquery "^1.6.0"
semver "^7.6.3"
vue-i18n@^11.1.12:
version "11.1.12"
resolved "https://registry.yarnpkg.com/vue-i18n/-/vue-i18n-11.1.12.tgz#230b21d65dd89343ebd40d19c9e3cfb13db8c1fd"
integrity sha512-BnstPj3KLHLrsqbVU2UOrPmr0+Mv11bsUZG0PyCOzsawCivk8W00GMXHeVUWIDOgNaScCuZah47CZFE+Wnl8mw==
dependencies:
"@intlify/core-base" "11.1.12"
"@intlify/shared" "11.1.12"
"@vue/devtools-api" "^6.5.0"
vue-router@^4.5.1:
version "4.5.1"
resolved "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz"