Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/agents/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ request.get('/path', {
- `userStore.isAdmin` 与 `isSpaceAdmin` 的路由差异;
- xpack 静态脚本加载失败路径:`LicenseGenerator` 是无类型声明的 window 全局,登录/改密加密(`sqlbotEncrypt`)和动态路由注册都硬依赖它,脚本加载失败只提示并中断导航,没有降级。
- 新路由必须配置名称、标题 i18n 和正确父布局;不要绕过已有访问控制。
- 菜单路由的翻译键存入 `meta.titleKey`,由布局的 `useMenuTitle` 在渲染时翻译;不要在路由模块初始化时调用 `t()` 固定标题。扩展路由提供的 `meta.title` 作为直接显示的标题兼容保留。

## 视图、组件与样式

Expand Down
8 changes: 6 additions & 2 deletions frontend/src/components/layout/MenuItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -52,6 +53,7 @@ const MenuItem = defineComponent({
},
},
setup(props) {
const getMenuTitle = useMenuTitle()
const router = useRouter()
const route = useRoute()
const titleWithIcon = (props: any) => {
Expand All @@ -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,
Expand All @@ -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(
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/components/layout/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
<el-icon v-if="item.meta.icon">
<component :is="resolveIcon(item.meta.icon)" />
</el-icon>
<span>{{ t(`menu.${item.meta.title}`) }}</span>
<span>{{ getMenuTitle(item.meta) }}</span>
</el-menu-item>
</el-menu>

Expand Down Expand Up @@ -140,7 +140,7 @@
<el-icon v-if="item.meta.icon">
<component :is="resolveIcon(item.meta.icon)" />
</el-icon>
<span>{{ t(`menu.${item.meta.title}`) }}</span>
<span>{{ getMenuTitle(item.meta) }}</span>
</el-menu-item>
</el-menu>
</div>
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<string, any> = {
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/components/layout/useMenuTitle.ts
Original file line number Diff line number Diff line change
@@ -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 : ''
}
}
8 changes: 3 additions & 5 deletions frontend/src/router/dynamic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand All @@ -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' },
},
],
},
Expand All @@ -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[]

Expand Down
44 changes: 21 additions & 23 deletions frontend/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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' },
},
],
},
Expand All @@ -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' },
},
],
}, */
Expand All @@ -95,7 +93,7 @@ export const routes = [
name: 'dashboard',
component: Dashboard,
meta: {
title: t('dashboard.dashboard'),
titleKey: 'dashboard.dashboard',
iconActive: 'dashboard',
iconDeActive: 'noDashboard',
},
Expand All @@ -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' },
},
],
},
Expand All @@ -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',
},
Expand All @@ -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',
},
Expand All @@ -197,54 +195,54 @@ 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: [
{
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' },
},
],
},
{
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' },
},
],
},
Expand Down
13 changes: 13 additions & 0 deletions tests/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 路由或完整布局验收。
20 changes: 20 additions & 0 deletions tests/browser/fixtures/menu-language/bridge.mjs
Original file line number Diff line number Diff line change
@@ -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 })
}
2 changes: 2 additions & 0 deletions tests/browser/fixtures/menu-language/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<!doctype html>
<html><head><title>Menu language regression</title></head><body><div id="app"></div><script type="module" src="/main.mjs"></script></body></html>
36 changes: 36 additions & 0 deletions tests/browser/fixtures/menu-language/main.mjs
Original file line number Diff line number Diff line change
@@ -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() }
}
3 changes: 3 additions & 0 deletions tests/browser/fixtures/menu-language/route-stub.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Page bodies and navigation guards are outside this component regression.
export const watchRouter = () => {}
export default { render: () => null }
Loading
Loading