diff --git a/docs/agents/frontend.md b/docs/agents/frontend.md index f1e3b72cc..7e17efdc2 100644 --- a/docs/agents/frontend.md +++ b/docs/agents/frontend.md @@ -73,6 +73,7 @@ request.get('/path', { - `userStore.isAdmin` 与 `isSpaceAdmin` 的路由差异; - xpack 静态脚本加载失败路径:`LicenseGenerator` 是无类型声明的 window 全局,登录/改密加密(`sqlbotEncrypt`)和动态路由注册都硬依赖它,脚本加载失败只提示并中断导航,没有降级。 - 新路由必须配置名称、标题 i18n 和正确父布局;不要绕过已有访问控制。 +- 菜单路由的翻译键存入 `meta.titleKey`,由布局的 `useMenuTitle` 在渲染时翻译;不要在路由模块初始化时调用 `t()` 固定标题。扩展路由提供的 `meta.title` 作为直接显示的标题兼容保留。 ## 视图、组件与样式 diff --git a/frontend/src/components/layout/MenuItem.vue b/frontend/src/components/layout/MenuItem.vue index 279030569..33c3dff9a 100644 --- a/frontend/src/components/layout/MenuItem.vue +++ b/frontend/src/components/layout/MenuItem.vue @@ -2,6 +2,7 @@ import { h, defineComponent } from 'vue' import { ElMenuItem, ElSubMenu, ElIcon } from 'element-plus-secondary' import { useRouter, useRoute } from 'vue-router' +import { useMenuTitle } from './useMenuTitle' import chat from '@/assets/svg/menu/icon_chat_filled.svg' import noChat from '@/assets/svg/menu/icon_chat_outlined.svg' import dashboard from '@/assets/svg/menu/icon_dashboard_filled.svg' @@ -52,6 +53,7 @@ const MenuItem = defineComponent({ }, }, setup(props) { + const getMenuTitle = useMenuTitle() const router = useRouter() const route = useRoute() const titleWithIcon = (props: any) => { @@ -78,7 +80,8 @@ const MenuItem = defineComponent({ } if (children?.length) { - const { title, iconDeActive, iconActive } = props.menu?.meta || {} + const { iconDeActive, iconActive } = props.menu?.meta || {} + const title = getMenuTitle(props.menu?.meta) const icon = route.path.startsWith(path) ? iconActive : iconDeActive return h( ElSubMenu, @@ -93,7 +96,8 @@ const MenuItem = defineComponent({ ) } - const { title, iconDeActive, iconActive } = props.menu?.meta || {} + const { iconDeActive, iconActive } = props.menu?.meta || {} + const title = getMenuTitle(props.menu?.meta) const icon = route.path === path ? iconActive : iconDeActive const iconCom: any = iconMap[icon] ? ElIcon : null return h( diff --git a/frontend/src/components/layout/index.vue b/frontend/src/components/layout/index.vue index 4a0097e41..66a310410 100644 --- a/frontend/src/components/layout/index.vue +++ b/frontend/src/components/layout/index.vue @@ -45,7 +45,7 @@ - {{ t(`menu.${item.meta.title}`) }} + {{ getMenuTitle(item.meta) }} @@ -140,7 +140,7 @@ - {{ t(`menu.${item.meta.title}`) }} + {{ getMenuTitle(item.meta) }} @@ -172,11 +172,13 @@ import icon_ai from '@/assets/svg/icon_ai.svg' import { ArrowLeftBold } from '@element-plus/icons-vue' import { useCache } from '@/utils/useCache' import { useI18n } from 'vue-i18n' +import { useMenuTitle } from './useMenuTitle' import LanguageSelector from '@/components/Language-selector/index.vue' import AboutDialog from '@/components/about/index.vue' const aboutRef = ref() const { t } = useI18n() +const getMenuTitle = useMenuTitle() const { wsCache } = useCache() const topLayout = ref(false) const router = useRouter() @@ -216,9 +218,9 @@ const showSubmenu = computed(() => { ] */ const currentPageTitle = computed(() => { if (route.path.includes('/system')) { - return 'System Settings' + return t('system.system_settings') } - return route.meta.title || 'Dashboard' + return getMenuTitle(route.meta) || t('dashboard.dashboard') }) const resolveIcon = (iconName: any) => { const icons: Record = { diff --git a/frontend/src/components/layout/useMenuTitle.ts b/frontend/src/components/layout/useMenuTitle.ts new file mode 100644 index 000000000..8bc60e4a9 --- /dev/null +++ b/frontend/src/components/layout/useMenuTitle.ts @@ -0,0 +1,13 @@ +import { useI18n } from 'vue-i18n' +import type { RouteMeta } from 'vue-router' + +export const useMenuTitle = () => { + const { t } = useI18n() + return (meta: RouteMeta = {}): string => { + if (typeof meta.titleKey === 'string') { + return t(meta.titleKey) + } + // Extension routes may supply an already formatted title. + return typeof meta.title === 'string' ? meta.title : '' + } +} diff --git a/frontend/src/router/dynamic.ts b/frontend/src/router/dynamic.ts index 7555741d0..7f64eb1b7 100644 --- a/frontend/src/router/dynamic.ts +++ b/frontend/src/router/dynamic.ts @@ -2,11 +2,9 @@ import LayoutDsl from '@/components/layout/LayoutDsl.vue' import Datasource from '@/views/ds/Datasource.vue' import SetAssistant from '@/views/system/embedded/iframe.vue' -import { i18n } from '@/i18n' import { useUserStore } from '@/stores/user' import type { Router } from 'vue-router' const userStore = useUserStore() -const t = i18n.global.t const dynamicRouterList = [ { @@ -19,7 +17,7 @@ const dynamicRouterList = [ path: 'index', name: 'ds', component: Datasource, - meta: { title: t('menu.Data Connections'), iconActive: 'ds', iconDeActive: 'noDs' }, + meta: { titleKey: 'menu.Data Connections', iconActive: 'ds', iconDeActive: 'noDs' }, }, ], }, @@ -34,13 +32,13 @@ const dynamicRouterList = [ name: 'as', component: SetAssistant, meta: { - title: t('embedded.assistant_app'), + titleKey: 'embedded.assistant_app', iconActive: 'embedded', iconDeActive: 'noEmbedded', }, }, ], - meta: { title: t('embedded.assistant_app') }, + meta: { titleKey: 'embedded.assistant_app' }, }, ] as any[] diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index b1c266752..8d1fadf9b 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -33,10 +33,8 @@ import Workspace from '@/views/system/workspace/index.vue' import Page401 from '@/views/error/index.vue' import ChatPreview from '@/views/chat/preview.vue' -import { i18n } from '@/i18n' import { watchRouter } from './watch' -const t = i18n.global.t export const routes = [ { path: '/login', @@ -55,7 +53,7 @@ export const routes = [ props: (route: any) => { return { startChatDsId: route.query.start_chat } }, - meta: { title: t('menu.Data Q&A'), iconActive: 'chat', iconDeActive: 'noChat' }, + meta: { titleKey: 'menu.Data Q&A', iconActive: 'chat', iconDeActive: 'noChat' }, }, ], }, @@ -81,7 +79,7 @@ export const routes = [ path: 'index', name: 'ds', component: Datasource, - meta: { title: t('menu.Data Connections'), iconActive: 'ds', iconDeActive: 'noDs' }, + meta: { titleKey: 'menu.Data Connections', iconActive: 'ds', iconDeActive: 'noDs' }, }, ], }, */ @@ -95,7 +93,7 @@ export const routes = [ name: 'dashboard', component: Dashboard, meta: { - title: t('dashboard.dashboard'), + titleKey: 'dashboard.dashboard', iconActive: 'dashboard', iconDeActive: 'noDashboard', }, @@ -107,43 +105,43 @@ export const routes = [ name: 'set', component: LayoutDsl, redirect: '/set/member', - meta: { title: t('workspace.set'), iconActive: 'set', iconDeActive: 'noSet' }, + meta: { titleKey: 'workspace.set', iconActive: 'set', iconDeActive: 'noSet' }, children: [ { path: '/set/member', name: 'member', component: Member, - meta: { title: t('workspace.member_management') }, + meta: { titleKey: 'workspace.member_management' }, }, { path: '/set/permission', name: 'permission', component: Permission, - meta: { title: t('workspace.permission_configuration') }, + meta: { titleKey: 'workspace.permission_configuration' }, }, /* { path: '/set/assistant', name: 'setAssistant', component: SetAssistant, - meta: { title: t('embedded.assistant_app') }, + meta: { titleKey: 'embedded.assistant_app' }, }, */ { path: '/set/professional', name: 'professional', component: Professional, - meta: { title: t('professional.professional_terminology') }, + meta: { titleKey: 'professional.professional_terminology' }, }, { path: '/set/training', name: 'training', component: Training, - meta: { title: t('training.data_training') }, + meta: { titleKey: 'training.data_training' }, }, { path: '/set/prompt', name: 'prompt', component: Prompt, - meta: { title: t('prompt.customize_prompt_words') }, + meta: { titleKey: 'prompt.customize_prompt_words' }, }, ], }, @@ -170,14 +168,14 @@ export const routes = [ path: 'user', name: 'user', component: User, - meta: { title: t('user.user_management'), iconActive: 'user', iconDeActive: 'noUser' }, + meta: { titleKey: 'user.user_management', iconActive: 'user', iconDeActive: 'noUser' }, }, { path: 'workspace', name: 'workspace', component: Workspace, meta: { - title: t('user.workspace'), + titleKey: 'user.workspace', iconActive: 'workspace', iconDeActive: 'noWorkspace', }, @@ -187,7 +185,7 @@ export const routes = [ name: 'model', component: Model, meta: { - title: t('model.ai_model_configuration'), + titleKey: 'model.ai_model_configuration', iconActive: 'model', iconDeActive: 'noModel', }, @@ -197,14 +195,14 @@ export const routes = [ name: 'embedded', component: SystemEmbedded, meta: { - title: t('embedded.embedded_management'), + titleKey: 'embedded.embedded_management', iconActive: 'embedded', iconDeActive: 'noEmbedded', }, }, { path: 'setting', - meta: { title: t('system.system_settings'), iconActive: 'set', iconDeActive: 'noSet' }, + meta: { titleKey: 'system.system_settings', iconActive: 'set', iconDeActive: 'noSet' }, redirect: 'system_/appearance', name: 'setting', children: [ @@ -212,31 +210,31 @@ export const routes = [ path: 'appearance', name: 'appearance', component: Appearance, - meta: { title: t('system.appearance_settings') }, + meta: { titleKey: 'system.appearance_settings' }, }, { path: 'parameter', name: 'parameter', component: Parameter, - meta: { title: t('parameter.parameter_configuration') }, + meta: { titleKey: 'parameter.parameter_configuration' }, }, { path: 'variables', name: 'variables', component: Variables, - meta: { title: t('variables.system_variables') }, + meta: { titleKey: 'variables.system_variables' }, }, { path: 'authentication', name: 'authentication', component: Authentication, - meta: { title: t('system.authentication_settings') }, + meta: { titleKey: 'system.authentication_settings' }, }, { path: 'platform', name: 'platform', component: Platform, - meta: { title: t('platform.title') }, + meta: { titleKey: 'platform.title' }, }, ], }, @@ -244,7 +242,7 @@ export const routes = [ path: 'audit', name: 'audit', component: Audit, - meta: { title: t('audit.system_log'), iconActive: 'log', iconDeActive: 'noLog' }, + meta: { titleKey: 'audit.system_log', iconActive: 'log', iconDeActive: 'noLog' }, }, ], }, diff --git a/tests/browser/README.md b/tests/browser/README.md index 0b0740604..e904bec37 100644 --- a/tests/browser/README.md +++ b/tests/browser/README.md @@ -100,3 +100,16 @@ npm --prefix tests/browser test -- --config=password.config.mjs 需要先安装 `frontend` 依赖。独立 Vite 夹具监听 `127.0.0.1:4208`,加载当前 checkout 的真实 `PwdForm.vue`、`userApi` 和 HTTP 请求封装,替换会话/Store 边界,并通过 Playwright 拦截改密响应。覆盖中英文策略拒绝提示、保留表单、修改后重试成功、允许保存及网络失败提示。测试使用合成密码,不访问业务数据库;它不验证真实系统参数保存、鉴权、数据库事务或重新登录。 后端对应离线测试为 `backend/tests/test_initial_password_policy.py`,提取实际函数并替换配置、用户和 Session 边界,验证开启/关闭策略、普通用户/管理员、旧密码与格式校验、拒绝时不写入。真实环境验收仍需在隔离账号上完成“开启策略 → 改密拒绝 → 合法改密 → 重新登录”。 + +## 菜单语言与切换账号回归 + +```sh +npm --prefix tests/browser test -- --config=menu-language.config.mjs +``` + +需要 Node.js 20+、本机 Chrome 及已安装的 `frontend` 依赖。独立 Vite 夹具仅监听 +`127.0.0.1:4217`,加载当前 checkout 的真实静态/动态路由、用户 Store、i18n、 +`Menu.vue` 和 `MenuItem.vue`,替换账号 API、缓存、页面内容及导航守卫。 +测试模拟英语账号退出后另一账号登录,覆盖五种语言的普通导航、设置子菜单、 +切回英语、管理员菜单即时切换语言、普通成员的动态菜单过滤及扩展直接标题兼容。 +账号均为合成数据,不连接业务后端;本测试不替代真实登录、权限鉴权、xpack 路由或完整布局验收。 diff --git a/tests/browser/fixtures/menu-language/bridge.mjs b/tests/browser/fixtures/menu-language/bridge.mjs new file mode 100644 index 000000000..65038c7e2 --- /dev/null +++ b/tests/browser/fixtures/menu-language/bridge.mjs @@ -0,0 +1,20 @@ +// Only account/network, storage and unrelated URL helpers are isolated. +// Menu, route definitions, Pinia actions and translations use production code. +const values = new Map([['user.language', 'en']]) +export const useCache = () => ({ wsCache: { + get: key => values.get(key), + set: (key, value) => values.set(key, value), + delete: key => values.delete(key) +} }) +export const getBrowserLocale = () => 'en' +export const getCurrentRouter = () => '' +export const getQueryString = () => '' +export const getSQLBotAddr = () => '' +export const isPlatform = () => false +let account = { id: 41, language: 'en', weight: 1 } +export const selectAccount = value => { account = value } +export const AuthApi = { + login: async () => ({ access_token: 'synthetic-menu-test-token' }), + logout: async () => null, + info: async () => ({ account: 'synthetic', name: 'Test', oid: 1, origin: 0, exp: 0, time: 0, ...account }) +} diff --git a/tests/browser/fixtures/menu-language/index.html b/tests/browser/fixtures/menu-language/index.html new file mode 100644 index 000000000..1f0360658 --- /dev/null +++ b/tests/browser/fixtures/menu-language/index.html @@ -0,0 +1,2 @@ + +Menu language regression
diff --git a/tests/browser/fixtures/menu-language/main.mjs b/tests/browser/fixtures/menu-language/main.mjs new file mode 100644 index 000000000..a0d84265d --- /dev/null +++ b/tests/browser/fixtures/menu-language/main.mjs @@ -0,0 +1,36 @@ +import { createApp, h, nextTick } from 'vue' +import { i18n } from '@/i18n' +import { store } from '@/stores' +import { useUserStore } from '@/stores/user' +import router from '@/router' +import { generateDynamicRouters } from '@/router/dynamic' +import Menu from '@/components/layout/Menu.vue' +import { selectAccount } from './bridge.mjs' + +const user = useUserStore() +// Extension routes are registered before mounting the layout, as in the app. +router.addRoute({ + path: '/extension', name: 'extension', component: { render: () => null }, + meta: { title: 'Extension title', iconActive: 'chat', iconDeActive: 'noChat' } +}) +async function signIn(account) { + selectAccount(account) + await user.login({ username: 'synthetic', password: 'synthetic' }) + await user.info() + generateDynamicRouters(router) + await router.push('/chat/index') + await nextTick() +} +await signIn({ id: 41, language: 'en', weight: 1 }) +const app = createApp({ render: () => h('main', [ + h('p', { id: 'language' }, user.getLanguage), + h('h1', i18n.global.t('menu.Data Q&A')), + router.currentRoute.value.path !== '/login' ? h(Menu) : null +]) }) +app.use(store).use(i18n).use(router).mount('#app') +window.menuFixture = { + signIn, + async signOut() { await user.logout(); await router.push('/login'); await nextTick() }, + async setLanguage(language) { user.setLanguage(language); await nextTick() }, + async go(path) { await router.push(path); await nextTick() } +} diff --git a/tests/browser/fixtures/menu-language/route-stub.mjs b/tests/browser/fixtures/menu-language/route-stub.mjs new file mode 100644 index 000000000..f4252a421 --- /dev/null +++ b/tests/browser/fixtures/menu-language/route-stub.mjs @@ -0,0 +1,3 @@ +// Page bodies and navigation guards are outside this component regression. +export const watchRouter = () => {} +export default { render: () => null } diff --git a/tests/browser/fixtures/menu-language/server.mjs b/tests/browser/fixtures/menu-language/server.mjs new file mode 100644 index 000000000..7f0977379 --- /dev/null +++ b/tests/browser/fixtures/menu-language/server.mjs @@ -0,0 +1,42 @@ +import { createRequire } from 'node:module' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = path.dirname(fileURLToPath(import.meta.url)) +const frontend = path.resolve(root, '../../../../frontend') +const require = createRequire(path.join(frontend, 'package.json')) +const { createServer } = await import(require.resolve('vite')) +const { default: vue } = await import(require.resolve('@vitejs/plugin-vue')) +const { default: svg } = await import(require.resolve('vite-svg-loader')) +const bridge = path.join(root, 'bridge.mjs') +const server = await createServer({ + configFile: false, + root, + cacheDir: path.resolve(root, '../../output/menu-language-vite'), + plugins: [ + { + name: 'isolate-pages', + enforce: 'pre', + resolveId(id, importer) { + if ((id.endsWith('.vue') && importer?.includes('/src/router/')) || + (id === './watch' && importer?.endsWith('/router/index.ts'))) { + return path.join(root, 'route-stub.mjs') + } + } + }, + vue(), + svg() + ], + resolve: { alias: [ + ...['@/api/login', '@/utils/useCache', '@/utils/utils'].map(find => ({ find, replacement: bridge })), + { find: '@', replacement: path.join(frontend, 'src') }, + ...['vue', 'vue-router', 'vue-i18n', 'pinia', 'element-plus-secondary', 'mitt'].map(name => ({ + find: name, replacement: path.join(frontend, 'node_modules', name) + })) + ] }, + server: { host: '127.0.0.1', port: 4217, strictPort: true, fs: { allow: [root, frontend] } } +}) +await server.listen() +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, async () => { await server.close(); process.exit() }) +} diff --git a/tests/browser/menu-language.config.mjs b/tests/browser/menu-language.config.mjs new file mode 100644 index 000000000..7faaf0aba --- /dev/null +++ b/tests/browser/menu-language.config.mjs @@ -0,0 +1,14 @@ +import { defineConfig } from '@playwright/test' +import base from './playwright.config.mjs' + +export default defineConfig({ + ...base, + webServer: { + command: 'node fixtures/menu-language/server.mjs', + url: 'http://127.0.0.1:4217', + reuseExistingServer: false, + timeout: 30000 + }, + projects: [{ name: 'menu-language', testMatch: 'menu-language.spec.mjs', use: { baseURL: 'http://127.0.0.1:4217' } }], + outputDir: 'output/menu-language-results' +}) diff --git a/tests/browser/specs/menu-language.spec.mjs b/tests/browser/specs/menu-language.spec.mjs new file mode 100644 index 000000000..98ea4eb91 --- /dev/null +++ b/tests/browser/specs/menu-language.spec.mjs @@ -0,0 +1,65 @@ +import { readFileSync } from 'node:fs' +import { test, expect } from '@playwright/test' + +const locales = ['zh-TW', 'zh-CN', 'en', 'ko-KR', 'th-TH'] +const messages = Object.fromEntries(locales.map(locale => [locale, + JSON.parse(readFileSync(new URL(`../../../frontend/src/i18n/${locale}.json`, import.meta.url), 'utf8')) +])) +const text = (locale, key) => key.split('.').reduce((value, part) => value[part], messages[locale]) +const primary = ['menu.Data Q&A', 'dashboard.dashboard', 'menu.Data Connections', 'embedded.assistant_app', 'workspace.set'] +const settings = ['workspace.member_management', 'workspace.permission_configuration', 'professional.professional_terminology', 'training.data_training', 'prompt.customize_prompt_words'] +const admin = ['user.user_management', 'user.workspace', 'model.ai_model_configuration', 'embedded.embedded_management', 'system.system_settings', 'audit.system_log'] +const systemSettings = ['system.appearance_settings', 'parameter.parameter_configuration', 'variables.system_variables', 'system.authentication_settings', 'platform.title'] + +async function expectLabels(page, locale, keys) { + for (const key of keys) { + await expect(page.getByRole('menubar').getByText(text(locale, key), { exact: true }).first()).toBeVisible() + } +} + +for (const locale of locales) { + test(`切换账号后普通导航和设置子菜单跟随 ${locale}`, async ({ page }) => { + const errors = [] + page.on('pageerror', error => errors.push(error.message)) + await page.goto('/') + await expectLabels(page, 'en', primary) + await page.evaluate(() => window.menuFixture.signOut()) + await expect(page.getByRole('menubar')).toHaveCount(0) + await page.evaluate(language => window.menuFixture.signIn({ id: 42, language, weight: 1 }), locale) + await expect(page.locator('#language')).toHaveText(locale) + await expect(page.getByRole('heading', { level: 1 })).toHaveText(text(locale, 'menu.Data Q&A')) + await expectLabels(page, locale, primary) + await page.getByText(text(locale, 'workspace.set'), { exact: true }).first().click() + await expectLabels(page, locale, settings) + await page.evaluate(() => window.menuFixture.signOut()) + await page.evaluate(() => window.menuFixture.signIn({ id: 41, language: 'en', weight: 1 })) + await expectLabels(page, 'en', primary) + expect(errors).toEqual([]) + }) + + test(`管理员菜单在不刷新页面时跟随 ${locale}`, async ({ page }) => { + await page.goto('/') + await page.evaluate(() => window.menuFixture.signOut()) + await page.evaluate(() => window.menuFixture.signIn({ id: 1, language: 'en', weight: 1 })) + await page.evaluate(() => window.menuFixture.go('/system/user')) + await expectLabels(page, 'en', admin) + await page.evaluate(language => window.menuFixture.setLanguage(language), locale) + await expectLabels(page, locale, admin) + await page.getByText(text(locale, 'system.system_settings'), { exact: true }).first().click() + await expectLabels(page, locale, systemSettings) + }) +} + +test('普通成员保持动态导航权限,扩展的直接标题保持可见', async ({ page }) => { + await page.goto('/') + await page.evaluate(() => window.menuFixture.signOut()) + await page.evaluate(() => window.menuFixture.signIn({ id: 43, language: 'zh-TW', weight: 0 })) + await expectLabels(page, 'zh-TW', primary.slice(0, 2)) + for (const key of primary.slice(2)) { + await expect(page.getByRole('menubar').getByText(text('zh-TW', key), { exact: true })).toHaveCount(0) + } + await page.evaluate(() => window.menuFixture.go('/extension')) + await expect(page.getByRole('menuitem', { name: 'Extension title' })).toBeVisible() + await page.evaluate(() => window.menuFixture.setLanguage('ko-KR')) + await expect(page.getByRole('menuitem', { name: 'Extension title' })).toBeVisible() +})