mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 10:57:03 +08:00
refactor(frontend): modularize i18n architecture
This commit is contained in:
@@ -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') }]
|
||||
}
|
||||
Reference in New Issue
Block a user