增加了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> </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">

View File

@ -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,
};
});

View File

@ -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);
};
// 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> </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>

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="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;
} }
} }

View File

View 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("更新成功");

View File

@ -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()
} }

View File

@ -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,
} }
} }

View File

@ -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