feat: 添加最近访问菜单功能并移除Websocket菜单

This commit is contained in:
Ray.Hao
2026-02-24 21:32:14 +08:00
parent c617d2943f
commit ecdc22969d
8 changed files with 170 additions and 362 deletions

View File

@@ -498,19 +498,6 @@ export default defineMock([
params: null, params: null,
}, },
}, },
{
path: "/function/websocket",
component: "demo/websocket",
name: "/function/websocket",
meta: {
title: "Websocket",
icon: "",
hidden: false,
keepAlive: true,
alwaysShow: false,
params: null,
},
},
{ {
path: "/function/ai-command", path: "/function/ai-command",
component: "demo/ai-command", component: "demo/ai-command",
@@ -1571,21 +1558,6 @@ export default defineMock([
perm: null, perm: null,
children: [], children: [],
}, },
{
id: 90,
parentId: 89,
name: "Websocket",
type: "MENU",
routeName: null,
routePath: "/function/websocket",
component: "demo/websocket",
sort: 3,
visible: 1,
icon: "",
redirect: "",
perm: null,
children: [],
},
{ {
id: 91, id: 91,
parentId: 89, parentId: 89,

View File

@@ -4,4 +4,8 @@ export { useStomp, useDictSync, useOnlineCount } from "./websocket";
export type { DictMessage, DictChangeMessage, DictChangeCallback } from "./websocket"; export type { DictMessage, DictChangeMessage, DictChangeCallback } from "./websocket";
// 表格相关 // 表格相关
export { useTableSelection } from "./table/useTableSelection"; export { useTableSelection } from "./useTableSelection";
// 最近访问菜单
export { useRecentMenus } from "./useRecentMenus";
export type { RecentMenuItem } from "./useRecentMenus";

View File

@@ -0,0 +1,99 @@
import { ref } from "vue";
/**
* 最近访问菜单项
*/
export interface RecentMenuItem {
path: string;
title: string;
icon?: string;
visitedAt: number;
}
const STORAGE_KEY = "recent_menus";
const MAX_COUNT = 8;
// 全局状态
const recentMenus = ref<RecentMenuItem[]>([]);
/**
* 从 localStorage 加载数据
*/
function loadFromStorage(): RecentMenuItem[] {
try {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : [];
} catch {
return [];
}
}
/**
* 保存到 localStorage
*/
function saveToStorage(menus: RecentMenuItem[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(menus));
}
// 初始化
recentMenus.value = loadFromStorage();
/**
* 最近访问菜单 composable
*/
export function useRecentMenus() {
/**
* 清空所有记录
*/
function clearRecentMenus() {
recentMenus.value = [];
localStorage.removeItem(STORAGE_KEY);
}
/**
* 格式化访问时间
*/
function formatVisitTime(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
if (diff < 60000) return "刚刚";
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`;
if (diff < 604800000) return `${Math.floor(diff / 86400000)}天前`;
const date = new Date(timestamp);
return `${date.getMonth() + 1}-${date.getDate()}`;
}
return {
recentMenus,
clearRecentMenus,
formatVisitTime,
};
}
/**
* 添加最近访问记录(全局方法,供路由守卫调用)
*/
export function addRecentMenu(path: string, title: string, icon?: string) {
if (!path || !title) return;
// 过滤掉不需要记录的路径
const excludePaths = ["/dashboard", "/redirect", "/404", "/401", "/login", "/"];
if (excludePaths.some((p) => path === p || path.startsWith(p + "/"))) return;
// 移除已存在的相同路径
const filtered = recentMenus.value.filter((item) => item.path !== path);
// 添加到开头
const newItem: RecentMenuItem = {
path,
title,
icon,
visitedAt: Date.now(),
};
recentMenus.value = [newItem, ...filtered].slice(0, MAX_COUNT);
saveToStorage(recentMenus.value);
}

View File

@@ -4,6 +4,7 @@ import router from "@/router";
import { usePermissionStore, useUserStore } from "@/store"; import { usePermissionStore, useUserStore } from "@/store";
import { useTenantStoreHook } from "@/store/modules/tenant"; import { useTenantStoreHook } from "@/store/modules/tenant";
import { isTenantEnabled } from "@/utils/tenant"; import { isTenantEnabled } from "@/utils/tenant";
import { addRecentMenu } from "@/composables/useRecentMenus";
/** /**
* 路由权限守卫 * 路由权限守卫
@@ -78,8 +79,14 @@ export function setupPermissionGuard() {
} }
}); });
router.afterEach(() => { router.afterEach((to) => {
NProgress.done(); NProgress.done();
// 记录最近访问
if (to.meta?.title && to.path) {
const icon = typeof to.meta.icon === "string" ? to.meta.icon : undefined;
addRecentMenu(to.path, to.meta.title as string, icon);
}
}); });
} }

View File

@@ -319,68 +319,63 @@
<ECharts :options="visitTrendChartOptions" height="400px" /> <ECharts :options="visitTrendChartOptions" height="400px" />
</el-card> </el-card>
</el-col> </el-col>
<!-- 最新动态--> <!-- 最近访问 -->
<el-col :xs="24" :span="8"> <el-col :xs="24" :span="8">
<el-card> <el-card>
<template #header> <template #header>
<div class="flex-x-between"> <div class="flex-x-between">
<span class="font-semibold">最新动态</span> <span class="font-semibold">最近访问</span>
<el-link <el-button
v-if="recentMenus.length > 0"
type="primary" type="primary"
underline="never" link
href="https://gitee.com/youlaiorg/vue3-element-admin/releases" size="small"
target="_blank" @click="clearRecentMenus"
> >
完整记录 清空
<el-icon class="ml-0.5"><TopRight /></el-icon> </el-button>
</el-link>
</div> </div>
</template> </template>
<el-scrollbar height="400px"> <div class="min-h-[400px] flex flex-col">
<el-timeline class="p-3"> <!-- 宫格显示 -->
<el-timeline-item <div v-if="recentMenus.length > 0" class="grid grid-cols-2 gap-3">
v-for="(item, index) in vesionList" <div
:key="index" v-for="item in recentMenus"
:timestamp="item.date" :key="item.path"
placement="top" class="group flex items-center gap-2 px-3 py-2.5 bg-[--el-fill-color-lighter] rounded-lg cursor-pointer transition-all duration-200 hover:bg-[--el-color-primary-light-8]"
:color="index === 0 ? '#67C23A' : '#909399'" @click="router.push(item.path)"
:hollow="index !== 0"
size="large"
> >
<div <!-- 图标 -->
class="p-4 mb-3 bg-[--el-fill-color-lighter] rounded-lg transition-all duration-200 hover:translate-x-1" <div class="shrink-0 w-8 h-8 flex items-center justify-center">
:class="{ <el-icon
'bg-[--el-color-primary-light-9]! border border-[--el-color-primary-light-5]': v-if="item.icon?.startsWith('el-icon-')"
index === 0, class="text-lg text-[--el-color-primary]"
}" >
> <component :is="item.icon.replace('el-icon-', '')" />
<div class="flex items-center gap-2"> </el-icon>
<el-text tag="strong">{{ item.title }}</el-text> <div
<el-tag v-if="item.tag" :type="index === 0 ? 'success' : 'info'" size="small"> v-else-if="item.icon"
{{ item.tag }} :class="`i-svg:${item.icon} text-lg text-[--el-color-primary]`"
</el-tag> />
</div> <el-icon v-else class="text-lg text-[--el-color-primary]"><Menu /></el-icon>
<el-text class="mb-3 text-xs leading-relaxed text-[--el-text-color-secondary]">
{{ item.content }}
</el-text>
<div v-if="item.link">
<el-link
:type="index === 0 ? 'primary' : 'info'"
:href="item.link"
target="_blank"
underline="never"
>
详情
<el-icon class="ml-0.5"><TopRight /></el-icon>
</el-link>
</div>
</div> </div>
</el-timeline-item> <!-- 标题 -->
</el-timeline> <span class="text-sm truncate flex-1 leading-tight">
</el-scrollbar> {{ item.title }}
</span>
</div>
</div>
<!-- 空状态 -->
<div v-else class="flex flex-col items-center justify-center flex-1 py-16">
<el-icon :size="48" class="text-[--el-text-color-placeholder] mb-4">
<Clock />
</el-icon>
<p class="text-sm text-[--el-text-color-secondary] mb-2">暂无访问记录</p>
<p class="text-xs text-[--el-text-color-placeholder]">访问的页面会自动记录在这里</p>
</div>
</div>
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>
@@ -395,17 +390,23 @@ defineOptions({
import { dayjs } from "element-plus"; import { dayjs } from "element-plus";
import { ref } from "vue"; import { ref } from "vue";
import { useRouter } from "vue-router";
import StatisticsAPI from "@/api/system/statistics"; import StatisticsAPI from "@/api/system/statistics";
import type { VisitStatsDetail, VisitTrendDetail } from "@/types/api"; import type { VisitStatsDetail, VisitTrendDetail } from "@/types/api";
import { useUserStore } from "@/store/modules/user"; import { useUserStore } from "@/store/modules/user";
import { formatGrowthRate } from "@/utils"; import { formatGrowthRate } from "@/utils";
import { useTransition, useDateFormat } from "@vueuse/core"; import { useTransition, useDateFormat } from "@vueuse/core";
import { CircleCheck, CircleClose, Loading } from "@element-plus/icons-vue"; import { CircleCheck, CircleClose, Loading, Clock, Menu } from "@element-plus/icons-vue";
import { useOnlineCount } from "@/composables"; import { useOnlineCount, useRecentMenus } from "@/composables";
const router = useRouter();
// 在线用户数量组件相关 // 在线用户数量组件相关
const { onlineUserCount, lastUpdateTime, isConnected, connectionState } = useOnlineCount(); const { onlineUserCount, lastUpdateTime, isConnected, connectionState } = useOnlineCount();
// 最近访问菜单
const { recentMenus, clearRecentMenus } = useRecentMenus();
// 格式化时间戳 // 格式化时间戳
const formattedTime = computed(() => { const formattedTime = computed(() => {
if (!lastUpdateTime.value) return "--"; if (!lastUpdateTime.value) return "--";
@@ -429,45 +430,8 @@ const wsStatusClass = computed(() => {
: "text-[--el-color-danger] bg-[--el-color-danger-light-9] border-[--el-color-danger-light-7]"; : "text-[--el-color-danger] bg-[--el-color-danger-light-9] border-[--el-color-danger-light-7]";
}); });
interface VersionItem {
id: string;
title: string; // 版本标题v2.4.0
date: string; // 发布时间
content: string; // 版本描述
link: string; // 详情链接
tag?: string; // 版本标签(可选)
}
const userStore = useUserStore(); const userStore = useUserStore();
// 当前通知公告列表
const vesionList = ref<VersionItem[]>([
{
id: "1",
title: "v2.4.0",
date: "2021-09-01 00:00:00",
content: "实现基础框架搭建,包含权限管理、路由系统等核心功能。",
link: "https://gitee.com/youlaiorg/vue3-element-admin/releases",
tag: "里程碑",
},
{
id: "2",
title: "v2.4.0",
date: "2021-09-01 00:00:00",
content: "实现基础框架搭建,包含权限管理、路由系统等核心功能。",
link: "https://gitee.com/youlaiorg/vue3-element-admin/releases",
tag: "里程碑",
},
{
id: "3",
title: "v2.4.0",
date: "2021-09-01 00:00:00",
content: "实现基础框架搭建,包含权限管理、路由系统等核心功能。",
link: "https://gitee.com/youlaiorg/vue3-element-admin/releases",
tag: "里程碑",
},
]);
// 当前时间(用于计算问候语) // 当前时间(用于计算问候语)
const currentDate = new Date(); const currentDate = new Date();

View File

@@ -1,242 +0,0 @@
<template>
<div class="app-container">
<el-link
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/websocket.vue"
type="primary"
target="_blank"
class="mb-[20px]"
>
示例源码 请点击>>>
</el-link>
<el-row :gutter="10">
<el-col :span="12">
<el-card>
<el-row>
<el-col :span="18">
<el-input v-model="socketEndpoint" style="width: 200px" />
<el-button
type="primary"
class="ml-5"
:disabled="isConnected"
@click="connectWebSocket"
>
连接
</el-button>
<el-button type="danger" :disabled="!isConnected" @click="disconnectWebSocket">
断开
</el-button>
</el-col>
<el-col :span="6" class="text-right">
连接状态
<el-tag v-if="isConnected" type="success">已连接</el-tag>
<el-tag v-else type="info">已断开</el-tag>
</el-col>
</el-row>
</el-card>
<!-- 广播消息发送部分 -->
<el-card class="mt-5">
<el-form label-width="90px">
<el-form-item label="消息内容">
<el-input v-model="topicMessage" type="textarea" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="sendToAll">发送广播</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 点对点消息发送部分 -->
<el-card class="mt-5">
<el-form label-width="90px">
<el-form-item label="消息内容">
<el-input v-model="queneMessage" type="textarea" />
</el-form-item>
<el-form-item label="消息接收人">
<el-input v-model="receiver" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="sendToUser">发送点对点消息</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
<!-- 消息接收显示部分 -->
<el-col :span="12">
<el-card>
<div class="chat-messages-wrapper">
<div
v-for="(message, index) in messages"
:key="index"
:class="[
message.type === 'tip' ? 'system-notice' : 'chat-message',
{
'chat-message--sent': message.sender === userStore.userInfo.username,
'chat-message--received': message.sender !== userStore.userInfo.username,
},
]"
>
<template v-if="message.type != 'tip'">
<div class="chat-message__content">
<div
:class="{
'chat-message__sender': message.sender === userStore.userInfo.username,
'chat-message__receiver': message.sender !== userStore.userInfo.username,
}"
>
{{ message.sender }}
</div>
<div class="text-gray-600">{{ message.content }}</div>
</div>
</template>
<div v-else>{{ message.content }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { useStomp } from "@/composables/websocket/useStomp";
import { useUserStoreHook } from "@/store/modules/user";
const userStore = useUserStoreHook();
// 用于手动调整 WebSocket 地址
const socketEndpoint = ref(import.meta.env.VITE_APP_WS_ENDPOINT);
// 同步连接状态"
interface MessageType {
type?: string;
sender?: string;
content: string;
}
const messages = ref<MessageType[]>([]);
// 广播消息内容
const topicMessage = ref("亲爱的朋友们,系统已恢复最新状态。");
// 点对点消息内容(默认示例)
const queneMessage = ref("Hi, " + userStore.userInfo.username + " 这里是点对点消息示例!");
const receiver = ref("root");
// 调用 useStomp hook默认使用 socketEndpoint 和 token此处用 getAccessToken()
const { isConnected, connect, subscribe, disconnect } = useStomp({
debug: true,
});
watch(
() => isConnected.value,
(connected) => {
if (connected) {
// 连接成功后,订阅广播和点对点消息主题
subscribe("/topic/notice", (res) => {
messages.value.push({
sender: "Server",
content: res.body,
});
});
subscribe("/user/queue/greeting", (res) => {
const messageData = JSON.parse(res.body) as MessageType;
messages.value.push({
sender: messageData.sender,
content: messageData.content,
});
});
messages.value.push({
sender: "Server",
content: "Websocket 已连接",
type: "tip",
});
} else {
messages.value.push({
sender: "Server",
content: "Websocket 已断开",
type: "tip",
});
}
}
);
// 连接 WebSocket
function connectWebSocket() {
connect();
}
// 断开 WebSocket
function disconnectWebSocket() {
disconnect();
}
// 发送广播消息
function sendToAll() {
if (isConnected.value) {
// 直接使用订阅模式处理广播消息
subscribe("/app/broadcast", () => {});
messages.value.push({
sender: userStore.userInfo.username,
content: topicMessage.value,
});
}
}
// 发送点对点消息
function sendToUser() {
if (isConnected.value) {
// 使用订阅模式处理点对点消息
subscribe(`/app/sendToUser/${receiver.value}`, () => {});
messages.value.push({
sender: userStore.userInfo.username,
content: queneMessage.value,
});
}
}
onMounted(() => {
connectWebSocket();
});
onBeforeUnmount(() => {
disconnectWebSocket();
});
</script>
<style scoped lang="scss">
.chat-messages-wrapper {
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-message {
max-width: 80%;
padding: 10px;
border-radius: 5px;
&--sent {
align-self: flex-end;
background-color: #dcf8c6;
}
&--received {
align-self: flex-start;
background-color: #e8e8e8;
}
&__content {
display: flex;
flex-direction: column;
color: var(--el-text-color-primary); // 使用主题文本颜色
}
&__sender {
margin-bottom: 5px;
font-weight: bold;
text-align: right;
}
&__receiver {
margin-bottom: 5px;
font-weight: bold;
text-align: left;
}
}
.system-notice {
align-self: center;
padding: 5px 10px;
font-size: 0.9em;
color: var(--el-text-color-secondary);
background-color: var(--el-fill-color-lighter);
border-radius: 15px;
}
</style>

View File

@@ -203,6 +203,10 @@ const formComponents = {
animation: featureFade 0.8s ease-out; animation: featureFade 0.8s ease-out;
} }
.dark .auth-feature {
color: rgba(240, 245, 255, 0.92);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.auth-view__wrapper { .auth-view__wrapper {
display: block; display: block;