优化菜单加载

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", "element-plus": "^2.11.7",
"less": "^4.4.2", "less": "^4.4.2",
"marked": "^16.4.1", "marked": "^16.4.1",
"os": "^0.1.2",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"vue": "^3.5.22", "vue": "^3.5.22",
"vue-router": "^4.6.3" "vue-router": "^4.6.3"
@ -3743,6 +3744,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/own-keys": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/own-keys/-/own-keys-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/own-keys/-/own-keys-1.0.1.tgz",

View File

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

View File

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

View File

@ -58,7 +58,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted, onUnmounted } 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';
@ -245,6 +245,17 @@ onMounted(() => {
setTimeout(() => { setTimeout(() => {
fetchMenus(); fetchMenus();
}, 100); }, 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); const menuItem = findMenuItemByPath(list.value, index);
if (menuItem && menuItem.route) { if (menuItem && menuItem.path) {
emit('menu-click', menuItem); // 使 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> </el-breadcrumb>
</div> </div>
<div class="r-content"> <div class="r-content">
<!-- 更新缓存按钮 -->
<el-button
circle
:icon="Refresh"
@click="refreshCache"
class="refresh-cache-btn"
:loading="cacheLoading"
title="更新菜单缓存"
/>
<!-- 主题切换按钮 --> <!-- 主题切换按钮 -->
<el-button <el-button
circle circle
@ -50,8 +60,9 @@ import { ref, computed, onMounted, onUnmounted } from "vue";
import { useRouter, useRoute } from "vue-router"; import { useRouter, useRoute } from "vue-router";
import { useAllDataStore } from "@/stores"; import { useAllDataStore } from "@/stores";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { User, SwitchButton, Sunny, Moon } from '@element-plus/icons-vue'; import { User, SwitchButton, Sunny, Moon, Refresh } from '@element-plus/icons-vue';
import { getAllMenus } from '@/api/menu'; import { getAllMenus } from '@/api/menu';
import { ElMessage } from 'element-plus';
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
@ -69,14 +80,95 @@ interface Breadcrumb {
} }
const menuList = ref<Menu[]>([]); 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 { try {
const res = await getAllMenus(); const res = await getAllMenus();
menuList.value = res.data || []; const menus = res.data || [];
if (updateCache && menus.length > 0) {
saveMenuToCache(menus);
}
return menus;
} catch (error) { } catch (error) {
console.error('Failed to load menu', error); console.error('Failed to load menu from API', error);
menuList.value = []; 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; store.state.isCollapse = !store.state.isCollapse;
}; };
const handleCommand = (command) => { const handleCommand = async (command) => {
if (command === 'profile') { if (command === 'profile') {
router.push('/user/userProfile'); router.push('/user/userProfile');
} else if (command === 'logout') { } else if (command === 'logout') {
@ -130,6 +222,18 @@ const handleCommand = (command) => {
// //
localStorage.removeItem('tenant'); localStorage.removeItem('tenant');
sessionStorage.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'); router.push('/login');
} }
}; };
@ -254,6 +358,21 @@ onUnmounted(() => {
align-items: center; align-items: center;
gap: 16px; 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 { .user {
width: 40px; width: 40px;
height: 40px; height: 40px;

View File

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

View File

@ -169,6 +169,15 @@ export const useTabsStore = defineTabsStore('tabs', () => {
saveTabsToStorage(tabList.value, activeTab.value); 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 { return {
tabList, tabList,
activeTab, activeTab,
@ -180,5 +189,6 @@ export const useTabsStore = defineTabsStore('tabs', () => {
closeAll, closeAll,
setActiveTab, setActiveTab,
saveTabsToStorage, saveTabsToStorage,
resetTabs,
}; };
}); });

View File

@ -4,7 +4,8 @@ import CommonHeader from '@/components/CommonHeader.vue';
import { useTabsStore } from '@/stores'; import { useTabsStore } from '@/stores';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { ref, watch, reactive, nextTick, onMounted, computed } from 'vue'; 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 tabsStore = useTabsStore();
const router = useRouter(); const router = useRouter();
@ -96,16 +97,47 @@ watch(
); );
// 1. /Tab // 1. /Tab
// watch(tabsStore.activeTab) const handleAsideMenuClick = async (menuItem) => {
const handleAsideMenuClick = (menuItem) => { const targetPath = menuItem.path;
// tab
tabsStore.addTab({ tabsStore.addTab({
title: menuItem.label, title: menuItem.label,
fullPath: menuItem.path, fullPath: targetPath,
name: menuItem.label, name: menuItem.label,
icon: menuItem.icon 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) => { const tabsCloseTab = (targetKey) => {
tabsStore.removeTab(targetKey); tabsStore.removeTab(targetKey);
@ -123,22 +155,39 @@ const closeAll = () => {
tabsStore.closeAll(); tabsStore.closeAll();
router.push(defaultDashboardPath); router.push(defaultDashboardPath);
}; };
// tabtab // tabtabtabhandleAsideMenuClick
// 使 immediate: false 使 flush: 'post' DOM // 使 immediate: false 使 flush: 'post' DOM
watch( watch(
() => tabsStore.activeTab, () => tabsStore.activeTab,
(newVal, oldVal) => { (newVal, oldVal) => {
// oldVal undefined // oldVal undefined
// watchtabhandleAsideMenuClick
if (newVal && oldVal !== undefined && router.currentRoute.value.fullPath !== newVal) { if (newVal && oldVal !== undefined && router.currentRoute.value.fullPath !== newVal) {
// 使 nextTick keep-alive //
nextTick(() => { const routeExists = router.resolve(newVal).matched.length > 0;
router.push(newVal).catch(err => {
// if (routeExists) {
if (err.name !== 'NavigationDuplicated') { //
console.error('路由跳转失败:', err); 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' } { flush: 'post' }

View File

@ -3,6 +3,7 @@ import { ref, onMounted } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { login } from "@/api/login"; import { login } from "@/api/login";
import { getAllMenus } from "@/api/menu";
const router = useRouter(); const router = useRouter();
const authStore = useAuthStore(); const authStore = useAuthStore();
@ -68,6 +69,23 @@ const handleLogin = async () => {
authStore.setLoginInfo(res.data); authStore.setLoginInfo(res.data);
// //
cacheTenant(tenantName); 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" }); router.push({ path: "/dashboard" });
} else { } else {
errorMsg.value = res.message || "登录失败"; errorMsg.value = res.message || "登录失败";

View File

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

View File

@ -3,6 +3,7 @@ package controllers
import ( import (
"encoding/json" "encoding/json"
"server/models" "server/models"
"strings"
"time" "time"
"github.com/beego/beego/v2/client/orm" "github.com/beego/beego/v2/client/orm"
@ -81,10 +82,50 @@ func (c *AuthController) Login() {
"data": nil, "data": nil,
} }
} else { } else {
// 登录成功写当前时间到last_login_time并增加login_count // 登录成功写当前时间到last_login_time获取IP写入last_login_ip并增加login_count
loginTime := time.Now() 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 := 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{}{ c.Data["json"] = map[string]interface{}{
"code": 0, "code": 0,

View File

@ -78,6 +78,7 @@ func (c *UserController) GetTenantUsers() {
"status": user.Status, "status": user.Status,
"role": user.Role, "role": user.Role,
"last_login_time": user.LastLoginTime, "last_login_time": user.LastLoginTime,
"last_login_ip": user.LastLoginIp,
}) })
} }
@ -364,6 +365,29 @@ func (c *UserController) DeleteUser() {
return 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) err = models.DeleteUser(userId)

View File

@ -28,6 +28,7 @@ type User struct {
Role int `orm:"column(role);default(0)" json:"role"` Role int `orm:"column(role);default(0)" json:"role"`
DeleteTime *time.Time `orm:"column(delete_time);null;type(datetime)" json:"delete_time"` 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"` 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 // TableName 设置表名默认为yz_users