feat: 字典实时同步和首页添加在线用户统计

This commit is contained in:
Ray.Hao
2025-04-22 22:15:15 +08:00
parent cad57b3dc0
commit fdf66164d8
14 changed files with 1275 additions and 70 deletions

View File

@@ -81,8 +81,42 @@
<!-- 数据统计 -->
<el-row :gutter="10" class="mt-5">
<!-- 在线用户数量 -->
<el-col :span="8">
<el-card shadow="never">
<template #header>
<div class="flex-x-between">
<span class="text-gray">在线用户</span>
<el-tag type="danger" size="small">实时</el-tag>
</div>
</template>
<div class="flex-x-between mt-2">
<div class="flex-y-center">
<span class="text-lg transition-all duration-300 hover:scale-110">
{{ onlineUserCount }}
</span>
<span v-if="isConnected" class="ml-2 text-xs text-[#67c23a]">
<el-icon><Connection /></el-icon>
已连接
</span>
<span v-else class="ml-2 text-xs text-[#f56c6c]">
<el-icon><Failed /></el-icon>
未连接
</span>
</div>
<div class="i-svg:people w-8 h-8 animate-[pulse_2s_infinite]" />
</div>
<div class="flex-x-between mt-2 text-sm text-gray">
<span>更新时间</span>
<span>{{ formattedTime }}</span>
</div>
</el-card>
</el-col>
<!-- 访客数(UV) -->
<el-col :span="12">
<el-col :span="8">
<el-skeleton :loading="visitStatsLoading" :rows="5" animated>
<template #template>
<el-card>
@@ -142,7 +176,7 @@
</el-col>
<!-- 浏览量(PV) -->
<el-col :span="12">
<el-col :span="8">
<el-skeleton :loading="visitStatsLoading" :rows="5" animated>
<template #template>
<el-card>
@@ -210,8 +244,8 @@
<div class="flex-x-between">
<span>访问趋势</span>
<el-radio-group v-model="visitTrendDateRange" size="small">
<el-radio-button label="近7天" :value="7" />
<el-radio-button label="30" :value="30" />
<el-radio-button :value="7">近7天</el-radio-button>
<el-radio-button :value="30">近30天</el-radio-button>
</el-radio-group>
</div>
</template>
@@ -288,7 +322,28 @@ import { dayjs } from "element-plus";
import LogAPI, { VisitStatsVO, VisitTrendVO } from "@/api/system/log.api";
import { useUserStore } from "@/store/modules/user.store";
import { formatGrowthRate } from "@/utils";
import { useTransition } from "@vueuse/core";
import { useTransition, useDateFormat } from "@vueuse/core";
import { Connection, Failed } from "@element-plus/icons-vue";
import { useWebSocketOnlineUsers } from "@/hooks/useWebSocketOnlineUsers";
// 在线用户数量组件相关
const { onlineUserCount, lastUpdateTime, isConnected } = useWebSocketOnlineUsers();
// 记录上一次的用户数量用于计算趋势
const previousCount = ref(0);
// 监听用户数量变化,计算趋势
watch(onlineUserCount, (newCount, oldCount) => {
if (oldCount > 0) {
previousCount.value = oldCount;
}
});
// 格式化时间戳
const formattedTime = computed(() => {
if (!lastUpdateTime.value) return "--";
return useDateFormat(lastUpdateTime, "HH:mm:ss").value;
});
interface VersionItem {
id: string;
@@ -506,14 +561,14 @@ const updateVisitTrendChartOptions = (data: VisitTrendVO) => {
*/
const computeGrowthRateClass = (growthRate?: number): string => {
if (!growthRate) {
return "color-[--el-color-info]";
return "text-[--el-color-info]";
}
if (growthRate > 0) {
return "color-[--el-color-danger]";
return "text-[--el-color-danger]";
} else if (growthRate < 0) {
return "color-[--el-color-success]";
return "text-[--el-color-success]";
} else {
return "color-[--el-color-info]";
return "text-[--el-color-info]";
}
};

View File

@@ -0,0 +1,308 @@
<template>
<div class="app-container">
<el-card class="box-card">
<template #header>
<div class="card-header">
<span>字典WebSocket实时更新演示</span>
<el-tag :type="wsConnected ? 'success' : 'danger'" size="small" class="ml-2">
WebSocket {{ wsStatusText }}
</el-tag>
</div>
</template>
<el-alert type="info" :closable="false" class="mb-4">
本示例展示WebSocket实时更新字典缓存的效果您可以编辑"男"性别字典项保存后后端将通过WebSocket通知所有客户端刷新缓存
</el-alert>
<el-row :gutter="16">
<el-col :span="8">
<el-card shadow="hover" class="dict-card">
<template #header>
<div class="flex justify-between items-center">
<span>性别字典项 - </span>
<el-button type="warning" size="small" @click="loadMaleDict">重新加载</el-button>
</div>
</template>
<div>
<div v-if="dictForm" class="dict-form">
<el-form :model="dictForm" label-width="80px">
<el-form-item label="字典编码">
<el-input v-model="dictForm.dictCode" disabled />
</el-form-item>
<el-form-item label="字典标签">
<el-input v-model="dictForm.label" />
</el-form-item>
<el-form-item label="字典值">
<el-input v-model="dictForm.value" disabled />
</el-form-item>
<el-form-item label="标记颜色">
<el-select
v-model="dictForm.tagType"
placeholder="选择标签类型"
style="width: 100%"
>
<el-option value="success" label="success">
<el-tag type="success">success</el-tag>
</el-option>
<el-option value="warning" label="warning">
<el-tag type="warning">warning</el-tag>
</el-option>
<el-option value="danger" label="danger">
<el-tag type="danger">danger</el-tag>
</el-option>
<el-option value="info" label="info">
<el-tag type="info">info</el-tag>
</el-option>
<el-option value="primary" label="primary">
<el-tag type="primary">primary</el-tag>
</el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="saveDict">保存</el-button>
<el-button @click="loadMaleDict">重置</el-button>
</el-form-item>
</el-form>
</div>
<el-empty v-else description="暂无字典数据" />
</div>
</el-card>
</el-col>
<!-- 列2: 字典组件展示 -->
<el-col :span="8">
<el-card shadow="hover" class="dict-card">
<template #header>
<div class="flex justify-between items-center">
<span>字典组件展示</span>
<el-button type="primary" size="small" @click="refreshDictComponent">
手动刷新
</el-button>
</div>
</template>
<div class="dict-component-demo">
<h4 class="mt-4 mb-3">性别组件</h4>
<el-radio-group v-model="selectedGender">
<el-radio
v-for="item in dictStore.getDictItems('gender')"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</el-radio>
</el-radio-group>
<h4 class="mt-4 mb-3">性别标签</h4>
<div>
<el-tag
v-for="item in dictStore.getDictItems('gender')"
:key="item.value"
:type="item.tagType || undefined"
class="mr-2"
>
{{ item.label }}
</el-tag>
</div>
<div class="mt-4 pt-3 border-top">
<div class="text-muted mb-2">已选择值: {{ selectedGender }}</div>
<div class="text-muted">最后更新: {{ lastUpdateTime }}</div>
</div>
</div>
</el-card>
</el-col>
<!-- 列3: 字典缓存数据 -->
<el-col :span="8">
<el-card shadow="hover" class="dict-card">
<template #header>
<div class="flex justify-between items-center">
<span>字典缓存数据</span>
<div>
<el-tag v-if="dictCacheStatus" type="success" class="ml-2" size="small">
已缓存
</el-tag>
<el-tag v-else type="danger" class="ml-2" size="small">未缓存</el-tag>
</div>
</div>
</template>
<div class="cache-content">
<pre class="cache-data">{{
JSON.stringify(dictStore.getDictItems("gender"), null, 2)
}}</pre>
</div>
</el-card>
</el-col>
</el-row>
</el-card>
</div>
</template>
<script setup lang="ts">
import { useDictStoreHook } from "@/store/modules/dict.store";
import { useDateFormat } from "@vueuse/core";
import DictAPI, { DictItemForm } from "@/api/system/dict.api";
import { useWebSocketDict, DictMessage } from "@/hooks/useWebSocketDict";
// 性别字典编码
const DICT_CODE = "gender";
// 男性字典项ID
const MALE_ITEM_ID = "1";
// 字典store
const dictStore = useDictStoreHook();
// 保存状态
const saving = ref(false);
// 最后更新时间
const lastUpdateTime = ref("-");
// 字典表单数据
const dictForm = ref<DictItemForm | null>(null);
// 选中的性别
const selectedGender = ref("");
// 初始化WebSocket
const dictWebSocket = useWebSocketDict();
// 获取连接状态
const wsConnected = computed(() => dictWebSocket.isConnected);
// WebSocket连接状态显示文本
const wsStatusText = computed(() => (wsConnected.value ? "已连接" : "未连接"));
// 保存WebSocket清理函数
let unregisterCallback: (() => void) | null = null;
// 当前选中字典的缓存状态
const dictCacheStatus = computed(() => {
// 检查字典是否在缓存中
return dictStore.getDictItems(DICT_CODE).length > 0;
});
// 设置WebSocket
const setupWebSocket = () => {
// 初始化WebSocket连接
dictWebSocket.initWebSocket();
// 注册字典消息回调
unregisterCallback = dictWebSocket.onDictMessage((message: DictMessage) => {
// 只有当消息是关于性别字典的更新时才处理
if (message.dictCode === DICT_CODE) {
// 更新最后更新时间
lastUpdateTime.value = useDateFormat(new Date(), "YYYY-MM-DD HH:mm:ss").value;
// 触发字典组件重新加载
nextTick(() => {
refreshDictComponent();
});
}
});
};
// 刷新字典组件,强制重新加载字典数据
const refreshDictComponent = async () => {
// 这里重新获取字典数据以触发按需加载
await dictStore.loadDictItems(DICT_CODE);
ElMessage.success("字典组件已刷新");
};
// 加载男性字典表单数据
const loadMaleDict = async () => {
// 获取男性字典项表单数据 - 使用接口 /dicts/gender/items/1/form
const data = await DictAPI.getDictItemFormData(DICT_CODE, MALE_ITEM_ID);
dictForm.value = data;
};
// 保存字典项
const saveDict = async () => {
if (!dictForm.value) return;
saving.value = true;
try {
// dictForm的类型已经是DictItemForm直接传入
await DictAPI.updateDictItem(DICT_CODE, MALE_ITEM_ID, dictForm.value);
// 更新时间
lastUpdateTime.value = useDateFormat(new Date(), "YYYY-MM-DD HH:mm:ss").value;
ElMessage.success("保存成功后端将通过WebSocket通知所有客户端");
} catch (error) {
console.error("保存字典项失败:", error);
ElMessage.error("保存失败");
} finally {
saving.value = false;
}
};
// 组件挂载时加载性别字典
onMounted(async () => {
await loadMaleDict();
// 加载初始字典数据
await dictStore.loadDictItems(DICT_CODE);
// 初始化选中性别为男
selectedGender.value = "1";
// 设置WebSocket
setupWebSocket();
});
// 组件卸载时清理WebSocket
onUnmounted(() => {
unregisterCallback?.();
});
</script>
<style scoped>
.dict-card {
display: flex;
flex-direction: column;
height: 600px;
overflow: hidden;
}
.dict-card :deep(.el-card__body) {
flex: 1;
overflow: auto;
}
.dict-component-demo {
display: flex;
flex-direction: column;
height: 100%;
padding: 12px;
}
.cache-content {
height: 100%;
overflow: hidden;
}
pre {
padding: 8px;
overflow-y: auto;
word-wrap: break-word;
white-space: pre-wrap;
background-color: #f8f9fa;
border-radius: 4px;
}
.cache-data {
height: 100%;
padding: 8px;
overflow-y: auto;
font-size: 12px;
background-color: #f8f9fa;
border-radius: 4px;
}
.dict-form {
margin-bottom: 20px;
}
.text-muted {
font-size: 0.9em;
color: #909399;
}
.border-top {
border-top: 1px solid #ebeef5;
}
</style>

View File

@@ -0,0 +1,390 @@
<template>
<div class="app-container">
<el-card class="box-card">
<template #header>
<div class="card-header">
<span>WebSocket测试</span>
</div>
</template>
<el-tabs v-model="activeTab">
<el-tab-pane label="连接状态" name="status">
<el-alert
:title="isConnected ? '已连接到WebSocket服务器' : '未连接到WebSocket服务器'"
:type="isConnected ? 'success' : 'error'"
:description="connectionMessage"
show-icon
:closable="false"
/>
<div class="button-container">
<el-button
:type="isConnected ? 'warning' : 'primary'"
:loading="connecting"
@click="toggleConnection"
>
{{ isConnected ? "断开连接" : "连接" }}
</el-button>
</div>
</el-tab-pane>
<el-tab-pane label="字典更新通知" name="dict">
<p class="section-desc">触发字典更新通知所有在线用户会收到该通知</p>
<div class="form-container">
<el-form :model="dictForm" label-width="100px">
<el-form-item label="字典编码">
<el-input v-model="dictForm.dictCode" placeholder="请输入字典编码" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="sendDictUpdate">发送字典更新通知</el-button>
</el-form-item>
</el-form>
</div>
</el-tab-pane>
<el-tab-pane label="用户消息" name="message">
<p class="section-desc">向特定用户发送消息</p>
<div class="form-container">
<el-form :model="messageForm" label-width="100px">
<el-form-item label="接收用户">
<el-input v-model="messageForm.receiver" placeholder="请输入接收用户的用户名" />
</el-form-item>
<el-form-item label="消息内容">
<el-input
v-model="messageForm.content"
type="textarea"
:rows="3"
placeholder="请输入消息内容"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="sendUserMessage">发送消息</el-button>
<el-button type="success" @click="sendBroadcast">发送广播</el-button>
</el-form-item>
</el-form>
</div>
</el-tab-pane>
<el-tab-pane label="在线用户" name="online">
<div class="stats-container">
<el-statistic title="当前在线用户数">
<template #value>
<div class="statistic-value">{{ onlineStats.total }}</div>
</template>
</el-statistic>
</div>
<el-table v-loading="loadingUsers" :data="onlineUsers" style="width: 100%">
<el-table-column prop="username" label="用户名" width="180" />
<el-table-column prop="nickname" label="昵称" width="180" />
<el-table-column prop="loginTime" label="登录时间">
<template #default="scope">
{{ formatDate(scope.row.loginTime) }}
</template>
</el-table-column>
</el-table>
<div class="button-container">
<el-button type="primary" @click="fetchOnlineUsers">刷新列表</el-button>
</div>
</el-tab-pane>
<el-tab-pane label="消息记录" name="logs">
<div class="logs-container">
<div v-for="(log, index) in messageLogs" :key="index" class="log-item">
<div class="log-time">{{ formatDate(log.timestamp) }}</div>
<div class="log-content" :class="{ 'log-broadcast': log.isBroadcast }">
<span class="log-sender">{{ log.sender }}</span>
: {{ log.content }}
<el-tag v-if="log.isBroadcast" size="small" type="warning">广播</el-tag>
</div>
</div>
<div v-if="messageLogs.length === 0" class="empty-logs">暂无消息记录</div>
</div>
<div class="button-container">
<el-button type="danger" @click="clearLogs">清空记录</el-button>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import { ElMessage } from "element-plus";
import { useWebSocketDict } from "@/hooks/useWebSocketDict";
import { useUserStore } from "@/store/modules/user";
import { getDictList } from "@/api/dict";
import { getOnlineUsers, getOnlineStats } from "@/api/user";
const userStore = useUserStore();
const { connectWebSocket, disconnectWebSocket, isConnected, sendMessage, subscribe } =
useWebSocketDict();
// 状态变量
const activeTab = ref("status");
const connecting = ref(false);
const connectionMessage = ref("WebSocket连接状态");
const messageLogs = ref([]);
const onlineUsers = ref([]);
const onlineStats = ref({ total: 0 });
const loadingUsers = ref(false);
// 表单数据
const dictForm = ref({
dictCode: "gender",
});
const messageForm = ref({
receiver: "",
content: "",
});
// 连接和断开WebSocket
const toggleConnection = async () => {
connecting.value = true;
try {
if (isConnected.value) {
disconnectWebSocket();
connectionMessage.value = "已断开连接";
} else {
await connectWebSocket();
connectionMessage.value = "已成功连接到WebSocket服务器";
setupSubscriptions();
}
} catch (error) {
connectionMessage.value = `连接失败: ${error.message}`;
ElMessage.error(`WebSocket连接失败: ${error.message}`);
} finally {
connecting.value = false;
}
};
// 设置订阅
const setupSubscriptions = () => {
// 订阅字典更新
subscribe("/topic/dict", (message) => {
addMessageLog({
sender: "System",
content: `字典 ${message.dictCode} 已更新`,
timestamp: new Date().getTime(),
isBroadcast: true,
});
ElMessage.success(`字典 ${message.dictCode} 已更新`);
});
// 订阅用户消息
const username = userStore.userInfo.username;
subscribe(`/user/${username}/messages`, (message) => {
addMessageLog({
sender: message.sender,
content: message.content,
timestamp: message.timestamp,
isBroadcast: false,
});
ElMessage.info(`收到来自 ${message.sender} 的消息`);
});
// 订阅广播消息
subscribe("/topic/public", (message) => {
addMessageLog({
sender: message.sender,
content: message.content,
timestamp: message.timestamp,
isBroadcast: true,
});
ElMessage.info(`收到来自 ${message.sender} 的广播消息`);
});
// 订阅在线用户更新
subscribe("/topic/users/online", (message) => {
ElMessage.info(`用户 ${message.username} ${message.online ? "上线" : "下线"}`);
fetchOnlineUsers();
fetchOnlineStats();
});
};
// 发送字典更新通知
const sendDictUpdate = async () => {
try {
// 调用字典API触发更新
await getDictList({ dictCode: dictForm.value.dictCode });
ElMessage.success("字典更新通知已发送");
} catch (error) {
ElMessage.error(`发送失败: ${error.message}`);
}
};
// 发送用户消息
const sendUserMessage = () => {
if (!messageForm.value.receiver || !messageForm.value.content) {
ElMessage.warning("请输入接收用户和消息内容");
return;
}
sendMessage(`/app/sendToUser/${messageForm.value.receiver}`, messageForm.value.content);
// 记录发送的消息
addMessageLog({
sender: userStore.userInfo.username,
content: `[发送给 ${messageForm.value.receiver}] ${messageForm.value.content}`,
timestamp: new Date().getTime(),
isBroadcast: false,
});
ElMessage.success("消息已发送");
};
// 发送广播消息
const sendBroadcast = () => {
if (!messageForm.value.content) {
ElMessage.warning("请输入消息内容");
return;
}
sendMessage("/app/broadcast", messageForm.value.content);
ElMessage.success("广播消息已发送");
};
// 获取在线用户
const fetchOnlineUsers = async () => {
loadingUsers.value = true;
try {
const res = await getOnlineUsers();
onlineUsers.value = res.data;
} catch (error) {
ElMessage.error(`获取在线用户失败: ${error.message}`);
} finally {
loadingUsers.value = false;
}
};
// 获取在线用户统计
const fetchOnlineStats = async () => {
try {
const res = await getOnlineStats();
onlineStats.value = res.data;
} catch (error) {
ElMessage.error(`获取在线统计失败: ${error.message}`);
}
};
// 添加消息日志
const addMessageLog = (log) => {
messageLogs.value.unshift(log);
// 限制日志数量
if (messageLogs.value.length > 100) {
messageLogs.value = messageLogs.value.slice(0, 100);
}
};
// 清空日志
const clearLogs = () => {
messageLogs.value = [];
};
// 格式化日期
const formatDate = (timestamp) => {
const date = new Date(timestamp);
return date.toLocaleString();
};
// 生命周期钩子
onMounted(async () => {
try {
await connectWebSocket();
connectionMessage.value = "已成功连接到WebSocket服务器";
setupSubscriptions();
await fetchOnlineUsers();
await fetchOnlineStats();
} catch (error) {
connectionMessage.value = `连接失败: ${error.message}`;
}
});
onUnmounted(() => {
disconnectWebSocket();
});
</script>
<style scoped>
.app-container {
padding: 20px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.section-desc {
margin-bottom: 20px;
color: #666;
}
.button-container {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.form-container {
max-width: 600px;
margin-top: 20px;
}
.logs-container {
height: 400px;
padding: 12px;
margin-bottom: 20px;
overflow-y: auto;
border: 1px solid #ebeef5;
border-radius: 4px;
}
.log-item {
padding-bottom: 12px;
margin-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.log-time {
margin-bottom: 4px;
font-size: 12px;
color: #909399;
}
.log-content {
word-break: break-all;
}
.log-sender {
font-weight: bold;
color: #409eff;
}
.log-broadcast .log-sender {
color: #e6a23c;
}
.empty-logs {
padding: 20px;
color: #909399;
text-align: center;
}
.stats-container {
display: flex;
margin-bottom: 20px;
}
.statistic-value {
font-size: 24px;
font-weight: bold;
color: #409eff;
}
</style>