菜单多语言配置

This commit is contained in:
2026-03-17 09:52:21 +08:00
parent 425e9feb56
commit 8e51ca8930
9 changed files with 138 additions and 31 deletions

View File

@@ -17,7 +17,7 @@
> >
<span <span
class="block max-w-46 overflow-hidden text-ellipsis whitespace-nowrap px-1.5 text-sm text-g-600 dark:text-g-800" class="block max-w-46 overflow-hidden text-ellipsis whitespace-nowrap px-1.5 text-sm text-g-600 dark:text-g-800"
>{{ formatMenuTitle(item.meta?.title as string) }}</span >{{ formatMenuTitle(item.meta?.title as string, item.path) }}</span
> >
</div> </div>
<div <div

View File

@@ -39,7 +39,7 @@
@click="searchGoPage(item)" @click="searchGoPage(item)"
@mouseenter="highlightOnHover(index)" @mouseenter="highlightOnHover(index)"
> >
{{ formatMenuTitle(item.meta.title) }} {{ formatMenuTitle(item.meta.title, item.path) }}
<ArtSvgIcon v-show="isHighlighted(index)" icon="fluent:arrow-enter-left-20-filled" /> <ArtSvgIcon v-show="isHighlighted(index)" icon="fluent:arrow-enter-left-20-filled" />
</div> </div>
</div> </div>
@@ -60,7 +60,7 @@
@click="searchGoPage(item)" @click="searchGoPage(item)"
@mouseenter="highlightOnHoverHistory(index)" @mouseenter="highlightOnHoverHistory(index)"
> >
{{ formatMenuTitle(item.meta.title) }} {{ formatMenuTitle(item.meta.title, item.path) }}
<div <div
class="size-5 selected-icon select-none rounded-full text-g-500 flex-cc c-p" class="size-5 selected-icon select-none rounded-full text-g-500 flex-cc c-p"
@click.stop="deleteHistory(index)" @click.stop="deleteHistory(index)"
@@ -182,7 +182,7 @@
const flattenAndMatch = (item: AppRouteRecord) => { const flattenAndMatch = (item: AppRouteRecord) => {
if (item.meta?.isHide) return if (item.meta?.isHide) return
const lowerItemTitle = formatMenuTitle(item.meta.title).toLowerCase() const lowerItemTitle = formatMenuTitle(item.meta.title, item.path).toLowerCase()
if (item.children && item.children.length > 0) { if (item.children && item.children.length > 0) {
item.children.forEach(flattenAndMatch) item.children.forEach(flattenAndMatch)

View File

@@ -2,7 +2,7 @@
<ElSubMenu v-if="hasChildren" :index="item.path || item.meta.title" class="!p-0"> <ElSubMenu v-if="hasChildren" :index="item.path || item.meta.title" class="!p-0">
<template #title> <template #title>
<ArtSvgIcon :icon="item.meta.icon" :color="theme?.iconColor" class="mr-1 text-lg" /> <ArtSvgIcon :icon="item.meta.icon" :color="theme?.iconColor" class="mr-1 text-lg" />
<span class="text-md">{{ formatMenuTitle(item.meta.title) }}</span> <span class="text-md">{{ formatMenuTitle(item.meta.title, item.path) }}</span>
<div v-if="item.meta.showBadge" class="art-badge art-badge-horizontal" /> <div v-if="item.meta.showBadge" class="art-badge art-badge-horizontal" />
<div v-if="item.meta.showTextBadge" class="art-text-badge"> <div v-if="item.meta.showTextBadge" class="art-text-badge">
{{ item.meta.showTextBadge }} {{ item.meta.showTextBadge }}
@@ -32,7 +32,7 @@
class="mr-1 text-lg" class="mr-1 text-lg"
:style="{ color: theme.iconColor }" :style="{ color: theme.iconColor }"
/> />
<span class="text-md">{{ formatMenuTitle(item.meta.title) }}</span> <span class="text-md">{{ formatMenuTitle(item.meta.title, item.path) }}</span>
<div <div
v-if="item.meta.showBadge" v-if="item.meta.showBadge"
class="art-badge" class="art-badge"

View File

@@ -135,7 +135,7 @@
return props.list.map((item) => ({ return props.list.map((item) => ({
...item, ...item,
isActive: isMenuItemActive(item), isActive: isMenuItemActive(item),
formattedTitle: formatMenuTitle(item.meta.title) formattedTitle: formatMenuTitle(item.meta.title, item.path)
})) }))
}) })

View File

@@ -19,7 +19,7 @@
<ElTooltip <ElTooltip
class="box-item" class="box-item"
effect="dark" effect="dark"
:content="$t(menu.meta.title)" :content="formatMenuTitle(menu.meta.title, menu.path)"
placement="right" placement="right"
:offset="15" :offset="15"
:hide-after="0" :hide-after="0"
@@ -43,7 +43,7 @@
}" }"
/> />
<span v-if="dualMenuShowText" class="text-md text-g-700"> <span v-if="dualMenuShowText" class="text-md text-g-700">
{{ $t(menu.meta.title) }} {{ formatMenuTitle(menu.meta.title, menu.path) }}
</span> </span>
<div v-if="menu.meta.showBadge" class="art-badge art-badge-dual" /> <div v-if="menu.meta.showBadge" class="art-badge art-badge-dual" />
</div> </div>
@@ -136,6 +136,7 @@
import { useMenuStore } from '@/store/modules/menu' import { useMenuStore } from '@/store/modules/menu'
import { isIframe } from '@/utils/navigation' import { isIframe } from '@/utils/navigation'
import { handleMenuJump } from '@/utils/navigation' import { handleMenuJump } from '@/utils/navigation'
import { formatMenuTitle } from '@/utils/router'
import SidebarSubmenu from './widget/SidebarSubmenu.vue' import SidebarSubmenu from './widget/SidebarSubmenu.vue'
import { useCommon } from '@/hooks/core/useCommon' import { useCommon } from '@/hooks/core/useCommon'
import { useWindowSize, useTimeoutFn } from '@vueuse/core' import { useWindowSize, useTimeoutFn } from '@vueuse/core'

View File

@@ -10,7 +10,7 @@
/> />
</div> </div>
<span class="menu-name"> <span class="menu-name">
{{ formatMenuTitle(item.meta.title) }} {{ formatMenuTitle(item.meta.title, item.path) }}
</span> </span>
<div v-if="item.meta.showBadge" class="art-badge" style="right: 10px" /> <div v-if="item.meta.showBadge" class="art-badge" style="right: 10px" />
</template> </template>
@@ -45,7 +45,7 @@
<template #title> <template #title>
<span class="menu-name"> <span class="menu-name">
{{ formatMenuTitle(item.meta.title) }} {{ formatMenuTitle(item.meta.title, item.path) }}
</span> </span>
<div v-if="item.meta.showBadge" class="art-badge" /> <div v-if="item.meta.showBadge" class="art-badge" />
<div v-if="item.meta.showTextBadge && (level > 0 || menuOpen)" class="art-text-badge"> <div v-if="item.meta.showTextBadge && (level > 0 || menuOpen)" class="art-text-badge">

View File

@@ -44,7 +44,7 @@
class="text-base mr-1 group-hover:text-theme" class="text-base mr-1 group-hover:text-theme"
:class="item.path === activeTab ? 'text-theme' : 'text-g-600'" :class="item.path === activeTab ? 'text-theme' : 'text-g-600'"
/> />
{{ item.customTitle || formatMenuTitle(item.title) }} {{ item.customTitle || formatMenuTitle(item.title, item.path) }}
<span <span
v-if="list.length > 1 && !item.fixedTab" v-if="list.length > 1 && !item.fixedTab"
class="inline-flex flex-cc relative ml-0.5 p-1 rounded-full tad-200 hover:bg-g-200" class="inline-flex flex-cc relative ml-0.5 p-1 rounded-full tad-200 hover:bg-g-200"

View File

@@ -245,7 +245,11 @@
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
"console": "Console" "console": "Console",
"userCenter": "User Center"
},
"userCenter": {
"title": "User Center"
}, },
"result": { "result": {
"title": "Result Page", "title": "Result Page",
@@ -259,11 +263,43 @@
"serverError": "500" "serverError": "500"
}, },
"system": { "system": {
"title": "System Settings", "title": "System Management",
"user": "User Manage", "user": "User Management",
"role": "Role Manage", "role": "Role Management",
"userCenter": "User Center", "userCenter": "User Center",
"menu": "Menu Manage" "menu": "Menu Management",
"dept": "Department Management",
"post": "Post Management",
"config": "System Config"
},
"safeguard": {
"title": "Operations Management",
"dict": "Data Dictionary",
"server": "Server Monitor",
"operLog": "Operation Log",
"loginLog": "Login Log",
"emailLog": "Email Log",
"database": "Database",
"cache": "Cache Management",
"attachment": "Attachment"
},
"tool": {
"title": "Development Tools",
"crontab": "Crontab",
"code": "Code Generator"
},
"dice": {
"title": "Dice Game",
"lotteryPoolConfig": "Lottery Tier Weight Config",
"player": "Player Management",
"playerWalletRecord": "Player Wallet Records",
"playRecord": "Player Draw Records",
"playerTicketRecord": "Player Ticket Records",
"rewardConfig": "Reward Config",
"reward": "Dice Point Weight Config",
"rewardConfigRecord": "Dice Weight Test Records",
"playRecordTest": "Draw Records (Test Weight)",
"config": "Game Config"
} }
}, },
"table": { "table": {

View File

@@ -11,6 +11,62 @@ import NProgress from 'nprogress'
import 'nprogress/nprogress.css' import 'nprogress/nprogress.css'
import i18n, { $t } from '@/locales' import i18n, { $t } from '@/locales'
/**
* 路径到菜单 i18n key 的映射
* 当后端返回的菜单名为中文或非 i18n key 时,根据 path 仍可显示多语言
*/
export const MAP_PATH_TO_MENU_I18N_KEY: Record<string, string> = {
'/dashboard': 'menus.dashboard.title',
'/dashboard/console': 'menus.dashboard.console',
'/dashboard/user-center': 'menus.userCenter.title',
'/system': 'menus.system.title',
'/system/user': 'menus.system.user',
'/system/role': 'menus.system.role',
'/system/user-center': 'menus.system.userCenter',
'/system/menu': 'menus.system.menu',
'/system/dept': 'menus.system.dept',
'/system/post': 'menus.system.post',
'/system/config': 'menus.system.config',
'/safeguard': 'menus.safeguard.title',
'/safeguard/dict': 'menus.safeguard.dict',
'/safeguard/server': 'menus.safeguard.server',
'/safeguard/oper-log': 'menus.safeguard.operLog',
'/safeguard/login-log': 'menus.safeguard.loginLog',
'/safeguard/email-log': 'menus.safeguard.emailLog',
'/safeguard/database': 'menus.safeguard.database',
'/safeguard/cache': 'menus.safeguard.cache',
'/safeguard/attachment': 'menus.safeguard.attachment',
'/tool': 'menus.tool.title',
'/tool/crontab': 'menus.tool.crontab',
'/tool/code': 'menus.tool.code',
'/dice': 'menus.dice.title',
'/dice/lottery_pool_config': 'menus.dice.lotteryPoolConfig',
'/dice/lottery_pool_config/index': 'menus.dice.lotteryPoolConfig',
'/dice/player': 'menus.dice.player',
'/dice/player/index': 'menus.dice.player',
'/dice/player_wallet_record': 'menus.dice.playerWalletRecord',
'/dice/player_wallet_record/index': 'menus.dice.playerWalletRecord',
'/dice/play_record': 'menus.dice.playRecord',
'/dice/play_record/index': 'menus.dice.playRecord',
'/dice/player_ticket_record': 'menus.dice.playerTicketRecord',
'/dice/player_ticket_record/index': 'menus.dice.playerTicketRecord',
'/dice/reward_config': 'menus.dice.rewardConfig',
'/dice/reward_config/index': 'menus.dice.rewardConfig',
'/dice/reward': 'menus.dice.reward',
'/dice/reward/index': 'menus.dice.reward',
'/dice/reward_config_record': 'menus.dice.rewardConfigRecord',
'/dice/reward_config_record/index': 'menus.dice.rewardConfigRecord',
'/dice/play_record_test': 'menus.dice.playRecordTest',
'/dice/play_record_test/index': 'menus.dice.playRecordTest',
'/dice/config': 'menus.dice.config',
'/dice/config/index': 'menus.dice.config',
'/result/success': 'menus.result.success',
'/result/fail': 'menus.result.fail',
'/exception/403': 'menus.exception.forbidden',
'/exception/404': 'menus.exception.notFound',
'/exception/500': 'menus.exception.serverError'
}
/** 扩展的路由配置类型 */ /** 扩展的路由配置类型 */
export type AppRouteRecordRaw = RouteRecordRaw & { export type AppRouteRecordRaw = RouteRecordRaw & {
hidden?: boolean hidden?: boolean
@@ -34,28 +90,42 @@ export const setPageTitle = (to: RouteLocationNormalized): void => {
const { title } = to.meta const { title } = to.meta
if (title) { if (title) {
setTimeout(() => { setTimeout(() => {
document.title = `${formatMenuTitle(String(title))} - ${AppConfig.systemInfo.name}` document.title = `${formatMenuTitle(String(title), to.path)} - ${AppConfig.systemInfo.name}`
}, 150) }, 150)
} }
} }
/**
* 根据路径获取对应的菜单 i18n key若有
*/
export const getMenuI18nKeyByPath = (path: string): string | undefined => {
if (!path) return undefined
const normalized = path.replace(/\?.*$/, '').replace(/#.*$/, '').replace(/\/$/, '') || '/'
return MAP_PATH_TO_MENU_I18N_KEY[normalized]
}
/** /**
* 格式化菜单标题 * 格式化菜单标题
* @param title 菜单标题,可以是 i18n 的 key,也可以是字符串 * @param title 菜单标题,可以是 i18n 的 key(如 menus.dashboard.title也可以是中文等纯文本
* @param path 可选,当前菜单路由 path当 title 非 i18n key 时,用 path 查表得到 key 再翻译,以实现多语言切换
* @returns 格式化后的菜单标题 * @returns 格式化后的菜单标题
*/ */
export const formatMenuTitle = (title: string): string => { export const formatMenuTitle = (title: string, path?: string): string => {
if (title) { if (!title) return ''
if (title.startsWith('menus.')) {
// 使用 te() 方法检查翻译键值是否存在,避免控制台警告 if (title.startsWith('menus.')) {
if (i18n.global.te(title)) { if (i18n.global.te(title)) {
return $t(title) return $t(title)
} else {
// 如果翻译不存在返回键值的最后部分作为fallback
return title.split('.').pop() || title
}
} }
return title return title.split('.').pop() || title
} }
return ''
if (path) {
const i18nKey = getMenuI18nKeyByPath(path)
if (i18nKey && i18n.global.te(i18nKey)) {
return $t(i18nKey)
}
}
return title
} }