增加了tabs
This commit is contained in:
		
							parent
							
								
									ddf90424ba
								
							
						
					
					
						commit
						8430eb509c
					
				| @ -46,194 +46,202 @@ | |||||||
|   </el-aside> |   </el-aside> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script> | <script setup> | ||||||
| import { ref, computed, onMounted } from "vue"; | import { ref, computed, onMounted, defineEmits } from 'vue'; | ||||||
| import { useRouter, useRoute } from "vue-router"; | import { useRouter, useRoute } from 'vue-router'; | ||||||
| import { useAllDataStore } from "@/stores"; | import { useAllDataStore } from '@/stores'; | ||||||
| import { getAllMenus } from "@/api/menu"; | import { getAllMenus } from '@/api/menu'; | ||||||
| 
 | 
 | ||||||
| export default { | const emit = defineEmits(['menu-click']); | ||||||
|   name: "CommonAside", |  | ||||||
|   setup() { |  | ||||||
|     const router = useRouter(); |  | ||||||
|     const route = useRoute(); |  | ||||||
|     const list = ref([]); |  | ||||||
|     const loading = ref(false); |  | ||||||
| 
 | 
 | ||||||
|     const store = useAllDataStore(); | const router = useRouter(); | ||||||
|     const isCollapse = computed(() => store.state.isCollapse); | const route = useRoute(); | ||||||
|     const width = computed(() => store.state.isCollapse ? '64px' : '180px'); | const list = ref([]); | ||||||
|  | const loading = ref(false); | ||||||
|  | 
 | ||||||
|  | const store = useAllDataStore(); | ||||||
|  | const isCollapse = computed(() => store.state.isCollapse); | ||||||
|  | const width = computed(() => store.state.isCollapse ? '64px' : '180px'); | ||||||
|  | const asideBgColor = ref('#0081ff'); | ||||||
|  | const asideTextColor = ref('#ffffff'); | ||||||
|  | 
 | ||||||
|  | // 更新主题颜色 | ||||||
|  | const updateThemeColors = () => { | ||||||
|  |   try { | ||||||
|  |     const root = document.documentElement; | ||||||
|  |     const bgColor = getComputedStyle(root).getPropertyValue('--aside-bg-color').trim(); | ||||||
|  |     const textColor = getComputedStyle(root).getPropertyValue('--aside-text-color').trim(); | ||||||
|      |      | ||||||
|     // 主题颜色(用于 Element Plus 组件,需要响应式) |     // 只有当获取到有效值时才更新 | ||||||
|     // 初始值设为可见的默认值,避免初始化时不可见 |     if (bgColor) { | ||||||
|     const asideBgColor = ref('#0081ff'); |       asideBgColor.value = bgColor; | ||||||
|     const asideTextColor = ref('#ffffff'); |     } | ||||||
|  |     if (textColor) { | ||||||
|  |       asideTextColor.value = textColor; | ||||||
|  |     } | ||||||
|  |   } catch (e) { | ||||||
|  |     console.warn('更新主题颜色失败:', e); | ||||||
|  |     // 出错时保持默认值 | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | // 将后端数据转换为前端需要的格式 | ||||||
|  | const transformMenuData = (menus) => { | ||||||
|  |   // 首先映射字段格式 | ||||||
|  |   const mappedMenus = menus.map(menu => ({ | ||||||
|  |     id: menu.id, | ||||||
|  |     path: menu.path, | ||||||
|  |     icon: menu.icon || 'fa-circle', | ||||||
|  |     label: menu.name, | ||||||
|  |     route: menu.path, | ||||||
|  |     parentId: menu.parentId || 0, | ||||||
|  |     order: menu.order || 0, | ||||||
|  |     children: [] | ||||||
|  |   })); | ||||||
|  | 
 | ||||||
|  |   // 构建树形结构 | ||||||
|  |   const menuMap = new Map(); | ||||||
|  |   const rootMenus = []; | ||||||
|  | 
 | ||||||
|  |   // 先创建所有菜单的映射 | ||||||
|  |   mappedMenus.forEach(menu => { | ||||||
|  |     menuMap.set(menu.id, menu); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   // 构建树形结构 | ||||||
|  |   mappedMenus.forEach(menu => { | ||||||
|  |     if (menu.parentId === 0) { | ||||||
|  |       rootMenus.push(menu); | ||||||
|  |     } else { | ||||||
|  |       const parent = menuMap.get(menu.parentId); | ||||||
|  |       if (parent) { | ||||||
|  |         if (!parent.children) { | ||||||
|  |           parent.children = []; | ||||||
|  |         } | ||||||
|  |         parent.children.push(menu); | ||||||
|  |       } else { | ||||||
|  |         // 如果找不到父节点,作为根节点处理 | ||||||
|  |         rootMenus.push(menu); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   // 按 order 排序(确保排序正确) | ||||||
|  |   const sortMenus = (menus) => { | ||||||
|  |     if (!menus || menus.length === 0) return; | ||||||
|      |      | ||||||
|     // 更新主题颜色 |     // 对当前层级的菜单进行排序 | ||||||
|     const updateThemeColors = () => { |     menus.sort((a, b) => { | ||||||
|       try { |       const orderA = Number(a.order) ?? 999999; // 没有order的排在最后 | ||||||
|         const root = document.documentElement; |       const orderB = Number(b.order) ?? 999999; | ||||||
|         const bgColor = getComputedStyle(root).getPropertyValue('--aside-bg-color').trim(); |       const diff = orderA - orderB; | ||||||
|         const textColor = getComputedStyle(root).getPropertyValue('--aside-text-color').trim(); |        | ||||||
|          |       // 如果 order 相同,按 id 排序(保证稳定性) | ||||||
|         // 只有当获取到有效值时才更新 |       if (diff === 0) { | ||||||
|         if (bgColor) { |         return (a.id || 0) - (b.id || 0); | ||||||
|           asideBgColor.value = bgColor; |  | ||||||
|         } |  | ||||||
|         if (textColor) { |  | ||||||
|           asideTextColor.value = textColor; |  | ||||||
|         } |  | ||||||
|       } catch (e) { |  | ||||||
|         console.warn('更新主题颜色失败:', e); |  | ||||||
|         // 出错时保持默认值 |  | ||||||
|       } |       } | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     // 将后端数据转换为前端需要的格式 |  | ||||||
|     const transformMenuData = (menus) => { |  | ||||||
|       // 首先映射字段格式 |  | ||||||
|       const mappedMenus = menus.map(menu => ({ |  | ||||||
|         id: menu.id, |  | ||||||
|         path: menu.path, |  | ||||||
|         icon: menu.icon || 'fa-circle', |  | ||||||
|         label: menu.name, |  | ||||||
|         route: menu.path, |  | ||||||
|         parentId: menu.parentId || 0, |  | ||||||
|         order: menu.order || 0, |  | ||||||
|         children: [] |  | ||||||
|       })); |  | ||||||
| 
 |  | ||||||
|       // 构建树形结构 |  | ||||||
|       const menuMap = new Map(); |  | ||||||
|       const rootMenus = []; |  | ||||||
| 
 |  | ||||||
|       // 先创建所有菜单的映射 |  | ||||||
|       mappedMenus.forEach(menu => { |  | ||||||
|         menuMap.set(menu.id, menu); |  | ||||||
|       }); |  | ||||||
| 
 |  | ||||||
|       // 构建树形结构 |  | ||||||
|       mappedMenus.forEach(menu => { |  | ||||||
|         if (menu.parentId === 0) { |  | ||||||
|           rootMenus.push(menu); |  | ||||||
|         } else { |  | ||||||
|           const parent = menuMap.get(menu.parentId); |  | ||||||
|           if (parent) { |  | ||||||
|             if (!parent.children) { |  | ||||||
|               parent.children = []; |  | ||||||
|             } |  | ||||||
|             parent.children.push(menu); |  | ||||||
|           } else { |  | ||||||
|             // 如果找不到父节点,作为根节点处理 |  | ||||||
|             rootMenus.push(menu); |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
|       }); |  | ||||||
| 
 |  | ||||||
|       // 按 order 排序(确保排序正确) |  | ||||||
|       const sortMenus = (menus) => { |  | ||||||
|         if (!menus || menus.length === 0) return; |  | ||||||
|          |  | ||||||
|         // 对当前层级的菜单进行排序 |  | ||||||
|         menus.sort((a, b) => { |  | ||||||
|           const orderA = Number(a.order) ?? 999999; // 没有order的排在最后 |  | ||||||
|           const orderB = Number(b.order) ?? 999999; |  | ||||||
|           const diff = orderA - orderB; |  | ||||||
|            |  | ||||||
|           // 如果 order 相同,按 id 排序(保证稳定性) |  | ||||||
|           if (diff === 0) { |  | ||||||
|             return (a.id || 0) - (b.id || 0); |  | ||||||
|           } |  | ||||||
|            |  | ||||||
|           return diff; |  | ||||||
|         }); |  | ||||||
|          |  | ||||||
|         // 递归排序子菜单 |  | ||||||
|         menus.forEach(menu => { |  | ||||||
|           if (menu.children && menu.children.length > 0) { |  | ||||||
|             sortMenus(menu.children); |  | ||||||
|           } |  | ||||||
|         }); |  | ||||||
|       }; |  | ||||||
| 
 |  | ||||||
|       // 先排序根菜单 |  | ||||||
|       sortMenus(rootMenus); |  | ||||||
|        |        | ||||||
|       // console.log('排序后的根菜单:', rootMenus.map(m => ({ name: m.label, order: m.order }))); |       return diff; | ||||||
|        |  | ||||||
|       return rootMenus; |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     // 获取菜单数据 |  | ||||||
|     const fetchMenus = async () => { |  | ||||||
|       loading.value = true; |  | ||||||
|       try { |  | ||||||
|         // 优先从 localStorage 读取 |  | ||||||
|         const cachedMenus = localStorage.getItem('menuData'); |  | ||||||
|         let menuData = null; |  | ||||||
|          |  | ||||||
|         if (cachedMenus) { |  | ||||||
|           try { |  | ||||||
|             menuData = JSON.parse(cachedMenus); |  | ||||||
|             // console.log('从缓存读取菜单数据'); |  | ||||||
|           } catch (e) { |  | ||||||
|             console.warn('缓存菜单数据解析失败:', e); |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
|          |  | ||||||
|         // 如果缓存中没有或解析失败,从接口获取 |  | ||||||
|         if (!menuData) { |  | ||||||
|           const res = await getAllMenus(); |  | ||||||
|           if (res && res.success && res.data) { |  | ||||||
|             menuData = res.data; |  | ||||||
|             // 保存到缓存 |  | ||||||
|             localStorage.setItem('menuData', JSON.stringify(menuData)); |  | ||||||
|           } else { |  | ||||||
|             console.error('获取菜单失败:', res?.message || '未知错误'); |  | ||||||
|             list.value = []; |  | ||||||
|             loading.value = false; |  | ||||||
|             return; |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
|          |  | ||||||
|         // 转换并排序菜单数据 |  | ||||||
|         const transformedMenus = transformMenuData(menuData); |  | ||||||
|         // console.log('转换后的菜单数据:', transformedMenus); |  | ||||||
|         // console.log('菜单顺序(按 order 排序):', transformedMenus.map(m => ({ name: m.label, order: m.order, id: m.id }))); |  | ||||||
|         list.value = transformedMenus; |  | ||||||
|       } catch (error) { |  | ||||||
|         console.error('获取菜单异常:', error); |  | ||||||
|         list.value = []; |  | ||||||
|       } finally { |  | ||||||
|         loading.value = false; |  | ||||||
|       } |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     // 组件挂载时获取菜单 |  | ||||||
|     onMounted(() => { |  | ||||||
|       // 先更新主题颜色,确保组件可见 |  | ||||||
|       updateThemeColors(); |  | ||||||
|        |  | ||||||
|       // 延迟一点获取菜单,确保主题已初始化 |  | ||||||
|       setTimeout(() => { |  | ||||||
|         fetchMenus(); |  | ||||||
|       }, 100); |  | ||||||
|        |  | ||||||
|       // 监听主题变化事件 |  | ||||||
|       window.addEventListener('theme-change', updateThemeColors); |  | ||||||
|        |  | ||||||
|       // 监听 CSS 变量变化(MutationObserver) |  | ||||||
|       const observer = new MutationObserver(updateThemeColors); |  | ||||||
|       observer.observe(document.documentElement, { |  | ||||||
|         attributes: true, |  | ||||||
|         attributeFilter: ['data-theme'] |  | ||||||
|       }); |  | ||||||
|     }); |     }); | ||||||
|  |      | ||||||
|  |     // 递归排序子菜单 | ||||||
|  |     menus.forEach(menu => { | ||||||
|  |       if (menu.children && menu.children.length > 0) { | ||||||
|  |         sortMenus(menu.children); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
| 
 | 
 | ||||||
|     // 计算属性:统一排序所有菜单项(不再区分有无子菜单) |   // 先排序根菜单 | ||||||
|     const sortedMenuList = computed(() => { |   sortMenus(rootMenus); | ||||||
|       // 创建副本并排序,确保按 order 排序 |    | ||||||
|       const sorted = [...list.value].sort((a, b) => { |   // console.log('排序后的根菜单:', rootMenus.map(m => ({ name: m.label, order: m.order }))); | ||||||
|  |    | ||||||
|  |   return rootMenus; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | // 获取菜单数据 | ||||||
|  | const fetchMenus = async () => { | ||||||
|  |   loading.value = true; | ||||||
|  |   try { | ||||||
|  |     // 优先从 localStorage 读取 | ||||||
|  |     const cachedMenus = localStorage.getItem('menuData'); | ||||||
|  |     let menuData = null; | ||||||
|  |      | ||||||
|  |     if (cachedMenus) { | ||||||
|  |       try { | ||||||
|  |         menuData = JSON.parse(cachedMenus); | ||||||
|  |         // console.log('从缓存读取菜单数据'); | ||||||
|  |       } catch (e) { | ||||||
|  |         console.warn('缓存菜单数据解析失败:', e); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 如果缓存中没有或解析失败,从接口获取 | ||||||
|  |     if (!menuData) { | ||||||
|  |       const res = await getAllMenus(); | ||||||
|  |       if (res && res.success && res.data) { | ||||||
|  |         menuData = res.data; | ||||||
|  |         // 保存到缓存 | ||||||
|  |         localStorage.setItem('menuData', JSON.stringify(menuData)); | ||||||
|  |       } else { | ||||||
|  |         console.error('获取菜单失败:', res?.message || '未知错误'); | ||||||
|  |         list.value = []; | ||||||
|  |         loading.value = false; | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 转换并排序菜单数据 | ||||||
|  |     const transformedMenus = transformMenuData(menuData); | ||||||
|  |     // console.log('转换后的菜单数据:', transformedMenus); | ||||||
|  |     // console.log('菜单顺序(按 order 排序):', transformedMenus.map(m => ({ name: m.label, order: m.order, id: m.id }))); | ||||||
|  |     list.value = transformedMenus; | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('获取菜单异常:', error); | ||||||
|  |     list.value = []; | ||||||
|  |   } finally { | ||||||
|  |     loading.value = false; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | // 组件挂载时获取菜单 | ||||||
|  | onMounted(() => { | ||||||
|  |   // 先更新主题颜色,确保组件可见 | ||||||
|  |   updateThemeColors(); | ||||||
|  |    | ||||||
|  |   // 延迟一点获取菜单,确保主题已初始化 | ||||||
|  |   setTimeout(() => { | ||||||
|  |     fetchMenus(); | ||||||
|  |   }, 100); | ||||||
|  |    | ||||||
|  |   // 监听主题变化事件 | ||||||
|  |   window.addEventListener('theme-change', updateThemeColors); | ||||||
|  |    | ||||||
|  |   // 监听 CSS 变量变化(MutationObserver) | ||||||
|  |   const observer = new MutationObserver(updateThemeColors); | ||||||
|  |   observer.observe(document.documentElement, { | ||||||
|  |     attributes: true, | ||||||
|  |     attributeFilter: ['data-theme'] | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | // 计算属性:统一排序所有菜单项(不再区分有无子菜单) | ||||||
|  | const sortedMenuList = computed(() => { | ||||||
|  |   // 创建副本并排序,确保按 order 排序 | ||||||
|  |   const sorted = [...list.value].sort((a, b) => { | ||||||
|  |     const orderA = Number(a.order) ?? 999999; | ||||||
|  |     const orderB = Number(b.order) ?? 999999; | ||||||
|  |     if (orderA === orderB) { | ||||||
|  |       return (a.id || 0) - (b.id || 0); | ||||||
|  |     } | ||||||
|  |     return orderA - orderB; | ||||||
|  |   }); | ||||||
|  |    | ||||||
|  |   // 确保子菜单也排序 | ||||||
|  |   sorted.forEach(menu => { | ||||||
|  |     if (menu.children && menu.children.length > 0) { | ||||||
|  |       menu.children.sort((a, b) => { | ||||||
|         const orderA = Number(a.order) ?? 999999; |         const orderA = Number(a.order) ?? 999999; | ||||||
|         const orderB = Number(b.order) ?? 999999; |         const orderB = Number(b.order) ?? 999999; | ||||||
|         if (orderA === orderB) { |         if (orderA === orderB) { | ||||||
| @ -241,60 +249,33 @@ export default { | |||||||
|         } |         } | ||||||
|         return orderA - orderB; |         return orderA - orderB; | ||||||
|       }); |       }); | ||||||
|        |     } | ||||||
|       // 确保子菜单也排序 |   }); | ||||||
|       sorted.forEach(menu => { |    | ||||||
|         if (menu.children && menu.children.length > 0) { |   return sorted; | ||||||
|           menu.children.sort((a, b) => { | }); | ||||||
|             const orderA = Number(a.order) ?? 999999; |  | ||||||
|             const orderB = Number(b.order) ?? 999999; |  | ||||||
|             if (orderA === orderB) { |  | ||||||
|               return (a.id || 0) - (b.id || 0); |  | ||||||
|             } |  | ||||||
|             return orderA - orderB; |  | ||||||
|           }); |  | ||||||
|         } |  | ||||||
|       }); |  | ||||||
|        |  | ||||||
|       return sorted; |  | ||||||
|     }); |  | ||||||
| 
 | 
 | ||||||
|     // 菜单点击事件处理 | // 菜单点击事件:emit 通知父组件 | ||||||
|     const handleMenuSelect = (index) => { | const handleMenuSelect = (index) => { | ||||||
|       const menuItem = findMenuItemByPath(list.value, index); |   const menuItem = findMenuItemByPath(list.value, index); | ||||||
|       if (menuItem && menuItem.route) { |   if (menuItem && menuItem.route) { | ||||||
|         router.push(menuItem.route); |     emit('menu-click', menuItem); | ||||||
|       } |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     // 递归查找菜单项 |  | ||||||
|     const findMenuItemByPath = (menus, path) => { |  | ||||||
|       for (const menu of menus) { |  | ||||||
|         if (menu.path === path) { |  | ||||||
|           return menu; |  | ||||||
|         } |  | ||||||
|         if (menu.children && menu.children.length > 0) { |  | ||||||
|           const found = findMenuItemByPath(menu.children, path); |  | ||||||
|           if (found) return found; |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|       return null; |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     return { |  | ||||||
|       list, |  | ||||||
|       sortedMenuList, |  | ||||||
|       store, |  | ||||||
|       isCollapse, |  | ||||||
|       width, |  | ||||||
|       loading, |  | ||||||
|       handleMenuSelect, |  | ||||||
|       route, |  | ||||||
|       asideBgColor, |  | ||||||
|       asideTextColor |  | ||||||
|     }; |  | ||||||
|   } |   } | ||||||
| }; | }; | ||||||
|  | 
 | ||||||
|  | // 递归查找菜单项 | ||||||
|  | const findMenuItemByPath = (menus, path) => { | ||||||
|  |   for (const menu of menus) { | ||||||
|  |     if (menu.path === path) { | ||||||
|  |       return menu; | ||||||
|  |     } | ||||||
|  |     if (menu.children && menu.children.length > 0) { | ||||||
|  |       const found = findMenuItemByPath(menu.children, path); | ||||||
|  |       if (found) return found; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   return null; | ||||||
|  | }; | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <style scoped lang="less"> | <style scoped lang="less"> | ||||||
|  | |||||||
| @ -1,24 +1,96 @@ | |||||||
| import { defineStore } from 'pinia' | import { defineStore } from 'pinia'; | ||||||
| import { ref, computed, reactive } from 'vue' | import { ref, computed, reactive } from 'vue'; | ||||||
| 
 | 
 | ||||||
| // 初始化state数据
 | // ========== 全局状态 Store ==========
 | ||||||
| function initState() { | function initState() { | ||||||
|     return { |   return { | ||||||
|         isCollapse: false, |     isCollapse: false, | ||||||
|     }; |   }; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export const useAllDataStore = defineStore('allData', () => { | export const useAllDataStore = defineStore('allData', () => { | ||||||
|     const state = reactive(initState()); |   const state = reactive(initState()); | ||||||
|     const count = ref(0); |   const count = ref(0); | ||||||
|     const doubleCount = computed(() => count.value * 2); |   const doubleCount = computed(() => count.value * 2); | ||||||
|     function increment() { |   function increment() { | ||||||
|         count.value++; |     count.value++; | ||||||
|  |   } | ||||||
|  |   return { | ||||||
|  |     state, | ||||||
|  |     count, | ||||||
|  |     doubleCount, | ||||||
|  |     increment, | ||||||
|  |   }; | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | // ========== 多标签页 Tabs Store ==========
 | ||||||
|  | import { defineStore as defineTabsStore } from 'pinia'; | ||||||
|  | import { ref as vueRef } from 'vue'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * 多标签页Tabs状态管理 | ||||||
|  |  * tabList每个tab结构: { | ||||||
|  |  *   title: 标签显示名, | ||||||
|  |  *   fullPath: 路由路径(唯一key), | ||||||
|  |  *   name: 路由name, | ||||||
|  |  *   icon: 图标(可选) | ||||||
|  |  * } | ||||||
|  |  */ | ||||||
|  | export const useTabsStore = defineTabsStore('tabs', () => { | ||||||
|  |   // 固定首页tab
 | ||||||
|  |   const defaultDashboardPath = '/dashboard'; | ||||||
|  |   const tabList = vueRef([ | ||||||
|  |     { title: '首页', fullPath: defaultDashboardPath, name: 'Dashboard' }, | ||||||
|  |   ]); | ||||||
|  |   const activeTab = vueRef(defaultDashboardPath); | ||||||
|  | 
 | ||||||
|  |   // 添加tab,若已存在则激活
 | ||||||
|  |   function addTab(tab) { | ||||||
|  |     const exist = tabList.value.find((t) => t.fullPath === tab.fullPath); | ||||||
|  |     if (!exist) { | ||||||
|  |       tabList.value.push(tab); | ||||||
|     } |     } | ||||||
|     return { |     activeTab.value = tab.fullPath; | ||||||
|         state, |   } | ||||||
|         count, | 
 | ||||||
|         doubleCount, |   // 删除指定tab并切换激活tab
 | ||||||
|         increment |   function removeTab(fullPath) { | ||||||
|  |     const idx = tabList.value.findIndex((t) => t.fullPath === fullPath); | ||||||
|  |     if (idx > -1) { | ||||||
|  |       tabList.value.splice(idx, 1); | ||||||
|  |       // 只在关闭当前激活tab时切换激活tab
 | ||||||
|  |       if (activeTab.value === fullPath) { | ||||||
|  |         if (tabList.value.length > 0) { | ||||||
|  |           // 优先激活右侧(如无则激活左侧)
 | ||||||
|  |           const newIdx = idx >= tabList.value.length ? tabList.value.length - 1 : idx; | ||||||
|  |           activeTab.value = tabList.value[newIdx].fullPath; | ||||||
|  |         } else { | ||||||
|  |           // 全部关闭,兜底首页
 | ||||||
|  |           activeTab.value = defaultDashboardPath; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|     } |     } | ||||||
| }) |   } | ||||||
|  | 
 | ||||||
|  |   // 关闭其他,只留首页和当前激活tab
 | ||||||
|  |   function closeOthers() { | ||||||
|  |     tabList.value = tabList.value.filter( | ||||||
|  |       (t) => t.fullPath === defaultDashboardPath || t.fullPath === activeTab.value | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // 关闭全部,只留首页
 | ||||||
|  |   function closeAll() { | ||||||
|  |     tabList.value = tabList.value.filter((t) => t.fullPath === defaultDashboardPath); | ||||||
|  |     activeTab.value = defaultDashboardPath; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   return { | ||||||
|  |     tabList, | ||||||
|  |     activeTab, | ||||||
|  |     addTab, | ||||||
|  |     removeTab, | ||||||
|  |     closeOthers, | ||||||
|  |     closeAll, | ||||||
|  |   }; | ||||||
|  | }); | ||||||
| @ -1,18 +1,148 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import CommonAside from "@/components/CommonAside.vue"; | import CommonAside from '@/components/CommonAside.vue'; | ||||||
| import CommonHeader from "@/components/CommonHeader.vue"; | import CommonHeader from '@/components/CommonHeader.vue'; | ||||||
|  | import { useTabsStore } from '@/stores'; | ||||||
|  | import { useRouter, useRoute } from 'vue-router'; | ||||||
|  | import { ref, watch, reactive, nextTick } from 'vue'; | ||||||
|  | import { More, DArrowRight } from '@element-plus/icons-vue' | ||||||
|  | 
 | ||||||
|  | const tabsStore = useTabsStore(); | ||||||
|  | const router = useRouter(); | ||||||
|  | const route = useRoute(); | ||||||
|  | const defaultDashboardPath = '/dashboard'; | ||||||
|  | 
 | ||||||
|  | // 1. 侧栏菜单点击:加入/激活Tab并切换路由 | ||||||
|  | const handleAsideMenuClick = (menuItem) => { | ||||||
|  |   tabsStore.addTab({ | ||||||
|  |     title: menuItem.label, | ||||||
|  |     fullPath: menuItem.path, | ||||||
|  |     name: menuItem.label, | ||||||
|  |     icon: menuItem.icon | ||||||
|  |   }); | ||||||
|  |   if (route.fullPath !== menuItem.path) { | ||||||
|  |     router.push(menuItem.path); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | const tabsCloseTab = (targetKey) => { | ||||||
|  |   tabsStore.removeTab(targetKey); | ||||||
|  |   if (route.fullPath !== tabsStore.activeTab) { | ||||||
|  |     router.push(tabsStore.activeTab); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | const closeOthers = () => { | ||||||
|  |   tabsStore.closeOthers(); | ||||||
|  |   if (!tabsStore.tabList.find(tab => tab.fullPath === route.fullPath)) { | ||||||
|  |     router.push(tabsStore.activeTab); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | const closeAll = () => { | ||||||
|  |   tabsStore.closeAll(); | ||||||
|  |   router.push(defaultDashboardPath); | ||||||
|  | }; | ||||||
|  | // 主动监听tab激活,保证切tab时内容区切换 | ||||||
|  | watch( | ||||||
|  |   () => tabsStore.activeTab, | ||||||
|  |   (newVal) => { | ||||||
|  |     if (newVal && router.currentRoute.value.fullPath !== newVal) { | ||||||
|  |       router.push(newVal); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | // ========== 右键菜单逻辑 ========== // | ||||||
|  | const contextMenu = reactive({ | ||||||
|  |   visible: false, | ||||||
|  |   x: 0, | ||||||
|  |   y: 0, | ||||||
|  |   tab: null, | ||||||
|  | }); | ||||||
|  | const contextDropdownRef = ref(null); | ||||||
|  | 
 | ||||||
|  | const onTabContextMenu = (event, tab) => { | ||||||
|  |   event.preventDefault(); | ||||||
|  |   contextMenu.visible = true; | ||||||
|  |   contextMenu.x = event.clientX; | ||||||
|  |   contextMenu.y = event.clientY; | ||||||
|  |   contextMenu.tab = tab; | ||||||
|  |   nextTick(() => { | ||||||
|  |     // 溢出优化可扩展 | ||||||
|  |   }); | ||||||
|  |   document.body.addEventListener('click', hideContextMenu, { once: true }); | ||||||
|  | }; | ||||||
|  | function hideContextMenu() { | ||||||
|  |   contextMenu.visible = false; | ||||||
|  |   contextMenu.tab = null; | ||||||
|  | } | ||||||
|  | function closeTabContextTab() { | ||||||
|  |   if (contextMenu.tab && contextMenu.tab.fullPath !== defaultDashboardPath) { | ||||||
|  |     tabsStore.removeTab(contextMenu.tab.fullPath); | ||||||
|  |   } | ||||||
|  |   hideContextMenu(); | ||||||
|  | } | ||||||
|  | function closeOthersContextTab() { | ||||||
|  |   if (contextMenu.tab) { | ||||||
|  |     tabsStore.activeTab = contextMenu.tab.fullPath; | ||||||
|  |     tabsStore.closeOthers(); | ||||||
|  |   } | ||||||
|  |   hideContextMenu(); | ||||||
|  | } | ||||||
|  | function closeAllTabs() { | ||||||
|  |   tabsStore.closeAll(); | ||||||
|  |   hideContextMenu(); | ||||||
|  |   router.push(defaultDashboardPath); | ||||||
|  | } | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
|   <div class="common-layout"> |   <div class="common-layout"> | ||||||
|     <el-container class="main-container"> |     <el-container class="main-container"> | ||||||
|       <common-aside /> |       <common-aside @menu-click="handleAsideMenuClick" /> | ||||||
|       <el-container> |       <el-container> | ||||||
|         <el-header class="main-header"> |         <el-header class="main-header"> | ||||||
|           <common-header /> |           <common-header /> | ||||||
|         </el-header> |         </el-header> | ||||||
|         <el-main class="right-main"> |         <el-main class="right-main"> | ||||||
|           <router-view /> |           <div class="multi-tabs-wrapper"> | ||||||
|  |             <el-tabs | ||||||
|  |               v-model="tabsStore.activeTab" | ||||||
|  |               type="card" | ||||||
|  |               class="multi-tabs" | ||||||
|  |               closable | ||||||
|  |               @tab-remove="tabsCloseTab" | ||||||
|  |             > | ||||||
|  |               <el-tab-pane | ||||||
|  |                 v-for="tab in tabsStore.tabList" | ||||||
|  |                 :key="tab.fullPath" | ||||||
|  |                 :label="tab.title" | ||||||
|  |                 :name="tab.fullPath" | ||||||
|  |                 :closable="tab.fullPath !== defaultDashboardPath" | ||||||
|  |                 @contextmenu="onTabContextMenu($event, tab)" | ||||||
|  |               /> | ||||||
|  |             </el-tabs> | ||||||
|  |             <!-- 跟随浮动到 tabs 最右侧的批量按钮 --> | ||||||
|  |             <div class="floated-tabs-extra-btn"> | ||||||
|  |               <el-dropdown> | ||||||
|  |                 <span class="extra-action-btn"> | ||||||
|  |                   <el-icon><DArrowRight /></el-icon> | ||||||
|  |                 </span> | ||||||
|  |                 <template #dropdown> | ||||||
|  |                   <el-dropdown-menu> | ||||||
|  |                     <el-dropdown-item @click="closeOthers">关闭其他</el-dropdown-item> | ||||||
|  |                     <el-dropdown-item @click="closeAll">关闭全部</el-dropdown-item> | ||||||
|  |                   </el-dropdown-menu> | ||||||
|  |                 </template> | ||||||
|  |               </el-dropdown> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  | 
 | ||||||
|  |           <!-- 主内容毛玻璃卡片容器 --> | ||||||
|  |           <div class="main-panel glass-card"> | ||||||
|  |             <keep-alive :max="10"> | ||||||
|  |               <router-view v-slot="{ Component }"> | ||||||
|  |                 <component :is="Component" /> | ||||||
|  |               </router-view> | ||||||
|  |             </keep-alive> | ||||||
|  |           </div> | ||||||
|         </el-main> |         </el-main> | ||||||
|       </el-container> |       </el-container> | ||||||
|     </el-container> |     </el-container> | ||||||
| @ -20,33 +150,61 @@ import CommonHeader from "@/components/CommonHeader.vue"; | |||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <style scoped lang="less"> | <style scoped lang="less"> | ||||||
| .common-layout, | .common-layout, .main-container { | ||||||
| .main-container { |  | ||||||
|   height: 100vh; |   height: 100vh; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   display: flex; |   display: flex; | ||||||
|   background-color: var(--bg-color-page); |   background-color: var(--bg-color-page); | ||||||
|   transition: background-color 0.3s ease; |   transition: background-color 0.3s ease; | ||||||
| 
 |  | ||||||
|   :deep(.el-aside) { |   :deep(.el-aside) { | ||||||
|     display: block !important; |     display: block !important; | ||||||
|     visibility: visible !important; |     visibility: visible !important; | ||||||
|     height: 100vh; |     height: 100vh; | ||||||
|   } |   } | ||||||
| 
 |  | ||||||
|   .main-header { |   .main-header { | ||||||
|     background-color: var(--header-bg-color, #0081ff); |     background-color: var(--header-bg-color, #0081ff); | ||||||
|     transition: background-color 0.3s ease; |     transition: background-color 0.3s ease; | ||||||
|     height: 60px; |     height: 60px; | ||||||
|     padding: 0; |     padding: 0; | ||||||
|   } |   } | ||||||
|    |  | ||||||
|   .right-main { |   .right-main { | ||||||
|     background-color: var(--bg-color-page); |     background-color: var(--bg-color-page); | ||||||
|     color: var(--text-color-primary); |     color: var(--text-color-primary); | ||||||
|     transition: background-color 0.3s ease, color 0.3s ease; |     transition: background-color 0.3s ease, color 0.3s ease; | ||||||
|     padding: 20px; |     padding: 20px; | ||||||
|     overflow-y: auto; |     overflow-y: auto; | ||||||
|  |     .multi-tabs-wrapper { | ||||||
|  |       position: relative; | ||||||
|  |       zoom: 1; | ||||||
|  |       min-height: 45px; | ||||||
|  |     } | ||||||
|  |     .floated-tabs-extra-btn { | ||||||
|  |       float: right; | ||||||
|  |       margin-top: -40px; | ||||||
|  |       margin-right: 12px; | ||||||
|  |       z-index: 10; | ||||||
|  |     } | ||||||
|  |     .extra-action-btn { | ||||||
|  |       display: inline-flex; | ||||||
|  |       align-items: center; | ||||||
|  |       font-size: 20px; | ||||||
|  |       cursor: pointer; | ||||||
|  |       color: #888; | ||||||
|  |       background: none; | ||||||
|  |       border: none; | ||||||
|  |       padding: 0 8px; | ||||||
|  |       border-radius: 4px; | ||||||
|  |       transition: color 0.2s; | ||||||
|  |       &:hover { | ||||||
|  |         color: #409eff; | ||||||
|  |         background: none; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     .more-menu { | ||||||
|  |       font-size: 13px; | ||||||
|  |       margin-left: 10px; | ||||||
|  |       cursor: pointer; | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
| </style> | </style> | ||||||
|  | |||||||
							
								
								
									
										0
									
								
								pc/src/views/apps/knowledge/category/edit.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								pc/src/views/apps/knowledge/category/edit.vue
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										13
									
								
								pc/src/views/apps/knowledge/category/index.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								pc/src/views/apps/knowledge/category/index.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,13 @@ | |||||||
|  | <template> | ||||||
|  |     <div> | ||||||
|  | 123 | ||||||
|  |     </div> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <script setup> | ||||||
|  | 
 | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <style lang="scss" scoped> | ||||||
|  | 
 | ||||||
|  | </style> | ||||||
| @ -67,10 +67,20 @@ | |||||||
|     <div class="knowledge-repos"> |     <div class="knowledge-repos"> | ||||||
|       <div class="repos-header"> |       <div class="repos-header"> | ||||||
|         <h2>知识库列表</h2> |         <h2>知识库列表</h2> | ||||||
|         <el-button type="primary" @click="handleCreate" class="create-btn"> |         <div> | ||||||
|           <i class="fa-solid fa-plus"></i> |           <el-button type="primary" @click="handleCreate" class="create-btn"> | ||||||
|           新建知识库 |             <i class="fa-solid fa-plus"></i> | ||||||
|         </el-button> |             新建知识库 | ||||||
|  |           </el-button> | ||||||
|  |           <el-button type="primary" @click="handleCategory" class="create-btn"> | ||||||
|  |             <i class="fas fa-folder-open"></i> | ||||||
|  |             分类管理 | ||||||
|  |           </el-button> | ||||||
|  |           <el-button type="primary" @click="handleTags" class="create-btn"> | ||||||
|  |             <i class="fas fa-tags"></i> | ||||||
|  |             标签管理 | ||||||
|  |           </el-button> | ||||||
|  |         </div> | ||||||
|       </div> |       </div> | ||||||
| 
 | 
 | ||||||
|       <div class="repos-grid"> |       <div class="repos-grid"> | ||||||
| @ -286,7 +296,7 @@ async function fetchRepoList() { | |||||||
|       repoList.value = result.list || result.data?.list || []; |       repoList.value = result.list || result.data?.list || []; | ||||||
|       total.value = result.total || result.data?.total || 0; |       total.value = result.total || result.data?.total || 0; | ||||||
|     } |     } | ||||||
|      | 
 | ||||||
|     // 更新统计数据(避免重复调用 API) |     // 更新统计数据(避免重复调用 API) | ||||||
|     updateStats(); |     updateStats(); | ||||||
|   } catch (error: any) { |   } catch (error: any) { | ||||||
| @ -314,6 +324,16 @@ function handleEdit(repo: Knowledge) { | |||||||
|   router.push(`/apps/knowledge/edit/${repo.id}`); |   router.push(`/apps/knowledge/edit/${repo.id}`); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | function handleCategory() { | ||||||
|  |   // 跳转到分类管理页面,使用路径跳转 | ||||||
|  |   router.push(`/apps/knowledge/category`); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function handleTags() { | ||||||
|  |   // 跳转到标签管理页面,使用路径跳转 | ||||||
|  |   router.push(`/apps/knowledge/tags`); | ||||||
|  | } | ||||||
|  | 
 | ||||||
| function handleDelete(repo: Knowledge) { | function handleDelete(repo: Knowledge) { | ||||||
|   ElMessageBox.confirm(`确认删除知识「${repo.title}」?`, "提示", { |   ElMessageBox.confirm(`确认删除知识「${repo.title}」?`, "提示", { | ||||||
|     confirmButtonText: "确定", |     confirmButtonText: "确定", | ||||||
| @ -457,7 +477,7 @@ onMounted(() => { | |||||||
|   .search-input { |   .search-input { | ||||||
|     flex: 1; |     flex: 1; | ||||||
| 
 | 
 | ||||||
|     :deep(.el-input__wrapper){ |     :deep(.el-input__wrapper) { | ||||||
|       border: none !important; |       border: none !important; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -706,7 +726,8 @@ onMounted(() => { | |||||||
|             color: var(--primary-color); |             color: var(--primary-color); | ||||||
|             border-color: var(--border-color); |             border-color: var(--border-color); | ||||||
|             font-weight: 500; |             font-weight: 500; | ||||||
|             transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; |             transition: background-color 0.3s ease, color 0.3s ease, | ||||||
|  |               border-color 0.3s ease; | ||||||
|           } |           } | ||||||
| 
 | 
 | ||||||
|           .repo-owner { |           .repo-owner { | ||||||
| @ -744,7 +765,8 @@ onMounted(() => { | |||||||
|           border-color: var(--border-color-lighter); |           border-color: var(--border-color-lighter); | ||||||
|           font-size: 12px; |           font-size: 12px; | ||||||
|           padding: 4px 12px; |           padding: 4px 12px; | ||||||
|           transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; |           transition: background-color 0.3s ease, color 0.3s ease, | ||||||
|  |             border-color 0.3s ease; | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|  | |||||||
							
								
								
									
										0
									
								
								pc/src/views/apps/knowledge/tag/edit.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								pc/src/views/apps/knowledge/tag/edit.vue
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										0
									
								
								pc/src/views/apps/knowledge/tag/index.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								pc/src/views/apps/knowledge/tag/index.vue
									
									
									
									
									
										Normal file
									
								
							| @ -145,7 +145,7 @@ | |||||||
|         > |         > | ||||||
|           <el-input |           <el-input | ||||||
|             v-model="currentMenu.ComponentPath" |             v-model="currentMenu.ComponentPath" | ||||||
|             placeholder="例如:@/views/settings/index.vue" |             placeholder="例如:/apps/knowledge/index.vue" | ||||||
|           /> |           /> | ||||||
|         </el-form-item> |         </el-form-item> | ||||||
| 
 | 
 | ||||||
| @ -440,10 +440,15 @@ const saveMenu = async () => { | |||||||
|   const valid = await menuFormRef.value.validate(); |   const valid = await menuFormRef.value.validate(); | ||||||
|   if (!valid) return; |   if (!valid) return; | ||||||
| 
 | 
 | ||||||
|  |   // 解决后端时间字段问题:过滤掉 CreateTime/UpdateTime | ||||||
|  |   const payload = { ...currentMenu.value }; | ||||||
|  |   delete payload.CreateTime; | ||||||
|  |   delete payload.UpdateTime; | ||||||
|  | 
 | ||||||
|   try { |   try { | ||||||
|     if (currentMenu.value.Id === 0) { |     if (currentMenu.value.Id === 0) { | ||||||
|       // 新增菜单 |       // 新增菜单 | ||||||
|       const result = await createMenu(currentMenu.value as Menu); |       const result = await createMenu(payload as Menu); | ||||||
|       if (result.success) { |       if (result.success) { | ||||||
|         ElMessage.success("菜单添加成功"); |         ElMessage.success("菜单添加成功"); | ||||||
|         dialogVisible.value = false; |         dialogVisible.value = false; | ||||||
| @ -455,7 +460,7 @@ const saveMenu = async () => { | |||||||
|       // 编辑菜单 |       // 编辑菜单 | ||||||
|       const result = await updateMenu( |       const result = await updateMenu( | ||||||
|         currentMenu.value.Id!, |         currentMenu.value.Id!, | ||||||
|         currentMenu.value as Menu |         payload as Menu | ||||||
|       ); |       ); | ||||||
|       if (result.success) { |       if (result.success) { | ||||||
|         ElMessage.success("更新成功"); |         ElMessage.success("更新成功"); | ||||||
|  | |||||||
| @ -167,7 +167,7 @@ func (c *KnowledgeController) Update() { | |||||||
| 	c.Data["json"] = map[string]interface{}{ | 	c.Data["json"] = map[string]interface{}{ | ||||||
| 		"code":    0, | 		"code":    0, | ||||||
| 		"message": "更新成功", | 		"message": "更新成功", | ||||||
| 		"data":    nil, | 		// "data":    nil, | ||||||
| 	} | 	} | ||||||
| 	c.ServeJSON() | 	c.ServeJSON() | ||||||
| } | } | ||||||
|  | |||||||
| @ -105,7 +105,7 @@ func (c *MenuController) UpdateMenu() { | |||||||
| 		c.Data["json"] = map[string]interface{}{ | 		c.Data["json"] = map[string]interface{}{ | ||||||
| 			"success": true, | 			"success": true, | ||||||
| 			"message": "更新菜单成功", | 			"message": "更新菜单成功", | ||||||
| 			"data":    menu, | 			// "data":    menu, | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -248,7 +248,7 @@ func UpdateKnowledge(id int, k *Knowledge) error { | |||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// 更新字段(不包含category_name,因为它是从联查获取的) | 	// 更新字段 | ||||||
| 	knowledge.Title = k.Title | 	knowledge.Title = k.Title | ||||||
| 	knowledge.CategoryId = k.CategoryId | 	knowledge.CategoryId = k.CategoryId | ||||||
| 	knowledge.Tags = k.Tags | 	knowledge.Tags = k.Tags | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user