From 0be2d69ffa732e4efdeebd00a7f3b7409b25e271 Mon Sep 17 00:00:00 2001 From: pnoker Date: Sun, 3 May 2026 02:38:39 +0800 Subject: [PATCH] feat(menu): multilingual menu titles (form + render utility) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- src/store/modules/menu.ts | 7 +++ src/utils/MenuUtil.ts | 56 +++++++++++++++++++ src/views/settings/menu/edit/MenuEditForm.vue | 6 ++ src/views/settings/menu/edit/index.ts | 23 +++++++- 4 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 src/utils/MenuUtil.ts diff --git a/src/store/modules/menu.ts b/src/store/modules/menu.ts index 2b1368b9d..2cda75e17 100644 --- a/src/store/modules/menu.ts +++ b/src/store/modules/menu.ts @@ -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; + // 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; diff --git a/src/utils/MenuUtil.ts b/src/utils/MenuUtil.ts new file mode 100644 index 000000000..e98876159 --- /dev/null +++ b/src/utils/MenuUtil.ts @@ -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 | 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 || ''; +} diff --git a/src/views/settings/menu/edit/MenuEditForm.vue b/src/views/settings/menu/edit/MenuEditForm.vue index 12b3cd94b..fd9075860 100644 --- a/src/views/settings/menu/edit/MenuEditForm.vue +++ b/src/views/settings/menu/edit/MenuEditForm.vue @@ -52,6 +52,12 @@ :placeholder="t('settings.menu.menuCodePlaceholder')" /> + + + + + + diff --git a/src/views/settings/menu/edit/index.ts b/src/views/settings/menu/edit/index.ts index 21ed0b5e1..02973fc01 100644 --- a/src/views/settings/menu/edit/index.ts +++ b/src/views/settings/menu/edit/index.ts @@ -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 = { ...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 || '',