refactor(frontend): modularize i18n architecture

This commit is contained in:
elky
2026-06-30 17:01:39 +08:00
parent bf29129a4b
commit 9f138d09e6
118 changed files with 11940 additions and 5368 deletions
+84 -253
View File
@@ -15,7 +15,7 @@
<div class="flex w-full max-w-3xl items-center justify-between rounded-3xl bg-orange-500 px-6 py-3 text-white shadow-2xl ring-1 ring-white/30">
<div class="flex items-center gap-3">
<AlertTriangle class="h-5 w-5" />
<span>认证已过期,请重新登录</span>
<span>{{ t('auth.expired') }}</span>
</div>
<Button
variant="outline"
@@ -23,7 +23,7 @@
class="border-white/60 text-white hover:bg-white/10"
@click="handleRelogin"
>
重新登录
{{ t('auth.relogin') }}
</Button>
</div>
</template>
@@ -74,13 +74,13 @@
<RouterLink
to="/dashboard/settings"
class="p-1.5 hover:bg-muted/50 rounded-md text-muted-foreground hover:text-foreground transition-colors"
title="个人设置"
:title="t('common.settings')"
>
<Settings class="w-4 h-4" />
</RouterLink>
<button
class="p-1.5 rounded-md text-muted-foreground hover:text-red-500 transition-colors"
title="退出登录"
:title="t('common.logout')"
@click="handleLogout"
>
<LogOut class="w-4 h-4" />
@@ -131,9 +131,10 @@
@apply-update="handleApplySystemUpdate"
@rollback="handleRollback"
/>
<LanguageSwitcher />
<button
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
:title="themeMode === 'system' ? '跟随系统' : themeMode === 'dark' ? '深色模式' : '浅色模式'"
:title="themeModeTitle"
@click="toggleDarkMode"
>
<SunMoon
@@ -244,12 +245,14 @@
<RouterLink
to="/dashboard/settings"
class="p-2 hover:bg-muted/50 rounded-lg text-muted-foreground hover:text-foreground transition-colors"
:title="t('common.settings')"
@click="mobileMenuOpen = false"
>
<Settings class="w-4 h-4" />
</RouterLink>
<button
class="p-2 rounded-lg text-muted-foreground hover:text-red-500 transition-colors"
:title="t('common.logout')"
@click="handleLogout"
>
<LogOut class="w-4 h-4" />
@@ -298,7 +301,7 @@
class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 text-xs font-medium"
>
<AlertTriangle class="w-3.5 h-3.5" />
<span>演示模式</span>
<span>{{ t('demo.mode') }}</span>
</div>
<div class="flex items-center gap-2">
@@ -324,10 +327,11 @@
@apply-update="handleApplySystemUpdate"
@rollback="handleRollback"
/>
<LanguageSwitcher />
<!-- Theme Toggle -->
<button
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
:title="themeMode === 'system' ? '跟随系统' : themeMode === 'dark' ? '深色模式' : '浅色模式'"
:title="themeModeTitle"
@click="toggleDarkMode"
>
<SunMoon
@@ -349,7 +353,7 @@
target="_blank"
rel="noopener noreferrer"
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
title="GitHub 仓库"
:title="t('common.githubRepository')"
>
<GithubIcon class="h-4 w-4" />
</a>
@@ -363,8 +367,8 @@
v-model="requiredAnnouncementOpen"
persistent
size="lg"
title="必读公告"
description="请确认后继续使用"
:title="t('announcement.requiredTitle')"
:description="t('announcement.requiredDescription')"
>
<div
v-if="currentRequiredAnnouncement"
@@ -391,7 +395,7 @@
:disabled="acknowledgingRequiredAnnouncement"
@click="acknowledgeRequiredAnnouncement"
>
{{ acknowledgingRequiredAnnouncement ? '确认中...' : '确认已读' }}
{{ acknowledgingRequiredAnnouncement ? t('common.confirming') : t('common.confirmRead') }}
</Button>
</template>
</Dialog>
@@ -444,50 +448,27 @@ import { Dialog } from '@/components/ui'
import AppShell from '@/components/layout/AppShell.vue'
import SidebarNav from '@/components/layout/SidebarNav.vue'
import HeaderLogo from '@/components/HeaderLogo.vue'
import LanguageSwitcher from '@/components/common/LanguageSwitcher.vue'
import UpdateDialog from '@/components/common/UpdateDialog.vue'
import VersionButton from '@/components/common/VersionButton.vue'
import { buildUpdateErrorStatus } from '@/utils/updateStatus'
import {
Home,
Users,
Key,
KeyRound,
BarChart3,
Cog,
Settings,
Activity,
Shield,
AlertTriangle,
SunMedium,
Moon,
Gauge,
Layers,
FolderTree,
Database,
Box,
LogOut,
SunMoon,
ChevronRight,
Megaphone,
Wallet,
CreditCard,
Package,
Gift,
Menu,
X,
Puzzle,
Zap,
FileUp,
Send,
Server,
SlidersHorizontal,
type LucideIcon,
} from 'lucide-vue-next'
import GithubIcon from '@/components/icons/GithubIcon.vue'
import { BUILTIN_TOOL_BREADCRUMBS } from '@/config/builtin-tools'
import { prefetchAdminNavigationTarget } from '@/utils/adminNavigationPrefetch'
import { sanitizeMarkdown } from '@/utils/sanitize'
import { useI18n, type MessageKey } from '@/i18n'
import { buildBreadcrumbs, buildNavigation } from './main-layout/navigation'
type SystemUpdatePhase = 'download' | 'restart' | 'reconnecting'
@@ -498,6 +479,7 @@ const moduleStore = useModuleStore()
const { themeMode, toggleDarkMode } = useDarkMode()
const { siteName, siteSubtitle } = useSiteInfo()
const { success, error: showError } = useToast()
const { t, locale } = useI18n()
const isDemo = computed(() => isDemoMode())
const isAdmin = computed(() => authStore.user?.role === 'admin')
@@ -523,7 +505,7 @@ const updateSupported = ref(true)
const updateStrategy = ref('manual')
const updateCapabilityMessage = ref<string | null>(null)
const dockerUpdateCommand = ref<string | null>(null)
const reconnectMessage = ref('等待服务恢复...')
const reconnectMessage = ref(t('update.reconnect.waiting'))
const rollbackAvailable = ref(false)
const rollingBack = ref(false)
const updateTaskStatus = ref<UpdateTaskStatusResponse | null>(null)
@@ -532,28 +514,33 @@ const systemUpdatePhase = ref<SystemUpdatePhase>(readStoredSystemUpdatePhase())
const preparedUpdateVersion = ref<string | null>(
readSessionStorageItem('aether_prepared_update_version')
)
const SOURCE_BUILD_UPDATE_HINT = '当前为源码构建,请使用 git pull 后重新编译。'
const SOURCE_BUILD_RELEASE_HINT = '当前为源码构建,请手动切换到对应标签后重新编译。'
const MANUAL_UPDATE_HINT = '当前部署策略不支持在线自更新,请手动下载 Release 或使用安装脚本更新。'
const SOURCE_BUILD_UPDATE_HINT: MessageKey = 'update.error.sourceBuildUpdateHint'
const SOURCE_BUILD_RELEASE_HINT: MessageKey = 'update.error.sourceBuildReleaseHint'
const MANUAL_UPDATE_HINT: MessageKey = 'update.error.manualHint'
let versionStatusLoadPromise: Promise<CheckUpdateResponse | null> | null = null
let updateStatusPollTimer: number | null = null
const updateProgressPercent = computed(() => updateTaskStatus.value?.progress_percent ?? null)
const updateProgressText = computed(() => formatUpdateProgressText(updateTaskStatus.value))
const updateDialogTitle = computed(() => {
if (updateDialogMode.value === 'selected') {
return updateSupported.value ? '切换版本' : '版本详情'
return updateSupported.value ? t('update.title.selected') : t('update.title.selectedReadOnly')
}
return '发现新版本'
return t('update.title.latest')
})
const updateDialogVersionLabel = computed(() => {
if (updateDialogMode.value === 'selected') {
return updateSupported.value ? '目标版本' : '版本标签'
return updateSupported.value ? t('update.version.target') : t('update.version.tag')
}
return '最新版本'
return t('update.version.latest')
})
const updateDialogReleaseLinkLabel = computed(() => {
if (updateDialogMode.value === 'selected') return '查看标签页'
return updateSupported.value ? '查看更新' : '查看发布'
if (updateDialogMode.value === 'selected') return t('update.link.tag')
return updateSupported.value ? t('update.link.update') : t('update.link.release')
})
const themeModeTitle = computed(() => {
if (themeMode.value === 'system') return t('theme.system')
if (themeMode.value === 'dark') return t('theme.dark')
return t('theme.light')
})
watch(systemUpdatePhase, (val) => {
@@ -598,8 +585,10 @@ function removeSessionStorageItem(key: string) {
}
function formatUpdateProgressText(status: UpdateTaskStatusResponse | null): string {
if (!status) return '正在下载更新包...'
const label = status.progress_label ? `正在下载${status.progress_label}` : formatUpdateTaskPhase(status.phase)
if (!status) return t('update.progress.downloadPackage')
const label = status.progress_label
? t('update.progress.downloadingLabel', { label: status.progress_label })
: formatUpdateTaskPhase(status.phase)
const downloaded = status.downloaded_bytes
const total = status.total_bytes
if (typeof downloaded === 'number' && typeof total === 'number' && total > 0) {
@@ -614,17 +603,17 @@ function formatUpdateProgressText(status: UpdateTaskStatusResponse | null): stri
function formatUpdateTaskPhase(phase: string): string {
switch (phase) {
case 'downloading':
return '正在下载更新包'
return t('update.progress.downloadingPackage')
case 'downloading_checksum':
return '正在下载校验文件'
return t('update.progress.downloadingChecksum')
case 'verifying':
return '正在校验更新包'
return t('update.progress.verifying')
case 'extracting':
return '正在解压更新包'
return t('update.progress.extracting')
case 'prepared':
return '更新包已准备完成'
return t('update.progress.prepared')
default:
return '正在准备更新'
return t('update.progress.preparing')
}
}
@@ -650,10 +639,10 @@ async function waitForPreparedUpdate(): Promise<UpdateTaskStatusResponse> {
const status = updateTaskStatus.value
if (status?.phase === 'prepared') return status
if (status?.phase === 'failed') {
throw new Error(status.error || '下载更新失败')
throw new Error(status.error || t('update.error.downloadFailed'))
}
}
throw new Error('下载更新超时')
throw new Error(t('update.error.downloadTimeout'))
}
function startUpdateStatusPolling() {
@@ -737,8 +726,8 @@ function applyUpdateCapability(capability: SystemUpdateCapabilityResponse) {
dockerUpdateCommand.value = capability.docker_update_command || null
}
function updateUnsupportedMessage(fallback = MANUAL_UPDATE_HINT): string {
return updateCapabilityMessage.value || fallback
function updateUnsupportedMessage(fallback: MessageKey = MANUAL_UPDATE_HINT): string {
return updateCapabilityMessage.value || t(fallback)
}
function syncSystemUpdatePhase(status: CheckUpdateResponse | null) {
@@ -778,7 +767,7 @@ function buildUpdateInfoFromRelease(release: ReleaseEntry): CheckUpdateResponse
has_update: !release.is_current,
updatable: canSelfUpdate && !release.is_current && release.updatable,
update_blocker: release.is_current
? '当前已是这个版本'
? t('update.error.alreadyCurrent')
: !canSelfUpdate
? updateUnsupportedMessage(SOURCE_BUILD_RELEASE_HINT)
: release.update_blocker,
@@ -807,8 +796,8 @@ async function handleApplySystemUpdate() {
applyUpdateCapability(capability)
if (!capability.supported) {
showError(
updateUnsupportedMessage('不支持在线自更新'),
'不支持在线更新'
updateUnsupportedMessage('update.error.unsupported'),
t('update.error.unsupportedTitle')
)
return
}
@@ -817,8 +806,8 @@ async function handleApplySystemUpdate() {
const targetStatus = updateInfo.value || versionStatus.value
if (targetStatus?.has_update && targetStatus.updatable === false) {
showError(
targetStatus.update_blocker || '当前版本暂不支持在线更新',
'无法在线更新'
targetStatus.update_blocker || t('update.error.notUpdatable'),
t('update.error.cannotUpdateOnline')
)
return
}
@@ -830,7 +819,7 @@ async function handleApplySystemUpdate() {
const finalStatus = await waitForPreparedUpdate()
preparedUpdateVersion.value = targetVersion
systemUpdatePhase.value = 'restart'
success(finalStatus.output || result.message || '更新包已下载完成,请点击“立即重启”完成安装')
success(finalStatus.output || result.message || t('update.success.prepared'))
} finally {
stopUpdateStatusPolling()
void refreshUpdateTaskStatus()
@@ -839,14 +828,14 @@ async function handleApplySystemUpdate() {
}
const result = await adminApi.applySystemUpdate(preparedUpdateVersion.value)
success(result.message || '一键重启已启动')
success(result.message || t('update.success.restartStarted'))
systemUpdatePhase.value = 'reconnecting'
reconnectMessage.value = '服务正在重启...'
reconnectMessage.value = t('update.reconnect.restarting')
showUpdateDialog.value = true
applyingSystemUpdate.value = false
await pollHealthUntilReady()
} catch (err) {
const fallback = systemUpdatePhase.value === 'download' ? '下载更新失败' : '启动重启失败'
const fallback = systemUpdatePhase.value === 'download' ? t('update.error.downloadFailed') : t('update.error.restartFailed')
showError(parseApiError(err, fallback))
} finally {
applyingSystemUpdate.value = false
@@ -858,14 +847,14 @@ async function handleRollback() {
rollingBack.value = true
try {
const result = await adminApi.rollbackSystemUpdate()
success(result.message || '回滚已启动')
success(result.message || t('update.success.rollbackStarted'))
systemUpdatePhase.value = 'reconnecting'
reconnectMessage.value = '正在回滚到上一版本...'
reconnectMessage.value = t('update.reconnect.rollback')
showUpdateDialog.value = true
rollingBack.value = false
await pollHealthUntilReady()
} catch (err) {
showError(parseApiError(err, '回滚失败'))
showError(parseApiError(err, t('update.error.rollbackFailed')))
} finally {
rollingBack.value = false
}
@@ -877,20 +866,22 @@ async function pollHealthUntilReady() {
for (let i = 0; i < maxAttempts; i++) {
if (i < 3) {
reconnectMessage.value = i === 0 ? '服务正在重启...' : `服务正在重启... (${i * 2}s)`
reconnectMessage.value = i === 0
? t('update.reconnect.restarting')
: t('update.reconnect.restartingWithSeconds', { seconds: i * 2 })
await new Promise(r => setTimeout(r, intervalMs))
continue
}
const elapsed = i * 2
reconnectMessage.value = `等待服务恢复... (${elapsed}s)`
reconnectMessage.value = t('update.reconnect.waitingWithSeconds', { seconds: elapsed })
try {
const resp = await fetch('/_gateway/health', {
method: 'GET',
signal: AbortSignal.timeout(3000),
})
if (resp.ok) {
reconnectMessage.value = '服务已恢复,正在刷新...'
reconnectMessage.value = t('update.reconnect.ready')
await new Promise(r => setTimeout(r, 500))
window.location.replace(buildFreshReloadUrl())
return
@@ -904,7 +895,7 @@ async function pollHealthUntilReady() {
try {
const status = await adminApi.getUpdateStatus()
if (status.phase === 'failed' && status.error) {
reconnectMessage.value = `更新失败: ${status.error}`
reconnectMessage.value = t('update.error.updateFailedWithReason', { reason: status.error })
systemUpdatePhase.value = 'download'
return
}
@@ -916,7 +907,7 @@ async function pollHealthUntilReady() {
await new Promise(r => setTimeout(r, intervalMs))
}
reconnectMessage.value = '等待超时,请手动刷新页面'
reconnectMessage.value = t('update.reconnect.timeout')
systemUpdatePhase.value = 'download'
}
@@ -1039,7 +1030,7 @@ function renderRequiredAnnouncement(content: string): string {
}
function formatRequiredAnnouncementDate(value: string): string {
return new Date(value).toLocaleString('zh-CN')
return new Date(value).toLocaleString(locale.value)
}
async function acknowledgeRequiredAnnouncement() {
@@ -1111,188 +1102,28 @@ function prefetchNavigationItem(href: string) {
prefetchAdminNavigationTarget(href)
}
// Navigation Data
const navigation = computed(() => {
const baseNavigation = [
{
title: '概览',
items: [
{ name: '仪表盘', href: '/dashboard', icon: Home },
{ name: '健康监控', href: '/dashboard/endpoint-status', icon: Activity },
]
},
{
title: '资源',
items: [
{ name: '模型目录', href: '/dashboard/models', icon: Box },
{ name: 'API 密钥', href: '/dashboard/api-keys', icon: Key },
]
},
{
title: '账户',
items: [
{ name: '钱包中心', href: '/dashboard/wallet', icon: Wallet },
{ name: '套餐中心', href: '/dashboard/billing', icon: Package },
...(moduleStore.isActive('referral') ? [{ name: '我的邀请', href: '/dashboard/referral', icon: Gift }] : []),
{ name: '使用统计', href: '/dashboard/usage', icon: BarChart3 },
]
}
]
// 系统菜单项(静态部分)
const systemItems: { name: string; href: string; icon: LucideIcon }[] = [
{ name: '公告管理', href: '/admin/announcements', icon: Megaphone },
{ name: '缓存监控', href: '/admin/cache-monitoring', icon: Gauge },
]
// 动态添加已激活模块的菜单项
// 图标映射
const iconMap: Record<string, LucideIcon> = {
Key,
KeyRound,
FileUp,
Shield,
Puzzle,
Server,
Send,
SlidersHorizontal,
CreditCard,
Gift,
}
const activeModuleItems = (group: string) =>
Object.values(moduleStore.modules)
.filter(m => m.active && m.admin_route && m.admin_menu_group === group)
.sort((a, b) => a.admin_menu_order - b.admin_menu_order)
.map(m => ({
name: m.display_name,
href: m.admin_route ?? '',
icon: iconMap[m.admin_menu_icon || ''] || Puzzle
}))
systemItems.push(...activeModuleItems('system'))
// 模块管理和系统设置放在最后
systemItems.push({ name: '模块管理', href: '/admin/modules', icon: Puzzle })
systemItems.push({ name: '系统设置', href: '/admin/system', icon: Cog })
const adminNavigation = [
{
title: '概览',
items: [
{ name: '仪表盘', href: '/admin/dashboard', icon: Home },
{ name: '运维总览', href: '/admin/operations', icon: Activity },
{ name: '健康监控', href: '/admin/health-monitor', icon: Activity },
{ name: '用户统计', href: '/admin/user-stats', icon: BarChart3 },
{ name: '成本分析', href: '/admin/cost-analysis', icon: Gauge },
{ name: '性能分析', href: '/admin/performance-analysis', icon: Activity },
]
},
{
title: '管理',
items: [
{ name: '用户管理', href: '/admin/users', icon: Users },
{ name: '提供商', href: '/admin/providers', icon: FolderTree },
{ name: '模型管理', href: '/admin/models', icon: Layers },
{ name: '调度策略', href: '/admin/routing', icon: SlidersHorizontal },
{ name: '号池管理', href: '/admin/pool', icon: Database },
{ name: '独立密钥', href: '/admin/keys', icon: Key },
{ name: '钱包管理', href: '/admin/wallets', icon: Wallet },
{ name: '套餐管理', href: '/admin/billing-plans', icon: Package },
...activeModuleItems('management'),
{ name: '异步任务', href: '/admin/async-tasks', icon: Zap },
{ name: '使用记录', href: '/admin/usage', icon: BarChart3 },
]
},
{
title: '系统',
items: systemItems
}
]
return authStore.canAccessAdmin ? adminNavigation : baseNavigation
return buildNavigation({
canAccessAdmin: authStore.canAccessAdmin,
modules: moduleStore.modules,
isModuleActive: moduleStore.isActive,
t,
})
})
const currentRoleLabel = computed(() => {
if (authStore.isAdmin) return '管理员'
if (authStore.isAuditAdmin) return '审计管理员'
return '用户'
if (authStore.isAdmin) return t('auth.role.admin')
if (authStore.isAuditAdmin) return t('auth.role.auditAdmin')
return t('auth.role.user')
})
// Breadcrumbs
interface BreadcrumbItem {
label: string
href?: string
}
const breadcrumbs = computed((): BreadcrumbItem[] => {
// Special case: personal settings page accessed by admin
if (route.path === '/dashboard/settings') {
return [
{ label: '账户' },
{ label: '个人设置' }
]
}
// Special case: module config pages (e.g., /admin/ldap)
if (route.meta?.module) {
const moduleName = route.meta.module as string
const moduleStatus = moduleStore.modules[moduleName]
const displayName = moduleStatus?.display_name || moduleName
return [
{ label: '系统' },
{ label: '模块管理', href: '/admin/modules' },
{ label: displayName }
]
}
// Special case: built-in tools under module management
if (BUILTIN_TOOL_BREADCRUMBS[route.path]) {
return [
{ label: '系统' },
{ label: '模块管理', href: '/admin/modules' },
{ label: BUILTIN_TOOL_BREADCRUMBS[route.path] }
]
}
// Special case: routing strategy detail pages
if (route.path.startsWith('/admin/routing/') && route.path !== '/admin/routing') {
return [
{ label: '管理' },
{ label: '调度策略', href: '/admin/routing' },
{
label: route.name === 'RoutingProfileCreate'
? '新建调度策略'
: '调度策略配置'
}
]
}
// Find section and page from navigation
for (const group of navigation.value) {
const activeItem = group.items.find(item => isNavActive(item.href))
if (activeItem) {
return [
{ label: group.title || '' },
{ label: activeItem.name }
]
}
}
// Special case: module pages not in navigation (module not active)
// Check if current path matches a module's admin_route
const currentModule = Object.values(moduleStore.modules).find(
m => m.admin_route && route.path === m.admin_route
)
if (currentModule) {
return [
{ label: '模块管理', href: '/admin/modules' },
{ label: currentModule.display_name }
]
}
return [{ label: '仪表盘' }]
})
const breadcrumbs = computed(() => buildBreadcrumbs({
route,
navigation: navigation.value,
modules: moduleStore.modules,
isNavActive,
t,
}))
// Styling Classes (Editorial)
const sidebarClasses = computed(() => {
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest'
import type { RouteLocationNormalizedLoaded } from 'vue-router'
import { buildBreadcrumbs, buildNavigation } from '@/layouts/main-layout/navigation'
import type { MessageKey } from '@/i18n'
const translate = (key: MessageKey) => `tx:${key}`
function route(path: string, name?: string, meta: Record<string, unknown> = {}): RouteLocationNormalizedLoaded {
return {
path,
fullPath: path,
query: {},
hash: '',
name,
params: {},
matched: [],
meta,
redirectedFrom: undefined,
} as RouteLocationNormalizedLoaded
}
describe('main layout navigation builder', () => {
it('builds user navigation from translation keys and active modules', () => {
const navigation = buildNavigation({
canAccessAdmin: false,
modules: {},
isModuleActive: (name) => name === 'referral',
t: translate,
})
expect(navigation.map(group => group.title)).toEqual([
'tx:nav.group.overview',
'tx:nav.group.resources',
'tx:nav.group.account',
])
expect(navigation.flatMap(group => group.items.map(item => item.name))).toContain('tx:nav.myReferral')
})
it('builds admin navigation with dynamic module menu items sorted by menu order', () => {
const navigation = buildNavigation({
canAccessAdmin: true,
modules: {
first: {
active: true,
admin_route: '/admin/first',
admin_menu_group: 'management',
admin_menu_order: 2,
admin_menu_icon: 'Gift',
display_name: 'First module',
},
second: {
active: true,
admin_route: '/admin/second',
admin_menu_group: 'management',
admin_menu_order: 1,
admin_menu_icon: 'Key',
display_name: 'Second module',
},
},
isModuleActive: () => false,
t: translate,
})
const managementItems = navigation.find(group => group.title === 'tx:nav.group.management')?.items ?? []
expect(managementItems.map(item => item.name)).toEqual(expect.arrayContaining(['Second module', 'First module']))
expect(managementItems.findIndex(item => item.name === 'Second module')).toBeLessThan(
managementItems.findIndex(item => item.name === 'First module')
)
})
it('builds translated breadcrumbs for settings and routing detail pages', () => {
const navigation = buildNavigation({
canAccessAdmin: true,
modules: {},
isModuleActive: () => false,
t: translate,
})
expect(buildBreadcrumbs({
route: route('/dashboard/settings'),
navigation,
modules: {},
isNavActive: () => false,
t: translate,
})).toEqual([
{ label: 'tx:nav.group.account' },
{ label: 'tx:breadcrumb.personalSettings' },
])
expect(buildBreadcrumbs({
route: route('/admin/routing/new', 'RoutingProfileCreate'),
navigation,
modules: {},
isNavActive: href => href === '/admin/routing',
t: translate,
})).toEqual([
{ label: 'tx:nav.group.management' },
{ label: 'tx:nav.routing', href: '/admin/routing' },
{ label: 'tx:breadcrumb.routingCreate' },
])
})
})
@@ -0,0 +1,217 @@
import type { RouteLocationNormalizedLoaded } from 'vue-router'
import type { LucideIcon } from 'lucide-vue-next'
import {
Activity,
BarChart3,
Box,
Cog,
CreditCard,
Database,
FileUp,
FolderTree,
Gift,
Gauge,
Home,
Key,
KeyRound,
Layers,
Package,
Puzzle,
Send,
Server,
Shield,
SlidersHorizontal,
Users,
Wallet,
Zap,
Megaphone,
} from 'lucide-vue-next'
import type { NavigationGroup } from '@/components/layout/SidebarNav.vue'
import type { ModuleStatus } from '@/api/modules'
import { BUILTIN_TOOL_BREADCRUMBS } from '@/config/builtin-tools'
import type { MessageKey } from '@/i18n'
type ModuleRecord = Record<string, ModuleStatus>
type Translate = (key: MessageKey) => string
export interface BreadcrumbItem {
label: string
href?: string
}
type NavItem = NavigationGroup['items'][number]
const moduleIconMap: Record<string, LucideIcon> = {
Key,
KeyRound,
FileUp,
Shield,
Puzzle,
Server,
Send,
SlidersHorizontal,
CreditCard,
Gift,
}
function activeModuleItems(modules: ModuleRecord, group: string): NavItem[] {
return Object.values(modules)
.filter(m => m.active && m.admin_route && m.admin_menu_group === group)
.sort((a, b) => a.admin_menu_order - b.admin_menu_order)
.map(m => ({
name: m.display_name,
href: m.admin_route ?? '',
icon: moduleIconMap[m.admin_menu_icon || ''] || Puzzle
}))
}
export function buildNavigation(options: {
canAccessAdmin: boolean
modules: ModuleRecord
isModuleActive: (name: string) => boolean
t?: Translate
}): NavigationGroup[] {
const { canAccessAdmin, modules, isModuleActive } = options
const t = options.t ?? ((key: MessageKey) => key)
if (!canAccessAdmin) {
return [
{
title: t('nav.group.overview'),
items: [
{ name: t('nav.dashboard'), href: '/dashboard', icon: Home },
{ name: t('nav.healthMonitor'), href: '/dashboard/endpoint-status', icon: Activity },
]
},
{
title: t('nav.group.resources'),
items: [
{ name: t('nav.modelCatalog'), href: '/dashboard/models', icon: Box },
{ name: t('nav.apiKeys'), href: '/dashboard/api-keys', icon: Key },
]
},
{
title: t('nav.group.account'),
items: [
{ name: t('nav.walletCenter'), href: '/dashboard/wallet', icon: Wallet },
{ name: t('nav.billingCenter'), href: '/dashboard/billing', icon: Package },
...(isModuleActive('referral') ? [{ name: t('nav.myReferral'), href: '/dashboard/referral', icon: Gift }] : []),
{ name: t('nav.usageStats'), href: '/dashboard/usage', icon: BarChart3 },
]
}
]
}
const systemItems: NavItem[] = [
{ name: t('nav.announcements'), href: '/admin/announcements', icon: Megaphone },
{ name: t('nav.cacheMonitoring'), href: '/admin/cache-monitoring', icon: Gauge },
...activeModuleItems(modules, 'system'),
{ name: t('nav.moduleManagement'), href: '/admin/modules', icon: Puzzle },
{ name: t('nav.systemSettings'), href: '/admin/system', icon: Cog },
]
return [
{
title: t('nav.group.overview'),
items: [
{ name: t('nav.dashboard'), href: '/admin/dashboard', icon: Home },
{ name: t('nav.operations'), href: '/admin/operations', icon: Activity },
{ name: t('nav.healthMonitor'), href: '/admin/health-monitor', icon: Activity },
{ name: t('nav.userStats'), href: '/admin/user-stats', icon: BarChart3 },
{ name: t('nav.costAnalysis'), href: '/admin/cost-analysis', icon: Gauge },
{ name: t('nav.performanceAnalysis'), href: '/admin/performance-analysis', icon: Activity },
]
},
{
title: t('nav.group.management'),
items: [
{ name: t('nav.userManagement'), href: '/admin/users', icon: Users },
{ name: t('nav.providers'), href: '/admin/providers', icon: FolderTree },
{ name: t('nav.modelManagement'), href: '/admin/models', icon: Layers },
{ name: t('nav.routing'), href: '/admin/routing', icon: SlidersHorizontal },
{ name: t('nav.pool'), href: '/admin/pool', icon: Database },
{ name: t('nav.standaloneKeys'), href: '/admin/keys', icon: Key },
{ name: t('nav.walletManagement'), href: '/admin/wallets', icon: Wallet },
{ name: t('nav.billingManagement'), href: '/admin/billing-plans', icon: Package },
...activeModuleItems(modules, 'management'),
{ name: t('nav.asyncTasks'), href: '/admin/async-tasks', icon: Zap },
{ name: t('nav.usageRecords'), href: '/admin/usage', icon: BarChart3 },
]
},
{
title: t('nav.group.system'),
items: systemItems
}
]
}
export function buildBreadcrumbs(options: {
route: RouteLocationNormalizedLoaded
navigation: NavigationGroup[]
modules: ModuleRecord
isNavActive: (href: string) => boolean
t?: Translate
}): BreadcrumbItem[] {
const { route, navigation, modules, isNavActive } = options
const t = options.t ?? ((key: MessageKey) => key)
if (route.path === '/dashboard/settings') {
return [
{ label: t('nav.group.account') },
{ label: t('breadcrumb.personalSettings') }
]
}
if (route.meta?.module) {
const moduleName = route.meta.module as string
const moduleStatus = modules[moduleName]
const displayName = moduleStatus?.display_name || moduleName
return [
{ label: t('nav.group.system') },
{ label: t('nav.moduleManagement'), href: '/admin/modules' },
{ label: displayName }
]
}
if (BUILTIN_TOOL_BREADCRUMBS[route.path]) {
return [
{ label: t('nav.group.system') },
{ label: t('nav.moduleManagement'), href: '/admin/modules' },
{ label: BUILTIN_TOOL_BREADCRUMBS[route.path] }
]
}
if (route.path.startsWith('/admin/routing/') && route.path !== '/admin/routing') {
return [
{ label: t('nav.group.management') },
{ label: t('nav.routing'), href: '/admin/routing' },
{
label: route.name === 'RoutingProfileCreate'
? t('breadcrumb.routingCreate')
: t('breadcrumb.routingConfig')
}
]
}
for (const group of navigation) {
const activeItem = group.items.find(item => isNavActive(item.href))
if (activeItem) {
return [
{ label: group.title || '' },
{ label: activeItem.name }
]
}
}
const currentModule = Object.values(modules).find(
m => m.admin_route && route.path === m.admin_route
)
if (currentModule) {
return [
{ label: t('nav.moduleManagement'), href: '/admin/modules' },
{ label: currentModule.display_name }
]
}
return [{ label: t('nav.dashboard') }]
}