增加了tabs

This commit is contained in:
李志强 2025-10-30 17:34:32 +08:00
parent ddf90424ba
commit 8430eb509c
12 changed files with 522 additions and 271 deletions

View File

@ -46,194 +46,202 @@
</el-aside>
</template>
<script>
import { ref, computed, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useAllDataStore } from "@/stores";
import { getAllMenus } from "@/api/menu";
<script setup>
import { ref, computed, onMounted, defineEmits } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useAllDataStore } from '@/stores';
import { getAllMenus } from '@/api/menu';
export default {
name: "CommonAside",
setup() {
const router = useRouter();
const route = useRoute();
const list = ref([]);
const loading = ref(false);
const emit = defineEmits(['menu-click']);
const store = useAllDataStore();
const isCollapse = computed(() => store.state.isCollapse);
const width = computed(() => store.state.isCollapse ? '64px' : '180px');
const router = useRouter();
const route = useRoute();
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
//
const asideBgColor = ref('#0081ff');
const asideTextColor = ref('#ffffff');
//
if (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 = [];
}
parent.children.push(menu);
} else {
//
rootMenus.push(menu);
}
}
});
// order
const sortMenus = (menus) => {
if (!menus || menus.length === 0) return;
//
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();
//
if (bgColor) {
asideBgColor.value = bgColor;
}
if (textColor) {
asideTextColor.value = textColor;
}
} catch (e) {
console.warn('更新主题颜色失败:', e);
//
//
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);
}
};
//
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 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']
});
return diff;
});
//
menus.forEach(menu => {
if (menu.children && menu.children.length > 0) {
sortMenus(menu.children);
}
});
};
//
const sortedMenuList = computed(() => {
// order
const sorted = [...list.value].sort((a, b) => {
//
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 orderB = Number(b.order) ?? 999999;
if (orderA === orderB) {
@ -241,60 +249,33 @@ export default {
}
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 orderB = Number(b.order) ?? 999999;
if (orderA === orderB) {
return (a.id || 0) - (b.id || 0);
}
return orderA - orderB;
});
}
});
return sorted;
});
}
});
return sorted;
});
//
const handleMenuSelect = (index) => {
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
};
// emit
const handleMenuSelect = (index) => {
const menuItem = findMenuItemByPath(list.value, index);
if (menuItem && 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;
};
</script>
<style scoped lang="less">

View File

@ -1,24 +1,96 @@
import { defineStore } from 'pinia'
import { ref, computed, reactive } from 'vue'
import { defineStore } from 'pinia';
import { ref, computed, reactive } from 'vue';
// 初始化state数据
// ========== 全局状态 Store ==========
function initState() {
return {
isCollapse: false,
};
return {
isCollapse: false,
};
}
export const useAllDataStore = defineStore('allData', () => {
const state = reactive(initState());
const count = ref(0);
const doubleCount = computed(() => count.value * 2);
function increment() {
count.value++;
const state = reactive(initState());
const count = ref(0);
const doubleCount = computed(() => count.value * 2);
function increment() {
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 {
state,
count,
doubleCount,
increment
activeTab.value = tab.fullPath;
}
// 删除指定tab并切换激活tab
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,
};
});

View File

@ -1,18 +1,148 @@
<script setup>
import CommonAside from "@/components/CommonAside.vue";
import CommonHeader from "@/components/CommonHeader.vue";
import CommonAside from '@/components/CommonAside.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);
};
// tabtab
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>
<template>
<div class="common-layout">
<el-container class="main-container">
<common-aside />
<common-aside @menu-click="handleAsideMenuClick" />
<el-container>
<el-header class="main-header">
<common-header />
</el-header>
<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-container>
</el-container>
@ -20,33 +150,61 @@ import CommonHeader from "@/components/CommonHeader.vue";
</template>
<style scoped lang="less">
.common-layout,
.main-container {
.common-layout, .main-container {
height: 100vh;
width: 100%;
display: flex;
background-color: var(--bg-color-page);
transition: background-color 0.3s ease;
:deep(.el-aside) {
display: block !important;
visibility: visible !important;
height: 100vh;
}
.main-header {
background-color: var(--header-bg-color, #0081ff);
transition: background-color 0.3s ease;
height: 60px;
padding: 0;
}
.right-main {
background-color: var(--bg-color-page);
color: var(--text-color-primary);
transition: background-color 0.3s ease, color 0.3s ease;
padding: 20px;
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>

View File

@ -0,0 +1,13 @@
<template>
<div>
123
</div>
</template>
<script setup>
</script>
<style lang="scss" scoped>
</style>

View File

@ -67,10 +67,20 @@
<div class="knowledge-repos">
<div class="repos-header">
<h2>知识库列表</h2>
<el-button type="primary" @click="handleCreate" class="create-btn">
<i class="fa-solid fa-plus"></i>
新建知识库
</el-button>
<div>
<el-button type="primary" @click="handleCreate" class="create-btn">
<i class="fa-solid fa-plus"></i>
新建知识库
</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 class="repos-grid">
@ -286,7 +296,7 @@ async function fetchRepoList() {
repoList.value = result.list || result.data?.list || [];
total.value = result.total || result.data?.total || 0;
}
// API
updateStats();
} catch (error: any) {
@ -314,6 +324,16 @@ function handleEdit(repo: Knowledge) {
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) {
ElMessageBox.confirm(`确认删除知识「${repo.title}」?`, "提示", {
confirmButtonText: "确定",
@ -457,7 +477,7 @@ onMounted(() => {
.search-input {
flex: 1;
:deep(.el-input__wrapper){
:deep(.el-input__wrapper) {
border: none !important;
}
@ -706,7 +726,8 @@ onMounted(() => {
color: var(--primary-color);
border-color: var(--border-color);
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 {
@ -744,7 +765,8 @@ onMounted(() => {
border-color: var(--border-color-lighter);
font-size: 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;
}
}

View File

View File

@ -145,7 +145,7 @@
>
<el-input
v-model="currentMenu.ComponentPath"
placeholder="例如:@/views/settings/index.vue"
placeholder="例如:/apps/knowledge/index.vue"
/>
</el-form-item>
@ -440,10 +440,15 @@ const saveMenu = async () => {
const valid = await menuFormRef.value.validate();
if (!valid) return;
// CreateTime/UpdateTime
const payload = { ...currentMenu.value };
delete payload.CreateTime;
delete payload.UpdateTime;
try {
if (currentMenu.value.Id === 0) {
//
const result = await createMenu(currentMenu.value as Menu);
const result = await createMenu(payload as Menu);
if (result.success) {
ElMessage.success("菜单添加成功");
dialogVisible.value = false;
@ -455,7 +460,7 @@ const saveMenu = async () => {
//
const result = await updateMenu(
currentMenu.value.Id!,
currentMenu.value as Menu
payload as Menu
);
if (result.success) {
ElMessage.success("更新成功");

View File

@ -167,7 +167,7 @@ func (c *KnowledgeController) Update() {
c.Data["json"] = map[string]interface{}{
"code": 0,
"message": "更新成功",
"data": nil,
// "data": nil,
}
c.ServeJSON()
}

View File

@ -105,7 +105,7 @@ func (c *MenuController) UpdateMenu() {
c.Data["json"] = map[string]interface{}{
"success": true,
"message": "更新菜单成功",
"data": menu,
// "data": menu,
}
}

View File

@ -248,7 +248,7 @@ func UpdateKnowledge(id int, k *Knowledge) error {
return err
}
// 更新字段不包含category_name因为它是从联查获取的
// 更新字段
knowledge.Title = k.Title
knowledge.CategoryId = k.CategoryId
knowledge.Tags = k.Tags