This commit is contained in:
yuk255 2025-09-29 22:03:44 +08:00
commit 8d2b4037d4
6 changed files with 979 additions and 0 deletions

35
src/api/modules/menu.ts Normal file
View File

@ -0,0 +1,35 @@
// 菜单相关API
import { ApiRequest } from '../request'
import type { ApiResponse } from '../types'
// 菜单项接口定义
export interface MenuItem {
id: string
name: string
path: string
type: string
icon: string | null
parentId: string | null
sortOrder: number
izVisible: number
permissionKey: string | null
}
// 菜单API类
export class MenuApi {
/**
*
*/
static async getIndexMenus(): Promise<ApiResponse<MenuItem[]>> {
return await ApiRequest.get('/aiol/aiolMenu/getIndexMenus')
}
/**
*
*/
static async getStudentMenus(): Promise<ApiResponse<MenuItem[]>> {
return await ApiRequest.get('/aiol/aiolMenu/getStudentMenus')
}
}
export default MenuApi

View File

@ -0,0 +1,186 @@
<template>
<div class="dynamic-navigation">
<!-- 加载状态 -->
<div v-if="menuStore.loading" class="loading">
<n-spin size="small" />
<span>加载菜单中...</span>
</div>
<!-- 错误状态 -->
<div v-else-if="menuStore.error" class="error">
<n-alert type="error" :title="menuStore.error" />
<n-button @click="handleRetry" size="small" type="primary">
重试
</n-button>
</div>
<!-- 菜单列表 -->
<nav v-else class="nav-menu">
<router-link
v-for="menu in menuStore.navMenus"
:key="menu.id"
:to="menu.path"
class="nav-item"
:class="{ active: isCurrentRoute(menu.path) }"
>
<n-icon v-if="menu.icon" :component="getIcon(menu.icon)" />
<span>{{ menu.name }}</span>
</router-link>
</nav>
<!-- 调试信息开发环境 -->
<div v-if="isDev" class="debug-info">
<n-collapse>
<n-collapse-item title="菜单调试信息" name="debug">
<pre>{{ JSON.stringify(menuStore.indexMenus, null, 2) }}</pre>
</n-collapse-item>
</n-collapse>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import {
NSpin,
NAlert,
NButton,
NIcon,
NCollapse,
NCollapseItem
} from 'naive-ui'
import {
HomeOutlined,
BookOutlined,
TeamOutlined,
FileTextOutlined,
CalendarOutlined,
RobotOutlined
} from '@vicons/antd'
import { useMenuStore } from '@/stores/menu'
//
const iconMap: Record<string, any> = {
home: HomeOutlined,
book: BookOutlined,
team: TeamOutlined,
resource: FileTextOutlined,
activity: CalendarOutlined,
ai: RobotOutlined,
}
const menuStore = useMenuStore()
const route = useRoute()
//
const isDev = computed(() => import.meta.env.DEV)
//
const getIcon = (iconName: string) => {
return iconMap[iconName] || HomeOutlined
}
//
const isCurrentRoute = (path: string) => {
return route.path === path
}
//
const handleRetry = async () => {
await menuStore.fetchIndexMenus()
}
//
onMounted(async () => {
if (menuStore.indexMenus.length === 0) {
await menuStore.fetchIndexMenus()
}
})
</script>
<style scoped>
.dynamic-navigation {
width: 100%;
}
.loading {
display: flex;
align-items: center;
gap: 8px;
padding: 16px;
color: #666;
}
.error {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.nav-menu {
display: flex;
flex-direction: column;
gap: 4px;
}
.nav-item {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
text-decoration: none;
color: #333;
border-radius: 6px;
transition: all 0.2s ease;
}
.nav-item:hover {
background-color: #f5f5f5;
color: #1890ff;
}
.nav-item.active {
background-color: #e6f7ff;
color: #1890ff;
font-weight: 500;
}
.debug-info {
margin-top: 16px;
padding: 16px;
background-color: #f8f9fa;
border-radius: 6px;
}
.debug-info pre {
font-size: 12px;
line-height: 1.4;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}
/* 响应式设计 */
@media (max-width: 768px) {
.nav-menu {
flex-direction: row;
overflow-x: auto;
padding: 8px 0;
}
.nav-item {
flex-shrink: 0;
min-width: 80px;
justify-content: center;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
}
.nav-item span {
font-size: 12px;
}
}
</style>

106
src/stores/menu.ts Normal file
View File

@ -0,0 +1,106 @@
// 菜单状态管理
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { MenuItem } from '@/api/modules/menu'
import { MenuApi } from '@/api/modules/menu'
import { generateNavMenus } from '@/utils/routeUtils'
export const useMenuStore = defineStore('menu', () => {
// 状态
const indexMenus = ref<MenuItem[]>([])
const studentMenus = ref<MenuItem[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
// 计算属性
const navMenus = computed(() => generateNavMenus(indexMenus.value))
const visibleIndexMenus = computed(() =>
indexMenus.value
.filter(menu => menu.izVisible === 1)
.sort((a, b) => b.sortOrder - a.sortOrder)
)
const visibleStudentMenus = computed(() =>
studentMenus.value
.filter(menu => menu.izVisible === 1)
.sort((a, b) => b.sortOrder - a.sortOrder)
)
// 方法
const fetchIndexMenus = async () => {
try {
loading.value = true
error.value = null
const response = await MenuApi.getIndexMenus()
if (response.code === 200) {
indexMenus.value = response.data || []
console.log('✅ 首页菜单加载成功:', indexMenus.value)
} else {
throw new Error(response.message || '获取首页菜单失败')
}
} catch (err: any) {
error.value = err.message || '获取首页菜单失败'
console.error('❌ 获取首页菜单失败:', err)
} finally {
loading.value = false
}
}
const fetchStudentMenus = async () => {
try {
loading.value = true
error.value = null
const response = await MenuApi.getStudentMenus()
if (response.code === 200) {
studentMenus.value = response.data || []
console.log('✅ 学生菜单加载成功:', studentMenus.value)
} else {
throw new Error(response.message || '获取学生菜单失败')
}
} catch (err: any) {
error.value = err.message || '获取学生菜单失败'
console.error('❌ 获取学生菜单失败:', err)
} finally {
loading.value = false
}
}
const getMenuByPath = (path: string, type: 'index' | 'student' = 'index'): MenuItem | undefined => {
const menus = type === 'index' ? indexMenus.value : studentMenus.value
return menus.find(menu => menu.path === path)
}
const isRouteVisible = (path: string, type: 'index' | 'student' = 'index'): boolean => {
const menu = getMenuByPath(path, type)
return menu ? menu.izVisible === 1 : false
}
const clearMenus = () => {
indexMenus.value = []
studentMenus.value = []
error.value = null
}
return {
// 状态
indexMenus,
studentMenus,
loading,
error,
// 计算属性
navMenus,
visibleIndexMenus,
visibleStudentMenus,
// 方法
fetchIndexMenus,
fetchStudentMenus,
getMenuByPath,
isRouteVisible,
clearMenus,
}
})

114
src/utils/routeUtils.ts Normal file
View File

@ -0,0 +1,114 @@
// 路由工具函数
import type { RouteRecordRaw } from 'vue-router'
import type { MenuItem } from '@/api/modules/menu'
// 组件映射表 - 将路径映射到对应的组件
const componentMap: Record<string, () => Promise<any>> = {
'/': () => import('@/views/Home.vue'),
'/courses': () => import('@/views/Courses.vue'),
'/special-training': () => import('@/views/SpecialTraining.vue'),
'/faculty': () => import('@/views/Faculty.vue'),
'/resources': () => import('@/views/Resources.vue'),
'/activities': () => import('@/views/Activities.vue'),
'/ai/app': () => import('@/views/Ai/AiAppList-NaiveUI.vue'),
}
// 路由名称映射表 - 将路径映射到路由名称
const routeNameMap: Record<string, string> = {
'/': 'Home',
'/courses': 'Courses',
'/special-training': 'SpecialTraining',
'/faculty': 'Faculty',
'/resources': 'Resources',
'/activities': 'Activities',
'/ai/app': 'AiAppList',
}
// 路由标题映射表 - 将路径映射到页面标题
const routeTitleMap: Record<string, string> = {
'/': '首页',
'/courses': '课程列表',
'/special-training': '专题训练',
'/faculty': '师资力量',
'/resources': '精选资源',
'/activities': '全部活动',
'/ai/app': 'AI应用管理',
}
/**
*
* @param menus
* @returns
*/
export function generateRoutesFromMenus(menus: MenuItem[]): RouteRecordRaw[] {
const routes: RouteRecordRaw[] = []
// 过滤可见的菜单项并按排序字段排序
const visibleMenus = menus
.filter(menu => menu.izVisible === 1)
.sort((a, b) => b.sortOrder - a.sortOrder) // 降序排列
for (const menu of visibleMenus) {
const { path, name } = menu
// 检查路径是否有对应的组件
if (!componentMap[path]) {
console.warn(`路径 ${path} 没有对应的组件映射,跳过生成路由`)
continue
}
const route: RouteRecordRaw = {
path,
name: routeNameMap[path] || name,
component: componentMap[path],
meta: {
title: routeTitleMap[path] || name,
menuId: menu.id,
sortOrder: menu.sortOrder,
permissionKey: menu.permissionKey,
}
}
routes.push(route)
}
return routes
}
/**
*
* @param menus
* @returns
*/
export function generateNavMenus(menus: MenuItem[]) {
return menus
.filter(menu => menu.izVisible === 1)
.sort((a, b) => b.sortOrder - a.sortOrder)
.map(menu => ({
id: menu.id,
name: menu.name,
path: menu.path,
icon: menu.icon,
sortOrder: menu.sortOrder,
}))
}
/**
*
* @param path
* @param menus
* @returns
*/
export function isRouteInMenus(path: string, menus: MenuItem[]): boolean {
return menus.some(menu => menu.path === path && menu.izVisible === 1)
}
/**
*
* @param path
* @param menus
* @returns undefined
*/
export function getMenuByPath(path: string, menus: MenuItem[]): MenuItem | undefined {
return menus.find(menu => menu.path === path)
}

320
src/views/AiModelTest.vue Normal file
View File

@ -0,0 +1,320 @@
<template>
<div class="ai-model-test">
<n-card title="AI模型字典API测试">
<n-space vertical size="large">
<!-- 操作按钮 -->
<n-card title="操作" size="small">
<n-space>
<n-button
@click="handleLoadModels"
:loading="loading"
type="primary"
>
加载AI模型列表
</n-button>
<n-button
@click="handleDirectRequest"
:loading="loading"
type="info"
>
直接请求测试
</n-button>
<n-button @click="handleClearModels">
清空数据
</n-button>
</n-space>
</n-card>
<!-- 状态信息 -->
<n-card title="状态信息" size="small">
<n-descriptions :column="2" bordered>
<n-descriptions-item label="加载状态">
<n-tag :type="loading ? 'warning' : 'success'">
{{ loading ? '加载中' : '已完成' }}
</n-tag>
</n-descriptions-item>
<n-descriptions-item label="错误信息">
<n-tag v-if="error" type="error">
{{ error }}
</n-tag>
<n-tag v-else type="success">无错误</n-tag>
</n-descriptions-item>
<n-descriptions-item label="模型数量">
{{ modelList.length }}
</n-descriptions-item>
<n-descriptions-item label="API地址">
<n-code>/sys/dict/getDictItems/airag_model where model_type = 'LLM',name,id</n-code>
</n-descriptions-item>
<n-descriptions-item label="用户Token">
<n-tag v-if="userStore.token" type="success">已登录</n-tag>
<n-tag v-else type="warning">未登录</n-tag>
</n-descriptions-item>
<n-descriptions-item label="Base URL">
<n-code>{{ baseURL }}</n-code>
</n-descriptions-item>
</n-descriptions>
</n-card>
<!-- 模型列表 -->
<n-card title="AI模型列表" size="small">
<n-data-table
:columns="modelColumns"
:data="modelList"
:pagination="false"
size="small"
/>
</n-card>
<!-- 选择器测试 -->
<n-card title="选择器测试" size="small">
<n-form-item label="选择AI模型">
<n-select
v-model:value="selectedModel"
placeholder="请选择AI模型"
:options="modelOptions"
:loading="loading"
@update:value="handleModelSelect"
/>
</n-form-item>
<n-form-item label="当前选择">
<n-tag v-if="selectedModel" type="info">
{{ getSelectedModelText() }}
</n-tag>
<span v-else>未选择</span>
</n-form-item>
</n-card>
<!-- 原始数据 -->
<n-card title="原始API响应数据" size="small">
<n-code
:code="JSON.stringify(rawData, null, 2)"
language="json"
show-line-numbers
/>
</n-card>
</n-space>
</n-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, h } from 'vue'
import {
NCard,
NSpace,
NDescriptions,
NDescriptionsItem,
NTag,
NButton,
NDataTable,
NCode,
NFormItem,
NSelect,
useMessage
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { SystemApi, type DictItem } from '@/api'
import axios from 'axios'
import { useUserStore } from '@/stores/user'
const message = useMessage()
const userStore = useUserStore()
//
const loading = ref(false)
const error = ref<string | null>(null)
const modelList = ref<DictItem[]>([])
const rawData = ref<any>(null)
const selectedModel = ref<string>('')
//
const baseURL = computed(() => import.meta.env.VITE_API_BASE_URL || '/jeecgboot')
//
const modelOptions = computed(() =>
modelList.value.map(item => ({
label: item.text,
value: item.value
}))
)
//
const modelColumns: DataTableColumns<DictItem> = [
{
title: 'Value',
key: 'value',
width: 200,
render: (row) => {
return h('code', {
style: 'background: #f5f5f5; padding: 2px 4px; border-radius: 3px;'
}, row.value)
}
},
{
title: 'Text',
key: 'text',
width: 120,
},
{
title: 'Label',
key: 'label',
width: 120,
},
{
title: 'Title',
key: 'title',
width: 120,
},
{
title: 'Color',
key: 'color',
width: 100,
render: (row) => {
return row.color ? h(NTag, { type: 'info' }, () => row.color) : '无'
}
}
]
// AI
const handleLoadModels = async () => {
loading.value = true
error.value = null
try {
console.log('🚀 开始加载AI模型列表...')
console.log('🔍 当前时间戳:', Date.now())
console.log('🔍 请求URL:', '/sys/dict/getDictItems/airag_model%20where%20model_type%20=%20\'LLM\',name,id')
const response = await SystemApi.getAiModelDict()
console.log('📦 完整API响应:', response)
console.log('📦 响应数据:', response.data)
rawData.value = response
if (response.code === 200 || response.code === 0) {
modelList.value = response.data || []
message.success(`加载成功,共${modelList.value.length}个模型`)
console.log('✅ AI模型列表加载成功:', modelList.value)
} else {
throw new Error(response.message || '获取模型列表失败')
}
} catch (err: any) {
error.value = err.message || '加载失败'
message.error(error.value || '未知错误')
console.error('❌ 加载AI模型列表失败:', err)
console.error('❌ 错误详情:', {
message: err.message,
response: err.response,
config: err.config,
stack: err.stack
})
// rawData便
rawData.value = {
error: err.message,
response: err.response?.data,
config: err.config
}
} finally {
loading.value = false
}
}
//
const handleDirectRequest = async () => {
loading.value = true
error.value = null
try {
console.log('🚀 开始直接请求测试...')
const baseURL = import.meta.env.VITE_API_BASE_URL || '/jeecgboot'
const timestamp = Date.now()
const token = userStore.token || localStorage.getItem('X-Access-Token') || ''
const url = `${baseURL}/sys/dict/getDictItems/airag_model%20where%20model_type%20=%20'LLM',name,id?_t=${timestamp}`
console.log('🔍 请求信息:', {
url,
timestamp,
token: token ? '***' : '无',
baseURL
})
const headers: any = {
'Content-Type': 'application/json',
'X-Request-Time': timestamp.toString(),
'timestamp': timestamp.toString(),
'X-Timestamp': timestamp.toString(),
}
if (token) {
headers['X-Access-Token'] = token
}
console.log('🔍 请求头:', headers)
const response = await axios.get(url, { headers })
console.log('📦 直接请求响应:', response)
rawData.value = response.data
if (response.data.code === 200 || response.data.code === 0) {
modelList.value = response.data.result || response.data.data || []
message.success(`直接请求成功,共${modelList.value.length}个模型`)
} else {
throw new Error(response.data.message || '直接请求失败')
}
} catch (err: any) {
error.value = err.message || '直接请求失败'
message.error(error.value || '未知错误')
console.error('❌ 直接请求失败:', err)
rawData.value = {
error: err.message,
response: err.response?.data,
status: err.response?.status,
headers: err.response?.headers
}
} finally {
loading.value = false
}
}
//
const handleClearModels = () => {
modelList.value = []
rawData.value = null
selectedModel.value = ''
error.value = null
message.info('数据已清空')
}
//
const handleModelSelect = (value: string) => {
const model = modelList.value.find(item => item.value === value)
if (model) {
message.info(`已选择: ${model.text}`)
}
}
//
const getSelectedModelText = () => {
const model = modelList.value.find(item => item.value === selectedModel.value)
return model ? model.text : selectedModel.value
}
</script>
<style scoped>
.ai-model-test {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
:deep(.n-code) {
max-height: 300px;
overflow-y: auto;
}
</style>

218
src/views/MenuTest.vue Normal file
View File

@ -0,0 +1,218 @@
<template>
<div class="menu-test">
<n-card title="动态菜单路由测试">
<n-space vertical size="large">
<!-- 菜单状态 -->
<n-card title="菜单状态" size="small">
<n-descriptions :column="2" bordered>
<n-descriptions-item label="加载状态">
<n-tag :type="menuStore.loading ? 'warning' : 'success'">
{{ menuStore.loading ? '加载中' : '已加载' }}
</n-tag>
</n-descriptions-item>
<n-descriptions-item label="错误信息">
<n-tag v-if="menuStore.error" type="error">
{{ menuStore.error }}
</n-tag>
<n-tag v-else type="success">无错误</n-tag>
</n-descriptions-item>
<n-descriptions-item label="首页菜单数量">
{{ menuStore.indexMenus.length }}
</n-descriptions-item>
<n-descriptions-item label="可见菜单数量">
{{ menuStore.visibleIndexMenus.length }}
</n-descriptions-item>
</n-descriptions>
</n-card>
<!-- 操作按钮 -->
<n-card title="操作" size="small">
<n-space>
<n-button
@click="handleRefreshMenus"
:loading="menuStore.loading"
type="primary"
>
刷新菜单
</n-button>
<n-button @click="handleClearMenus">
清空菜单
</n-button>
<n-button @click="handleTestRoute">
测试路由跳转
</n-button>
</n-space>
</n-card>
<!-- 菜单列表 -->
<n-card title="菜单列表" size="small">
<n-data-table
:columns="menuColumns"
:data="menuStore.visibleIndexMenus"
:pagination="false"
size="small"
/>
</n-card>
<!-- 路由信息 -->
<n-card title="当前路由信息" size="small">
<n-descriptions :column="1" bordered>
<n-descriptions-item label="路径">
{{ $route.path }}
</n-descriptions-item>
<n-descriptions-item label="名称">
{{ $route.name }}
</n-descriptions-item>
<n-descriptions-item label="标题">
{{ $route.meta.title }}
</n-descriptions-item>
<n-descriptions-item label="菜单ID">
{{ $route.meta.menuId || '无' }}
</n-descriptions-item>
</n-descriptions>
</n-card>
<!-- 动态导航组件 -->
<n-card title="动态导航组件" size="small">
<DynamicNavigation />
</n-card>
<!-- 原始数据 -->
<n-card title="原始菜单数据" size="small">
<n-code
:code="JSON.stringify(menuStore.indexMenus, null, 2)"
language="json"
show-line-numbers
/>
</n-card>
</n-space>
</n-card>
</div>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { useRouter } from 'vue-router'
import {
NCard,
NSpace,
NDescriptions,
NDescriptionsItem,
NTag,
NButton,
NDataTable,
NCode,
useMessage
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { useMenuStore } from '@/stores/menu'
import DynamicNavigation from '@/components/layout/DynamicNavigation.vue'
import type { MenuItem } from '@/api/modules/menu'
const menuStore = useMenuStore()
const router = useRouter()
const message = useMessage()
//
const menuColumns: DataTableColumns<MenuItem> = [
{
title: 'ID',
key: 'id',
width: 60,
},
{
title: '名称',
key: 'name',
width: 120,
},
{
title: '路径',
key: 'path',
width: 150,
render: (row) => {
return h('code', { style: 'background: #f5f5f5; padding: 2px 4px; border-radius: 3px;' }, row.path)
}
},
{
title: '类型',
key: 'type',
width: 80,
},
{
title: '排序',
key: 'sortOrder',
width: 80,
},
{
title: '可见',
key: 'izVisible',
width: 80,
render: (row) => {
return h(NTag, {
type: row.izVisible === 1 ? 'success' : 'error'
}, () => row.izVisible === 1 ? '是' : '否')
}
},
{
title: '操作',
key: 'actions',
width: 100,
render: (row) => {
return h(NButton, {
size: 'small',
type: 'primary',
onClick: () => handleNavigate(row.path)
}, () => '跳转')
}
}
]
//
const handleRefreshMenus = async () => {
try {
await menuStore.fetchIndexMenus()
message.success('菜单刷新成功')
} catch (error) {
message.error('菜单刷新失败')
}
}
//
const handleClearMenus = () => {
menuStore.clearMenus()
message.info('菜单已清空')
}
//
const handleTestRoute = () => {
const menus = menuStore.visibleIndexMenus
if (menus.length > 0) {
const randomMenu = menus[Math.floor(Math.random() * menus.length)]
handleNavigate(randomMenu.path)
} else {
message.warning('没有可用的菜单项')
}
}
//
const handleNavigate = (path: string) => {
router.push(path).then(() => {
message.success(`已跳转到: ${path}`)
}).catch((error) => {
message.error(`跳转失败: ${error.message}`)
})
}
</script>
<style scoped>
.menu-test {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
:deep(.n-code) {
max-height: 300px;
overflow-y: auto;
}
</style>