feat(menu): multilingual menu titles (form + render utility)

Replaces the "store an i18n key in menu_ext.content.title and resolve
it via frontend locales" design with per-locale titles maintained by
the admin in the menu CRUD form. The menu store type gains a
titles: Record<string,string> field, and MenuEditForm now asks for
Chinese / English display names directly instead of an nav.* key.

A new resolveMenuTitle(node) helper in utils/MenuUtil.ts gives the
rest of the UI a single place to pick the title with the right
fallback chain: titles[currentLocale] → titles.en → any populated
locale → legacy content.title (via $t for nav.* keys still in the
database) → menuName. Any time a menu is rendered — sidebar,
breadcrumb, settings left nav — it should go through this helper.

Old rows that still carry content.title keep rendering via the
legacy-fallback branch until the dc3-docker migration converts them
to titles; no hard cutover required.
This commit is contained in:
pnoker
2026-05-03 02:38:39 +08:00
parent 6a56bf0f82
commit 0be2d69ffa
4 changed files with 90 additions and 2 deletions
+7
View File
@@ -28,6 +28,13 @@ export interface MenuNode {
menuIndex?: number;
menuExt?: {
content?: {
// Authoritative locale → display-name map. Backed by
// MenuExt.Content.titles on the server; populated via the menu CRUD
// form (Chinese Title / English Title inputs).
titles?: Record<string, string>;
// Legacy i18n key (e.g. "nav.home") retained for backwards
// compatibility with rows that pre-date the multilingual-titles
// migration. New UI should treat this as a final fallback only.
title?: string;
icon?: string;
url?: string;
+56
View File
@@ -0,0 +1,56 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import i18n from '@/config/i18n';
import type { MenuNode } from '@/store/modules/menu';
/**
* Resolve the display name of a menu node given the currently active locale.
*
* Priority:
* 1. menuExt.content.titles[currentLocale] — authoritative per-locale
* title maintained from the menu CRUD form.
* 2. menuExt.content.titles.en — English fallback when the locale the
* user picked has no explicit entry.
* 3. Legacy menuExt.content.title — may be either an i18n key (old seed
* data like `"nav.home"`) or plain text; fed through $t so existing
* `nav.*` keys keep resolving, and falls through to the raw string
* when the key is not registered in locales.
* 4. menuName — the English machine name stored on the row, so the UI
* never shows a blank label.
*/
export function resolveMenuTitle(node: Partial<MenuNode> | null | undefined): string {
if (!node) return '';
const content = node.menuExt?.content;
if (content) {
const locale = i18n.global.locale.value as string;
const titles = content.titles;
if (titles) {
if (titles[locale]) return titles[locale];
if (titles.en) return titles.en;
// Any other locale populated on the row — pick the first non-empty
// entry so a row authored only in e.g. Japanese doesn't render blank.
for (const key of Object.keys(titles)) {
if (titles[key]) return titles[key];
}
}
if (content.title) {
const translated = i18n.global.t(content.title);
return translated && translated !== content.title ? translated : content.title;
}
}
return node.menuName || '';
}
@@ -52,6 +52,12 @@
:placeholder="t('settings.menu.menuCodePlaceholder')"
/>
</el-form-item>
<el-form-item :label="t('settings.menu.titleZh')" prop="titleZh">
<el-input v-model="reactiveData.form.titleZh" clearable :placeholder="t('settings.menu.titleZhPlaceholder')" />
</el-form-item>
<el-form-item :label="t('settings.menu.titleEn')" prop="titleEn">
<el-input v-model="reactiveData.form.titleEn" clearable :placeholder="t('settings.menu.titleEnPlaceholder')" />
</el-form-item>
<el-form-item :label="t('settings.menu.menuType')" prop="menuTypeFlag">
<el-select v-model="reactiveData.form.menuTypeFlag">
<el-option v-for="opt in MENU_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
+21 -2
View File
@@ -32,6 +32,8 @@ const createEmptyForm = () => ({
menuTypeFlag: 'COMMON' as string,
menuLevel: 'C1' as string,
menuIndex: 0 as number,
titleZh: '',
titleEn: '',
icon: '',
url: '',
enableFlag: 'ENABLE' as string,
@@ -63,6 +65,8 @@ export default defineComponent({
const rules: FormRules = {
menuName: [{ required: true, message: t('settings.menu.menuNamePlaceholder'), trigger: 'blur' }],
menuCode: [{ required: true, message: t('settings.menu.menuCodePlaceholder'), trigger: 'blur' }],
titleZh: [{ required: true, message: t('settings.menu.titleZhPlaceholder'), trigger: 'blur' }],
titleEn: [{ required: true, message: t('settings.menu.titleEnPlaceholder'), trigger: 'blur' }],
menuTypeFlag: [{ required: true, trigger: 'change' }],
menuLevel: [{ required: true, trigger: 'change' }],
};
@@ -85,11 +89,20 @@ export default defineComponent({
const showEdit = (row: any) => {
reactiveData.mode = 'edit';
const content = row?.menuExt?.content || {};
const titles = content.titles || {};
// Legacy rows may still carry content.title (an i18n key like
// "nav.home"). Keep the form usable on old data by falling back to
// the resolved i18n text, then to menuName, so editors never face a
// blank title input.
const legacyTitle =
typeof content.title === 'string' && content.title ? t(content.title, content.title) : row?.menuName || '';
const initial = {
...createEmptyForm(),
...row,
parentMenuId: row?.parentMenuId ?? 0,
menuIndex: row?.menuIndex ?? 0,
titleZh: titles.zh || legacyTitle,
titleEn: titles.en || legacyTitle,
icon: content.icon || '',
url: content.url || '',
};
@@ -107,12 +120,18 @@ export default defineComponent({
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
reactiveData.submitting = true;
const { icon, url, ...rest } = reactiveData.form;
const { icon, url, titleZh, titleEn, ...rest } = reactiveData.form;
const payload: Record<string, any> = {
...rest,
menuExt: {
content: {
title: rest.menuName,
// Authoritative locale → display-name map. Picked up by the
// sidebar / breadcrumb / header via content.titles[currentLocale]
// with fallback to content.titles.en.
titles: {
zh: titleZh,
en: titleEn,
},
icon: icon || '',
url: url || '',
remark: rest.remark || '',