feat: 实现功能模块系统,支持模块的动态注册与启用控制

- 新增 ModuleRegistry 核心,支持模块注册、可用性检查和启用状态管理
- 新增模块管理 API(管理端和公共端),提供模块状态查询和启用控制
- 将 LDAP 功能迁移到模块系统,支持通过环境变量和数据库配置控制
- 前端新增模块管理页面和 store,支持模块启用/禁用操作
- 路由守卫集成模块状态检查,未激活模块的页面自动重定向
- 面包屑导航支持模块配置页面的层级显示
- Switch 组件新增 disabled 属性支持
This commit is contained in:
fawney19
2026-01-16 16:14:12 +08:00
parent 50343d5459
commit 6c98816f9f
17 changed files with 1332 additions and 39 deletions

View File

@@ -0,0 +1,63 @@
import apiClient from './client'
export interface ModuleStatus {
name: string
available: boolean
enabled: boolean
active: boolean
display_name: string
description: string
category: 'auth' | 'monitoring' | 'security' | 'integration'
admin_route: string | null
admin_menu_icon: string | null
admin_menu_group: string | null
admin_menu_order: number
health: 'healthy' | 'degraded' | 'unhealthy' | 'unknown'
}
export interface AuthModuleInfo {
name: string
display_name: string
active: boolean
}
export const modulesApi = {
/**
* 获取所有模块状态(管理员)
*/
async getAllStatus(): Promise<Record<string, ModuleStatus>> {
const response = await apiClient.get<Record<string, ModuleStatus>>(
'/api/admin/modules/status'
)
return response.data
},
/**
* 获取单个模块状态(管理员)
*/
async getStatus(moduleName: string): Promise<ModuleStatus> {
const response = await apiClient.get<ModuleStatus>(
`/api/admin/modules/status/${moduleName}`
)
return response.data
},
/**
* 设置模块启用状态(管理员)
*/
async setEnabled(moduleName: string, enabled: boolean): Promise<ModuleStatus> {
const response = await apiClient.put<ModuleStatus>(
`/api/admin/modules/status/${moduleName}/enabled`,
{ enabled }
)
return response.data
},
/**
* 获取认证模块状态(公开接口,供登录页使用)
*/
async getAuthModulesStatus(): Promise<AuthModuleInfo[]> {
const response = await apiClient.get<AuthModuleInfo[]>('/api/modules/auth-status')
return response.data
},
}

View File

@@ -3,7 +3,8 @@
type="button"
role="switch"
:aria-checked="modelValue"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:disabled="disabled"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:class="[
modelValue ? 'bg-primary' : 'bg-muted'
]"
@@ -21,6 +22,7 @@
<script setup lang="ts">
defineProps<{
modelValue: boolean
disabled?: boolean
}>()
defineEmits<{

View File

@@ -245,9 +245,24 @@
<header class="hidden lg:flex h-16 px-8 items-center justify-between shrink-0 border-b border-[#3d3929]/5 dark:border-white/5 sticky top-0 z-40 backdrop-blur-md bg-[#faf9f5]/90 dark:bg-[#191714]/90">
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<span>{{ currentSectionName }}</span>
<ChevronRight class="w-3 h-3 opacity-50" />
<span class="text-foreground font-medium">{{ currentPageName }}</span>
<template v-for="(crumb, index) in breadcrumbs" :key="index">
<template v-if="index > 0">
<ChevronRight class="w-3 h-3 opacity-50" />
</template>
<RouterLink
v-if="crumb.href && index < breadcrumbs.length - 1"
:to="crumb.href"
class="hover:text-foreground transition-colors"
>
{{ crumb.label }}
</RouterLink>
<span
v-else
:class="index === breadcrumbs.length - 1 ? 'text-foreground font-medium' : ''"
>
{{ crumb.label }}
</span>
</template>
</div>
</div>
@@ -313,6 +328,7 @@
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useModuleStore } from '@/stores/modules'
import { useDarkMode } from '@/composables/useDarkMode'
import { isDemoMode } from '@/config/demo'
import { adminApi, type CheckUpdateResponse } from '@/api/admin'
@@ -345,12 +361,14 @@ import {
Menu,
X,
Mail,
Puzzle,
} from 'lucide-vue-next'
import GithubIcon from '@/components/icons/GithubIcon.vue'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const moduleStore = useModuleStore()
const { themeMode, toggleDarkMode } = useDarkMode()
const isDemo = computed(() => isDemoMode())
@@ -415,6 +433,11 @@ onMounted(() => {
}
}, 5000)
// 管理员预加载模块状态(路由守卫会按需加载,这里提前加载以避免菜单闪烁)
if (authStore.user?.role === 'admin' && !moduleStore.loaded && !moduleStore.loading) {
moduleStore.fetchModules()
}
// 延迟检查更新,避免影响页面加载
setTimeout(() => {
checkForUpdate()
@@ -473,6 +496,19 @@ const navigation = computed(() => {
}
]
// 系统菜单项(静态部分)
const systemItems = [
{ name: '公告管理', href: '/admin/announcements', icon: Megaphone },
{ name: '缓存监控', href: '/admin/cache-monitoring', icon: Gauge },
{ name: 'IP 安全', href: '/admin/ip-security', icon: Shield },
{ name: '审计日志', href: '/admin/audit-logs', icon: AlertTriangle },
{ name: '邮件配置', href: '/admin/email', icon: Mail },
]
// 模块管理和系统设置放在最后
systemItems.push({ name: '模块管理', href: '/admin/modules', icon: Puzzle })
systemItems.push({ name: '系统设置', href: '/admin/system', icon: Cog })
const adminNavigation = [
{
title: '概览',
@@ -494,46 +530,52 @@ const navigation = computed(() => {
},
{
title: '系统',
items: [
{ name: '公告管理', href: '/admin/announcements', icon: Megaphone },
{ name: '缓存监控', href: '/admin/cache-monitoring', icon: Gauge },
{ name: 'IP 安全', href: '/admin/ip-security', icon: Shield },
{ name: '审计日志', href: '/admin/audit-logs', icon: AlertTriangle },
{ name: '邮件配置', href: '/admin/email', icon: Mail },
{ name: 'LDAP 配置', href: '/admin/ldap', icon: Shield },
{ name: '系统设置', href: '/admin/system', icon: Cog },
]
items: systemItems
}
]
return authStore.user?.role === 'admin' ? adminNavigation : baseNavigation
})
// Dynamic Header Title
const currentSectionName = computed(() => {
// Special case: personal settings page accessed by admin
if (route.path === '/dashboard/settings') {
return '账户'
}
// Find the group that contains the active item
for (const group of navigation.value) {
const hasActiveItem = group.items.some(item => isNavActive(item.href))
if (hasActiveItem) {
return group.title || ''
}
}
return ''
})
// Breadcrumbs
interface BreadcrumbItem {
label: string
href?: string
}
const currentPageName = computed(() => {
// Special case: personal settings page accessed by admin
if (route.path === '/dashboard/settings') {
return '个人设置'
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 }
]
}
// 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 }
]
}
// Flatten navigation to find matching item name
const allItems = navigation.value.flatMap(group => group.items)
const active = allItems.find(item => isNavActive(item.href))
return active ? active.name : route.name?.toString() || '仪表盘'
}
return [{ label: '仪表盘' }]
})
// Styling Classes (Editorial)

View File

@@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useModuleStore } from '@/stores/modules'
import { importWithRetry } from '@/utils/importRetry'
import { log } from '@/utils/logger'
@@ -116,6 +117,11 @@ const routes: RouteRecordRaw[] = [
name: 'SystemSettings',
component: () => importWithRetry(() => import('@/views/admin/SystemSettings.vue'))
},
{
path: 'modules',
name: 'ModuleManagement',
component: () => importWithRetry(() => import('@/views/admin/ModuleManagement.vue'))
},
{
path: 'email',
name: 'EmailSettings',
@@ -124,7 +130,8 @@ const routes: RouteRecordRaw[] = [
{
path: 'ldap',
name: 'LdapSettings',
component: () => importWithRetry(() => import('@/views/admin/LdapSettings.vue'))
component: () => importWithRetry(() => import('@/views/admin/LdapSettings.vue')),
meta: { module: 'ldap' }
},
{
path: 'audit-logs',
@@ -164,6 +171,7 @@ function isNetworkError(error: any): boolean {
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
const moduleStore = useModuleStore()
try {
// 如果有token但没有用户信息,尝试获取用户信息
@@ -186,6 +194,7 @@ router.beforeEach(async (to, from, next) => {
// 检查整个路由匹配记录链中的 meta
const requiresAuth = to.matched.some(record => record.meta.requiresAuth !== false)
const requiresAdmin = to.matched.some(record => record.meta.requiresAdmin)
const moduleName = to.matched.find(record => record.meta.module)?.meta.module as string | undefined
// 如果需要认证但没有token,跳转到首页
if (requiresAuth && !authStore.token) {
@@ -212,6 +221,26 @@ router.beforeEach(async (to, from, next) => {
log.warn('Non-admin user attempted to access admin page, redirecting to user dashboard')
next('/dashboard')
} else {
// 检查模块可用性
if (moduleName) {
// 确保模块状态已加载
if (!moduleStore.loaded) {
try {
await moduleStore.fetchModules()
} catch (error) {
// fail-close: 获取模块状态失败时拒绝访问
log.warn('Failed to fetch modules status, denying access', { error })
next('/admin/dashboard')
return
}
}
// 如果模块未激活available && enabled重定向到管理员首页
if (!moduleStore.isActive(moduleName)) {
log.warn(`Module ${moduleName} is not active, redirecting to admin dashboard`)
next('/admin/dashboard')
return
}
}
next()
}
} else {

View File

@@ -0,0 +1,109 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { modulesApi, type ModuleStatus } from '@/api/modules'
import { log } from '@/utils/logger'
export const useModuleStore = defineStore('modules', () => {
const modules = ref<Record<string, ModuleStatus>>({})
const loaded = ref(false)
const loading = ref(false)
const error = ref<string | null>(null)
/**
* 获取所有模块状态
*/
async function fetchModules() {
if (loading.value) return
loading.value = true
error.value = null
try {
modules.value = await modulesApi.getAllStatus()
loaded.value = true
} catch (err: any) {
log.error('Failed to fetch modules status', err)
error.value = err.response?.data?.detail || '获取模块状态失败'
} finally {
loading.value = false
}
}
/**
* 检查模块是否部署可用
*/
function isAvailable(moduleName: string): boolean {
return modules.value[moduleName]?.available ?? false
}
/**
* 检查模块是否运行启用
*/
function isEnabled(moduleName: string): boolean {
return modules.value[moduleName]?.enabled ?? false
}
/**
* 检查模块是否最终激活
*/
function isActive(moduleName: string): boolean {
return modules.value[moduleName]?.active ?? false
}
/**
* 设置模块启用状态
*/
async function setEnabled(moduleName: string, enabled: boolean) {
try {
await modulesApi.setEnabled(moduleName, enabled)
// 刷新所有模块状态,确保依赖模块的 active 状态同步更新
await fetchModules()
return true
} catch (err: any) {
log.error(`Failed to set module ${moduleName} enabled=${enabled}`, err)
error.value = err.response?.data?.detail || '设置模块状态失败'
return false
}
}
/**
* 获取可用的管理菜单项available 即显示)
*/
const availableAdminMenuItems = computed(() => {
return Object.values(modules.value)
.filter((m) => m.available && m.admin_route)
.sort((a, b) => a.admin_menu_order - b.admin_menu_order)
})
/**
* 按分组获取可用的管理菜单项
*/
const availableAdminMenuItemsByGroup = computed(() => {
const items = availableAdminMenuItems.value
const groups: Record<string, ModuleStatus[]> = {}
for (const item of items) {
const group = item.admin_menu_group || 'other'
if (!groups[group]) {
groups[group] = []
}
groups[group].push(item)
}
return groups
})
return {
modules,
loaded,
loading,
error,
fetchModules,
isAvailable,
isEnabled,
isActive,
setEnabled,
availableAdminMenuItems,
availableAdminMenuItemsByGroup,
}
})

View File

@@ -0,0 +1,269 @@
<template>
<PageContainer>
<PageHeader
title="模块管理"
description="管理系统功能模块的启用状态"
>
<template #actions>
<Button
variant="outline"
:disabled="loading"
@click="fetchModules"
>
<RefreshCw class="w-4 h-4 mr-2" :class="{ 'animate-spin': loading }" />
刷新
</Button>
</template>
</PageHeader>
<!-- 搜索栏 -->
<div class="mt-6 mb-6">
<div class="relative">
<Search class="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
v-model="searchQuery"
placeholder="搜索模块名称或描述..."
class="pl-11 h-11"
/>
</div>
</div>
<div>
<!-- 模块卡片网格 -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
<div
v-for="module in filteredModules"
:key="module.name"
class="group relative border rounded-2xl p-6 transition-all duration-200 hover:shadow-lg"
:class="{
'bg-muted/40 border-muted': !module.available,
'border-primary/40 bg-gradient-to-br from-primary/5 to-primary/10 shadow-sm': module.active,
'border-border bg-card hover:border-primary/20': !module.active && module.available
}"
>
<!-- 状态指示器 -->
<div class="absolute top-5 right-5">
<div
class="w-2.5 h-2.5 rounded-full ring-2 ring-offset-2 ring-offset-background"
:class="{
'bg-green-500 ring-green-500/30': module.active,
'bg-amber-500 ring-amber-500/30': module.available && module.enabled && !module.active,
'bg-gray-300 ring-gray-300/30': module.available && !module.enabled,
'bg-red-400 ring-red-400/30': !module.available
}"
/>
</div>
<!-- 模块图标和名称 -->
<div class="flex items-start gap-4 mb-4">
<div
class="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 transition-colors"
:class="module.active
? 'bg-primary/15 text-primary'
: 'bg-muted text-muted-foreground group-hover:bg-muted/80'"
>
<component :is="getCategoryIcon(module.category)" class="w-6 h-6" />
</div>
<div class="flex-1 min-w-0 pt-0.5">
<h4 class="font-semibold text-base truncate">{{ module.display_name }}</h4>
<div class="mt-1.5">
<Badge
:variant="getStatusBadgeVariant(module)"
class="text-xs"
>
{{ getStatusText(module) }}
</Badge>
</div>
</div>
</div>
<!-- 描述 -->
<p class="text-sm text-muted-foreground leading-relaxed line-clamp-2 min-h-[2.5rem]">
{{ module.description }}
</p>
<!-- 模块信息 -->
<div class="mt-4 pt-4 border-t border-border/50 space-y-2">
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span class="font-mono bg-muted/50 px-1.5 py-0.5 rounded">{{ module.name }}</span>
<span class="text-border">|</span>
<span :class="{
'text-green-600': module.health === 'healthy',
'text-amber-600': module.health === 'degraded',
'text-red-600': module.health === 'unhealthy',
}">
{{ getHealthText(module.health) }}
</span>
</div>
</div>
<!-- 不可用提示 -->
<div
v-if="!module.available"
class="mt-4 text-xs text-orange-700 dark:text-orange-400 bg-orange-100 dark:bg-orange-950/50 rounded-lg px-3 py-2"
>
模块不可用请检查环境变量和依赖库
</div>
<!-- 操作区域 -->
<div class="mt-5 pt-4 border-t border-border/50 flex items-center justify-between">
<div class="flex items-center gap-3">
<Switch
:model-value="module.enabled"
:disabled="!module.available || toggling[module.name]"
@update:model-value="(val: boolean) => toggleModule(module.name, val)"
/>
<span class="text-sm" :class="module.enabled ? 'text-foreground' : 'text-muted-foreground'">
{{ module.enabled ? '已启用' : '已禁用' }}
</span>
</div>
<Button
v-if="module.admin_route && module.active"
variant="outline"
size="sm"
class="gap-1.5"
@click="router.push(module.admin_route)"
>
<Settings class="w-3.5 h-3.5" />
配置
</Button>
</div>
</div>
</div>
<!-- 搜索无结果 -->
<div
v-if="filteredModules.length === 0 && searchQuery && !loading"
class="text-center py-16"
>
<Search class="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
<p class="text-muted-foreground">没有找到匹配的模块</p>
</div>
<!-- 空状态 -->
<div
v-if="allModules.length === 0 && !loading"
class="text-center py-16"
>
<Puzzle class="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
<p class="text-muted-foreground">暂无可管理的模块</p>
</div>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { RefreshCw, Puzzle, Users, Shield, Gauge, Link, Search, Settings } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import Switch from '@/components/ui/switch.vue'
import Input from '@/components/ui/input.vue'
import { PageHeader, PageContainer } from '@/components/layout'
import { useToast } from '@/composables/useToast'
import { useModuleStore } from '@/stores/modules'
import type { ModuleStatus } from '@/api/modules'
import { log } from '@/utils/logger'
const router = useRouter()
const { success, error } = useToast()
const moduleStore = useModuleStore()
const loading = ref(false)
const toggling = ref<Record<string, boolean>>({})
const searchQuery = ref('')
// 获取分类图标
function getCategoryIcon(category: string) {
const icons: Record<string, any> = {
auth: Users,
monitoring: Gauge,
security: Shield,
integration: Link,
}
return icons[category] || Puzzle
}
// 获取状态文本
function getStatusText(module: ModuleStatus): string {
if (!module.available) return '不可用'
if (module.active) return '已激活'
if (module.enabled) return '已启用'
return '已禁用'
}
// 获取状态徽章样式
function getStatusBadgeVariant(module: ModuleStatus): 'default' | 'secondary' | 'outline' | 'destructive' {
if (!module.available) return 'destructive'
if (module.active) return 'default'
if (module.enabled) return 'secondary'
return 'outline'
}
// 获取健康状态文本
function getHealthText(health: string): string {
const texts: Record<string, string> = {
healthy: '健康',
degraded: '降级',
unhealthy: '异常',
unknown: '未知',
}
return texts[health] || health
}
// 所有模块列表(按 admin_menu_order 排序)
const allModules = computed(() => {
return Object.values(moduleStore.modules)
.sort((a, b) => a.admin_menu_order - b.admin_menu_order)
})
// 过滤后的模块列表
const filteredModules = computed(() => {
if (!searchQuery.value.trim()) {
return allModules.value
}
const query = searchQuery.value.toLowerCase()
return allModules.value.filter(
(m) =>
m.name.toLowerCase().includes(query) ||
m.display_name.toLowerCase().includes(query) ||
m.description.toLowerCase().includes(query)
)
})
// 获取模块列表
async function fetchModules() {
loading.value = true
try {
await moduleStore.fetchModules()
} catch (err) {
error('获取模块列表失败')
log.error('获取模块列表失败:', err)
} finally {
loading.value = false
}
}
// 切换模块启用状态
async function toggleModule(moduleName: string, enabled: boolean) {
toggling.value[moduleName] = true
try {
const result = await moduleStore.setEnabled(moduleName, enabled)
if (result) {
success(enabled ? '模块已启用' : '模块已禁用')
} else {
error('操作失败')
}
} catch (err) {
error('操作失败')
log.error('切换模块状态失败:', err)
} finally {
toggling.value[moduleName] = false
}
}
onMounted(() => {
fetchModules()
})
</script>