优化菜单加载

This commit is contained in:
李志强 2025-11-04 10:08:50 +08:00
parent ca1d265e34
commit 34035fb007
13 changed files with 351 additions and 35 deletions

7
pc/package-lock.json generated
View File

@ -16,6 +16,7 @@
"element-plus": "^2.11.7",
"less": "^4.4.2",
"marked": "^16.4.1",
"os": "^0.1.2",
"pinia": "^3.0.3",
"vue": "^3.5.22",
"vue-router": "^4.6.3"
@ -3743,6 +3744,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/os": {
"version": "0.1.2",
"resolved": "https://registry.npmmirror.com/os/-/os-0.1.2.tgz",
"integrity": "sha512-ZoXJkvAnljwvc56MbvhtKVWmSkzV712k42Is2mA0+0KTSRakq5XXuXpjZjgAt9ctzl51ojhQWakQQpmOvXWfjQ==",
"license": "MIT"
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/own-keys/-/own-keys-1.0.1.tgz",

View File

@ -17,6 +17,7 @@
"element-plus": "^2.11.7",
"less": "^4.4.2",
"marked": "^16.4.1",
"os": "^0.1.2",
"pinia": "^3.0.3",
"vue": "^3.5.22",
"vue-router": "^4.6.3"

View File

@ -49,3 +49,6 @@ body {
border-bottom: 1px solid #dcdfe6 !important;
}
}
.el-form-item__label{
min-width: 60px;
}

View File

@ -58,7 +58,7 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useAllDataStore } from '@/stores';
import { getAllMenus } from '@/api/menu';
@ -245,6 +245,17 @@ onMounted(() => {
setTimeout(() => {
fetchMenus();
}, 100);
//
window.addEventListener('menu-cache-refreshed', fetchMenus);
});
//
onUnmounted(() => {
if (themeObserver) {
themeObserver.disconnect();
}
window.removeEventListener('menu-cache-refreshed', fetchMenus);
});
//
@ -294,8 +305,14 @@ const handleMenuSelect = (index) => {
}
const menuItem = findMenuItemByPath(list.value, index);
if (menuItem && menuItem.route) {
emit('menu-click', menuItem);
if (menuItem && menuItem.path) {
// 使 path route
const menuToEmit = {
...menuItem,
route: menuItem.path, // route
label: menuItem.name || menuItem.label
};
emit('menu-click', menuToEmit);
}
};

View File

@ -16,6 +16,16 @@
</el-breadcrumb>
</div>
<div class="r-content">
<!-- 更新缓存按钮 -->
<el-button
circle
:icon="Refresh"
@click="refreshCache"
class="refresh-cache-btn"
:loading="cacheLoading"
title="更新菜单缓存"
/>
<!-- 主题切换按钮 -->
<el-button
circle
@ -50,8 +60,9 @@ import { ref, computed, onMounted, onUnmounted } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useAllDataStore } from "@/stores";
import { useAuthStore } from "@/stores/auth";
import { User, SwitchButton, Sunny, Moon } from '@element-plus/icons-vue';
import { getAllMenus } from '@/api/menu';
import { User, SwitchButton, Sunny, Moon, Refresh } from '@element-plus/icons-vue';
import { getAllMenus } from '@/api/menu';
import { ElMessage } from 'element-plus';
const router = useRouter();
const route = useRoute();
@ -69,14 +80,95 @@ interface Breadcrumb {
}
const menuList = ref<Menu[]>([]);
const cacheLoading = ref(false);
const MENU_CACHE_KEY = 'menu_cache';
async function loadMenu() {
//
function loadMenuFromCache(): Menu[] | null {
try {
const cached = localStorage.getItem(MENU_CACHE_KEY);
if (cached) {
const menuData = JSON.parse(cached);
//
return menuData;
}
} catch (error) {
console.error('Failed to load menu from cache', error);
}
return null;
}
//
function saveMenuToCache(menus: Menu[]) {
try {
localStorage.setItem(MENU_CACHE_KEY, JSON.stringify(menus));
} catch (error) {
console.error('Failed to save menu to cache', error);
}
}
// API
async function loadMenuFromAPI(updateCache = true) {
try {
const res = await getAllMenus();
menuList.value = res.data || [];
const menus = res.data || [];
if (updateCache && menus.length > 0) {
saveMenuToCache(menus);
}
return menus;
} catch (error) {
console.error('Failed to load menu', error);
menuList.value = [];
console.error('Failed to load menu from API', error);
return [];
}
}
//
async function loadMenu() {
//
const cachedMenus = loadMenuFromCache();
if (cachedMenus && cachedMenus.length > 0) {
menuList.value = cachedMenus;
// UI
loadMenuFromAPI(true).then(menus => {
if (menus.length > 0) {
menuList.value = menus;
}
});
} else {
// API
menuList.value = await loadMenuFromAPI(true);
}
}
//
async function refreshCache() {
cacheLoading.value = true;
try {
const menus = await loadMenuFromAPI(true);
if (menus.length > 0) {
menuList.value = menus;
//
const { loadAndAddDynamicRoutes, resetDynamicRoutes } = await import('@/router/index');
//
resetDynamicRoutes();
await loadAndAddDynamicRoutes();
// Vue Router
await new Promise(resolve => setTimeout(resolve, 200));
// CommonAside
window.dispatchEvent(new CustomEvent('menu-cache-refreshed'));
ElMessage.success('菜单缓存和路由更新成功');
} else {
ElMessage.warning('未获取到菜单数据');
}
} catch (error) {
console.error('Failed to refresh cache', error);
ElMessage.error('更新缓存失败,请检查网络连接');
} finally {
cacheLoading.value = false;
}
}
@ -119,7 +211,7 @@ const handleCollapse = () => {
store.state.isCollapse = !store.state.isCollapse;
};
const handleCommand = (command) => {
const handleCommand = async (command) => {
if (command === 'profile') {
router.push('/user/userProfile');
} else if (command === 'logout') {
@ -130,6 +222,18 @@ const handleCommand = (command) => {
//
localStorage.removeItem('tenant');
sessionStorage.removeItem('tenant');
//tabs_list
localStorage.removeItem('tabs_list');
localStorage.removeItem('active_tab');
sessionStorage.removeItem('tabs_list');
//
localStorage.removeItem(MENU_CACHE_KEY);
// tabs store
const { useTabsStore } = await import('@/stores');
const tabsStore = useTabsStore();
tabsStore.resetTabs();
router.push('/login');
}
};
@ -254,6 +358,21 @@ onUnmounted(() => {
align-items: center;
gap: 16px;
.refresh-cache-btn,
.theme-toggle-btn {
// 使 Element Plus
background-color: var(--el-fill-color-light);
border-color: var(--el-border-color);
color: var(--el-text-color-primary);
margin-left: 0 !important;
&:hover {
background-color: var(--el-fill-color);
border-color: var(--el-border-color-dark);
color: var(--el-color-primary);
}
}
.user {
width: 40px;
height: 40px;

View File

@ -50,6 +50,12 @@ let dynamicRoutesAdded = false;
// 路由加载 Promise用于确保路由加载完成
let routesLoadingPromise = null;
// 重置动态路由状态,允许重新加载
export function resetDynamicRoutes() {
dynamicRoutesAdded = false;
routesLoadingPromise = null;
}
// 从 API 加载并添加动态路由
export async function loadAndAddDynamicRoutes() {
// 如果已经有加载中的 Promise直接返回它

View File

@ -169,6 +169,15 @@ export const useTabsStore = defineTabsStore('tabs', () => {
saveTabsToStorage(tabList.value, activeTab.value);
}
// 重置 tabs store 到初始状态(登出时使用)
function resetTabs() {
tabList.value = [{ title: '首页', fullPath: defaultDashboardPath, name: 'Dashboard' }];
activeTab.value = defaultDashboardPath;
// 清除 localStorage 中的 tabs 数据
localStorage.removeItem('tabs_list');
localStorage.removeItem('active_tab');
}
return {
tabList,
activeTab,
@ -180,5 +189,6 @@ export const useTabsStore = defineTabsStore('tabs', () => {
closeAll,
setActiveTab,
saveTabsToStorage,
resetTabs,
};
});

View File

@ -4,7 +4,8 @@ import CommonHeader from '@/components/CommonHeader.vue';
import { useTabsStore } from '@/stores';
import { useRouter, useRoute } from 'vue-router';
import { ref, watch, reactive, nextTick, onMounted, computed } from 'vue';
import { More, Close, CircleClose, ArrowUp } from '@element-plus/icons-vue'
import { More, Close, CircleClose, ArrowUp } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
const tabsStore = useTabsStore();
const router = useRouter();
@ -96,16 +97,47 @@ watch(
);
// 1. /Tab
// watch(tabsStore.activeTab)
const handleAsideMenuClick = (menuItem) => {
const handleAsideMenuClick = async (menuItem) => {
const targetPath = menuItem.path;
// tab
tabsStore.addTab({
title: menuItem.label,
fullPath: menuItem.path,
fullPath: targetPath,
name: menuItem.label,
icon: menuItem.icon
});
// router.push watch
// keep-alive
//
if (router.currentRoute.value.fullPath === targetPath) {
return;
}
//
let routeExists = router.resolve(targetPath).matched.length > 0;
if (!routeExists) {
// 500ms
for (let i = 0; i < 5; i++) {
await new Promise(resolve => setTimeout(resolve, 100));
routeExists = router.resolve(targetPath).matched.length > 0;
if (routeExists) break;
}
}
//
if (routeExists) {
router.push(targetPath).catch(err => {
if (err.name !== 'NavigationDuplicated') {
console.error('路由跳转失败:', err);
}
});
} else {
//
console.warn('路由不存在,尝试刷新页面:', targetPath);
//
ElMessage.warning(`路由 ${targetPath} 不存在,请刷新缓存后重试`);
}
};
const tabsCloseTab = (targetKey) => {
tabsStore.removeTab(targetKey);
@ -123,22 +155,39 @@ const closeAll = () => {
tabsStore.closeAll();
router.push(defaultDashboardPath);
};
// tabtab
// tabtabtabhandleAsideMenuClick
// 使 immediate: false 使 flush: 'post' DOM
watch(
() => tabsStore.activeTab,
(newVal, oldVal) => {
// oldVal undefined
// watchtabhandleAsideMenuClick
if (newVal && oldVal !== undefined && router.currentRoute.value.fullPath !== newVal) {
// 使 nextTick keep-alive
nextTick(() => {
router.push(newVal).catch(err => {
//
if (err.name !== 'NavigationDuplicated') {
console.error('路由跳转失败:', err);
}
//
const routeExists = router.resolve(newVal).matched.length > 0;
if (routeExists) {
//
nextTick(() => {
router.push(newVal).catch(err => {
if (err.name !== 'NavigationDuplicated') {
console.error('路由跳转失败:', err);
}
});
});
});
} else {
//
setTimeout(() => {
const retryRouteExists = router.resolve(newVal).matched.length > 0;
if (retryRouteExists && router.currentRoute.value.fullPath !== newVal) {
router.push(newVal).catch(err => {
if (err.name !== 'NavigationDuplicated') {
console.error('路由跳转失败:', err);
}
});
}
}, 100);
}
}
},
{ flush: 'post' }

View File

@ -3,6 +3,7 @@ import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import { login } from "@/api/login";
import { getAllMenus } from "@/api/menu";
const router = useRouter();
const authStore = useAuthStore();
@ -68,6 +69,23 @@ const handleLogin = async () => {
authStore.setLoginInfo(res.data);
//
cacheTenant(tenantName);
// tabs store
const { useTabsStore } = await import('@/stores');
const tabsStore = useTabsStore();
tabsStore.resetTabs();
//
try {
const menuRes = await getAllMenus();
if (menuRes && menuRes.data && menuRes.data.length > 0) {
localStorage.setItem('menu_cache', JSON.stringify(menuRes.data));
}
} catch (menuError) {
console.error('Failed to cache menu on login', menuError);
//
}
router.push({ path: "/dashboard" });
} else {
errorMsg.value = res.message || "登录失败";

View File

@ -44,8 +44,8 @@
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag :type="scope.row.status === 'active' ? 'success' : 'danger'">
{{ scope.row.status === "active" ? "启用" : "禁用" }}
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">
{{ scope.row.status === 1 ? "启用" : "禁用" }}
</el-tag>
</template>
</el-table-column>
@ -55,6 +55,16 @@
width="180"
align="center"
/>
<el-table-column
prop="lastLoginIp"
label="最后登录IP"
width="150"
align="center"
>
<template #default="scope">
<span>{{ scope.row.lastLoginIp || '未知' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="240" align="center" fixed="right">
<template #default="scope">
<el-button size="small" @click="handleEdit(scope.row)"
@ -63,7 +73,11 @@
<el-button size="small" type="warning" @click="handleChangePassword(scope.row)">
修改密码
</el-button>
<el-button size="small" type="danger" @click="handleDelete(scope.row)"
<el-button
v-if="scope.row.username !== 'admin'"
size="small"
type="danger"
@click="handleDelete(scope.row)"
>删除</el-button
>
</template>
@ -297,16 +311,21 @@ const fetchUsers = async () => {
roleName = roleInfo ? roleInfo.roleName : '';
}
// last_login_time
const lastLoginTime = item.last_login_time || item.lastLoginTime || null;
// IP last_login_ip
const lastLoginIp = item.last_login_ip || item.lastLoginIp || null;
return {
id: item.id,
username: item.username,
nickname: item.nickname,
email: item.email,
role: roleValue, // role ID
role: roleValue,
roleName: roleName,
status: item.status || "active",
lastLoginTime: item.lastLoginTime
? new Date(item.lastLoginTime).toLocaleString("zh-CN", {
status: item.status,
lastLoginTime: lastLoginTime
? new Date(lastLoginTime).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
@ -315,7 +334,8 @@ const fetchUsers = async () => {
second: "2-digit",
hour12: false,
})
: "",
: "从未登录",
lastLoginIp: lastLoginIp || null,
};
});
total.value = users.value.length;

View File

@ -3,6 +3,7 @@ package controllers
import (
"encoding/json"
"server/models"
"strings"
"time"
"github.com/beego/beego/v2/client/orm"
@ -81,10 +82,50 @@ func (c *AuthController) Login() {
"data": nil,
}
} else {
// 登录成功写当前时间到last_login_time并增加login_count
// 登录成功写当前时间到last_login_time获取IP写入last_login_ip并增加login_count
loginTime := time.Now()
// 获取客户端IP地址
clientIP := c.Ctx.Input.IP()
// 优先从X-Forwarded-For获取真实IP适用于代理环境
forwardedFor := c.Ctx.Input.Header("X-Forwarded-For")
if forwardedFor != "" {
// X-Forwarded-For可能包含多个IP取第一个
ips := strings.Split(forwardedFor, ",")
if len(ips) > 0 {
ip := strings.TrimSpace(ips[0])
// 过滤掉本地地址
if ip != "" && ip != "::1" && ip != "127.0.0.1" && !strings.HasPrefix(ip, "192.168.") && !strings.HasPrefix(ip, "10.") && !strings.HasPrefix(ip, "172.16.") {
clientIP = ip
} else if ip != "" {
clientIP = ip
}
}
}
// 如果X-Forwarded-For没有有效IP尝试从X-Real-IP获取
if clientIP == "" || clientIP == "::1" || clientIP == "127.0.0.1" {
realIP := c.Ctx.Input.Header("X-Real-IP")
if realIP != "" {
ip := strings.TrimSpace(realIP)
if ip != "::1" && ip != "127.0.0.1" {
clientIP = ip
}
}
}
// 如果获取到的是IPv6的localhost转换为IPv4格式显示
if clientIP == "::1" {
clientIP = "127.0.0.1"
}
// 如果仍然没有获取到IP使用默认值
if clientIP == "" {
clientIP = "unknown"
}
o := orm.NewOrm()
_, _ = o.Raw("UPDATE yz_users SET last_login_time = ?, login_count = IFNULL(login_count,0)+1 WHERE id = ?", loginTime, user.Id).Exec()
_, _ = o.Raw("UPDATE yz_users SET last_login_time = ?, last_login_ip = ?, login_count = IFNULL(login_count,0)+1 WHERE id = ?", loginTime, clientIP, user.Id).Exec()
c.Data["json"] = map[string]interface{}{
"code": 0,

View File

@ -78,6 +78,7 @@ func (c *UserController) GetTenantUsers() {
"status": user.Status,
"role": user.Role,
"last_login_time": user.LastLoginTime,
"last_login_ip": user.LastLoginIp,
})
}
@ -364,6 +365,29 @@ func (c *UserController) DeleteUser() {
return
}
// 先查询用户信息检查是否为admin账号
user, err := models.GetUserInfo(userId, "", 0)
if err != nil {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "查询用户失败: " + err.Error(),
"data": nil,
}
c.ServeJSON()
return
}
// 禁止删除admin账号
if user.Username == "admin" {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "admin账号不允许删除",
"data": nil,
}
c.ServeJSON()
return
}
// 调用模型层方法删除用户
err = models.DeleteUser(userId)

View File

@ -28,6 +28,7 @@ type User struct {
Role int `orm:"column(role);default(0)" json:"role"`
DeleteTime *time.Time `orm:"column(delete_time);null;type(datetime)" json:"delete_time"`
LastLoginTime *time.Time `orm:"column(last_login_time);null;type(datetime)" json:"last_login_time"`
LastLoginIp string `orm:"column(last_login_ip);null;size(50)" json:"last_login_ip"`
}
// TableName 设置表名默认为yz_users