增加了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);
|
||||||
|
|
||||||
// 主题颜色(用于 Element Plus 组件,需要响应式)
|
const store = useAllDataStore();
|
||||||
// 初始值设为可见的默认值,避免初始化时不可见
|
const isCollapse = computed(() => store.state.isCollapse);
|
||||||
const asideBgColor = ref('#0081ff');
|
const width = computed(() => store.state.isCollapse ? '64px' : '180px');
|
||||||
const asideTextColor = ref('#ffffff');
|
const asideBgColor = ref('#0081ff');
|
||||||
|
const asideTextColor = ref('#ffffff');
|
||||||
|
|
||||||
// 更新主题颜色
|
// 更新主题颜色
|
||||||
const updateThemeColors = () => {
|
const updateThemeColors = () => {
|
||||||
try {
|
try {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const bgColor = getComputedStyle(root).getPropertyValue('--aside-bg-color').trim();
|
const bgColor = getComputedStyle(root).getPropertyValue('--aside-bg-color').trim();
|
||||||
const textColor = getComputedStyle(root).getPropertyValue('--aside-text-color').trim();
|
const textColor = getComputedStyle(root).getPropertyValue('--aside-text-color').trim();
|
||||||
|
|
||||||
// 只有当获取到有效值时才更新
|
// 只有当获取到有效值时才更新
|
||||||
if (bgColor) {
|
if (bgColor) {
|
||||||
asideBgColor.value = bgColor;
|
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 = [];
|
||||||
}
|
}
|
||||||
if (textColor) {
|
parent.children.push(menu);
|
||||||
asideTextColor.value = textColor;
|
} else {
|
||||||
}
|
// 如果找不到父节点,作为根节点处理
|
||||||
} catch (e) {
|
rootMenus.push(menu);
|
||||||
console.warn('更新主题颜色失败:', e);
|
|
||||||
// 出错时保持默认值
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 将后端数据转换为前端需要的格式
|
// 按 order 排序(确保排序正确)
|
||||||
const transformMenuData = (menus) => {
|
const sortMenus = (menus) => {
|
||||||
// 首先映射字段格式
|
if (!menus || menus.length === 0) return;
|
||||||
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();
|
menus.sort((a, b) => {
|
||||||
const rootMenus = [];
|
const orderA = Number(a.order) ?? 999999; // 没有order的排在最后
|
||||||
|
const orderB = Number(b.order) ?? 999999;
|
||||||
|
const diff = orderA - orderB;
|
||||||
|
|
||||||
// 先创建所有菜单的映射
|
// 如果 order 相同,按 id 排序(保证稳定性)
|
||||||
mappedMenus.forEach(menu => {
|
if (diff === 0) {
|
||||||
menuMap.set(menu.id, menu);
|
return (a.id || 0) - (b.id || 0);
|
||||||
});
|
|
||||||
|
|
||||||
// 构建树形结构
|
|
||||||
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 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;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// 组件挂载时获取菜单
|
return diff;
|
||||||
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(() => {
|
menus.forEach(menu => {
|
||||||
// 创建副本并排序,确保按 order 排序
|
if (menu.children && menu.children.length > 0) {
|
||||||
const sorted = [...list.value].sort((a, b) => {
|
sortMenus(menu.children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 先排序根菜单
|
||||||
|
sortMenus(rootMenus);
|
||||||
|
|
||||||
|
// 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;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 确保子菜单也排序
|
return sorted;
|
||||||
sorted.forEach(menu => {
|
});
|
||||||
if (menu.children && menu.children.length > 0) {
|
|
||||||
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 menuItem = findMenuItemByPath(list.value, index);
|
||||||
// 菜单点击事件处理
|
if (menuItem && menuItem.route) {
|
||||||
const handleMenuSelect = (index) => {
|
emit('menu-click', menuItem);
|
||||||
const menuItem = findMenuItemByPath(list.value, index);
|
|
||||||
if (menuItem && menuItem.route) {
|
|
||||||
router.push(menuItem.route);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 递归查找菜单项
|
|
||||||
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">
|
||||||
@ -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…
Reference in New Issue
Block a user