refactor(sse): 重构 SSE 分层并同步调整通知中心与示例页
- src/composables/sse: 删除,连接实现下沉到 utils/sse,事件名枚举迁到 enums/sse - src/utils/sse: 新增 SSE 连接与事件订阅实现 - src/stores/dict: 改用 utils/sse 订阅字典变更 - src/stores/user: 登出清理改用 utils/sse 的 cleanupSse - src/views/dashboard: 在线用户订阅下沉为页面级 composable - src/components/NoticeCenter: 由 NoticeDropdown 改名 - src/views/demo: 示例页面按 component/route/table 归类 - src/router: 同步示例详情页路由路径
This commit is contained in:
1 parent
d36ccf54d5
commit
d136266acd
47 files changed
+207
-430
No files matched your search
@@ -0,0 +1,37 @@
|
||||
import { onMounted, onUnmounted, readonly, ref } from "vue";
|
||||
import { useSse } from "@/utils/sse";
|
||||
import { SseTopics } from "@/enums/sse";
|
||||
|
||||
/**
|
||||
* 在线用户数组合式函数(页面级)
|
||||
*
|
||||
* 订阅 SSE 在线用户数事件,组件挂载时订阅、卸载时自动取消;
|
||||
* 进入页面后需等待下一次推送才有数据。
|
||||
*/
|
||||
export function useOnlineUsers() {
|
||||
const onlineUserCount = ref(0);
|
||||
|
||||
const { isConnected, on } = useSse();
|
||||
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
const handleOnlineUsersMessage = (count: number) => {
|
||||
if (!Number.isFinite(count) || count < 0) return;
|
||||
onlineUserCount.value = count;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
unsubscribe = on(SseTopics.ONLINE_USERS, handleOnlineUsersMessage);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
onlineUserCount.value = 0;
|
||||
});
|
||||
|
||||
return {
|
||||
onlineUserCount: readonly(onlineUserCount),
|
||||
isConnected,
|
||||
};
|
||||
}
|
||||
@@ -261,7 +261,7 @@ import {
|
||||
Document,
|
||||
VideoPlay,
|
||||
} from "@element-plus/icons-vue";
|
||||
import { useOnlineUsers } from "@/composables";
|
||||
import { useOnlineUsers } from "./composables/useOnlineUsers";
|
||||
|
||||
const userStore = useUserStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
@@ -141,11 +141,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDictStoreHook } from "@/stores/dict";
|
||||
import type { DictChangeMessage } from "@/stores/dict";
|
||||
import { useDateFormat } from "@vueuse/core";
|
||||
import DictAPI from "@/api/system/dict";
|
||||
import type { DictItemForm } from "@/api/system/dict";
|
||||
import { useDictSync } from "@/composables";
|
||||
import type { DictChangeMessage } from "@/composables";
|
||||
import { useSse } from "@/utils/sse";
|
||||
|
||||
// 性别字典编码
|
||||
const DICT_CODE = "gender";
|
||||
@@ -163,11 +163,11 @@ const dictForm = ref<DictItemForm | null>(null);
|
||||
// 选中的性别
|
||||
const selectedGender = ref("");
|
||||
|
||||
// 初始化 SSE
|
||||
const dictSse = useDictSync();
|
||||
// SSE 连接(用于展示连接状态)
|
||||
const sse = useSse();
|
||||
|
||||
// 获取连接状态
|
||||
const sseConnected = computed(() => dictSse.isConnected.value);
|
||||
const sseConnected = computed(() => sse.isConnected.value);
|
||||
|
||||
// SSE 连接状态显示文本
|
||||
const sseStatusText = computed(() => (sseConnected.value ? "已连接" : "未连接"));
|
||||
@@ -183,11 +183,11 @@ const dictCacheStatus = computed(() => {
|
||||
|
||||
// 设置 SSE
|
||||
const setupSse = () => {
|
||||
// 初始化 SSE 连接
|
||||
dictSse.initialize();
|
||||
// 初始化字典同步订阅(幂等,全局已在应用启动时开启)
|
||||
dictStore.setupDictSync();
|
||||
|
||||
// 注册字典消息回调
|
||||
unregisterCallback = dictSse.onDictChange((message: DictChangeMessage) => {
|
||||
unregisterCallback = dictStore.onDictChange((message: DictChangeMessage) => {
|
||||
// 只有当消息是关于性别字典的更新时才处理
|
||||
if (message.dictCode === DICT_CODE) {
|
||||
// 更新最后更新时间
|
||||
@@ -2,7 +2,7 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-link
|
||||
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/dictionary.vue"
|
||||
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/component/dictionary.vue"
|
||||
type="primary"
|
||||
target="_blank"
|
||||
class="mb-[20px]"
|
||||
File renamed without changes.
@@ -2,18 +2,23 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-link
|
||||
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/icon-select.vue"
|
||||
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/component/icon-select.vue"
|
||||
type="primary"
|
||||
target="_blank"
|
||||
class="mb-10"
|
||||
>
|
||||
示例源码 请点击>>>
|
||||
</el-link>
|
||||
<icon-select v-model="iconName" />
|
||||
<IconSelect v-model="iconName" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ❌ 踩坑:页面文件名 icon-select.vue 与模板标签 <icon-select> 同名会触发 Vue 隐式自引用
|
||||
// (unplugin-vue-components 对同名组件禁用自动导入,运行时解析到页面自身 → 无限递归栈溢出白屏)
|
||||
// ✅ 显式导入公共组件,优先级高于隐式自引用,杜绝递归
|
||||
import IconSelect from "@/components/IconSelect/index.vue";
|
||||
|
||||
// element-plus 图标格式以el-icon-开头
|
||||
const iconName = ref("el-icon-edit");
|
||||
// 本地SVG图标格式为src/assets/icons 下的文件名,不需要svg后缀
|
||||
File renamed without changes.
File renamed without changes.
+1
-1
@@ -2,7 +2,7 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-link
|
||||
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/table-select/index.vue"
|
||||
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/component/table-select/index.vue"
|
||||
type="primary"
|
||||
target="_blank"
|
||||
class="mb-10"
|
||||
@@ -18,6 +18,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 必须显式导入:页面文件名 text-scroll.vue 与标签 <TextScroll> 同名,会触发 Vue 隐式自引用
|
||||
// (自动导入对同名组件不生效,运行时解析到页面自身 → 递归栈溢出白屏)
|
||||
import TextScroll from "@/components/TextScroll/index.vue";
|
||||
</script>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<el-button
|
||||
class="page-header__link"
|
||||
tag="a"
|
||||
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/upload.vue"
|
||||
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/component/upload.vue"
|
||||
target="_blank"
|
||||
type="primary"
|
||||
plain
|
||||
@@ -2,7 +2,7 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-link
|
||||
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/wang-editor.vue"
|
||||
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/component/wang-editor.vue"
|
||||
type="primary"
|
||||
target="_blank"
|
||||
class="mb-[20px]"
|
||||
@@ -18,6 +18,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 必须显式导入:页面文件名 wang-editor.vue 与标签 <WangEditor> 同名,会触发 Vue 隐式自引用
|
||||
// (自动导入对同名组件不生效,运行时解析到页面自身 → 递归栈溢出白屏)
|
||||
import WangEditor from "@/components/WangEditor/index.vue";
|
||||
|
||||
const value = ref("初始化内容");
|
||||
@@ -1,21 +0,0 @@
|
||||
<!-- 图标选择器示例 -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-link
|
||||
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/icon-selector.vue"
|
||||
type="primary"
|
||||
target="_blank"
|
||||
class="mb-10"
|
||||
>
|
||||
示例源码 请点击>>>
|
||||
</el-link>
|
||||
<icon-select v-model="iconName" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// element-plus 图标格式以el-icon-开头
|
||||
const iconName = ref("el-icon-edit");
|
||||
// 本地SVG图标格式为src/assets/icons 下的文件名,不需要svg后缀
|
||||
// const iconName = ref("api");
|
||||
</script>
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,188 +0,0 @@
|
||||
<template>
|
||||
<div class="canvas-dom">
|
||||
<h3>基于canvas实现的签名组件</h3>
|
||||
<header>
|
||||
<el-button type="primary" @click="handleSaveImg">保存为图片</el-button>
|
||||
<el-button @click="handleToFile">保存到后端</el-button>
|
||||
<el-button @click="handleClearSign">清空签名</el-button>
|
||||
</header>
|
||||
<canvas
|
||||
ref="canvas"
|
||||
height="200"
|
||||
width="500"
|
||||
@mousedown="onEventStart"
|
||||
@mousemove.stop.prevent="onEventMove"
|
||||
@mouseup="onEventEnd"
|
||||
@touchstart="onEventStart"
|
||||
@touchmove.stop.prevent="onEventMove"
|
||||
@touchend="onEventEnd"
|
||||
/>
|
||||
<img v-if="imgUrl" :src="imgUrl" alt="签名" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import FileAPI from "@/api/file";
|
||||
|
||||
const imgUrl = ref("");
|
||||
const canvas = ref();
|
||||
let ctx: CanvasRenderingContext2D;
|
||||
let strokeColor = "#000";
|
||||
|
||||
// 正在绘制中,用来控制 move 和 end 事件
|
||||
let painting = false;
|
||||
|
||||
// 获取触发点相对被触发dom的左、上角距离
|
||||
const getOffset = (event: MouseEvent | TouchEvent) => {
|
||||
let offset: [number, number];
|
||||
if ((event as MouseEvent).offsetX) {
|
||||
// PC端
|
||||
const { offsetX, offsetY } = event as MouseEvent;
|
||||
offset = [offsetX, offsetY];
|
||||
} else {
|
||||
// 移动端
|
||||
const { top, left } = canvas.value.getBoundingClientRect();
|
||||
const offsetX = (event as TouchEvent).touches[0].clientX - left;
|
||||
const offsetY = (event as TouchEvent).touches[0].clientY - top;
|
||||
offset = [offsetX, offsetY];
|
||||
}
|
||||
|
||||
return offset;
|
||||
};
|
||||
|
||||
// 绘制起点
|
||||
let startX = 0,
|
||||
startY = 0;
|
||||
|
||||
// 鼠标/触摸 按下时,保存 触发点相对被触发dom的左、上 距离
|
||||
const onEventStart = (event: MouseEvent | TouchEvent) => {
|
||||
[startX, startY] = getOffset(event);
|
||||
painting = true;
|
||||
};
|
||||
|
||||
const onEventMove = (event: MouseEvent | TouchEvent) => {
|
||||
if (painting) {
|
||||
// 鼠标/触摸 移动时,保存移动点相对被触发 DOM 的左、上距离
|
||||
const [endX, endY] = getOffset(event);
|
||||
paint(startX, startY, endX, endY, ctx);
|
||||
|
||||
// 每次绘制或清除结束后,起点要重置为上次的终点
|
||||
startX = endX;
|
||||
startY = endY;
|
||||
}
|
||||
};
|
||||
|
||||
const onEventEnd = () => {
|
||||
if (painting) {
|
||||
painting = false; // 停止绘制
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
ctx = canvas.value.getContext("2d") as CanvasRenderingContext2D;
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
strokeColor = rootStyle.getPropertyValue("--el-text-color-primary").trim() || "#000";
|
||||
});
|
||||
const handleToFile = async () => {
|
||||
if (isCanvasBlank(canvas.value)) {
|
||||
ElMessage({
|
||||
type: "warning",
|
||||
message: "当前签名文件为空",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const file = dataURLtoFile(canvas.value.toDataURL(), "签名.png");
|
||||
|
||||
if (!file) return;
|
||||
const data = await FileAPI.uploadFile(file);
|
||||
handleClearSign();
|
||||
imgUrl.value = data.url;
|
||||
};
|
||||
const handleClearSign = () => {
|
||||
ctx.clearRect(0, 0, canvas.value.width, canvas.value.height);
|
||||
};
|
||||
const isCanvasBlank = (canvas: HTMLCanvasElement) => {
|
||||
const blank = document.createElement("canvas"); //系统获取一个空canvas对象
|
||||
blank.width = canvas.width;
|
||||
blank.height = canvas.height;
|
||||
return canvas.toDataURL() === blank.toDataURL(); //比较值相等则为空
|
||||
};
|
||||
|
||||
// 保存为图片
|
||||
const handleSaveImg = () => {
|
||||
if (isCanvasBlank(canvas.value)) {
|
||||
ElMessage({
|
||||
type: "warning",
|
||||
message: "当前签名文件为空",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const el = document.createElement("a");
|
||||
// 设置 href 为图片经过 base64 编码后的字符串,默认为 png 格式
|
||||
el.href = canvas.value.toDataURL();
|
||||
el.download = "签名";
|
||||
// 创建一个点击事件并对 a 标签进行触发
|
||||
const event = new MouseEvent("click");
|
||||
el.dispatchEvent(event);
|
||||
};
|
||||
// 转为file格式,可传递给后端
|
||||
const dataURLtoFile = (dataurl: string, filename: string) => {
|
||||
const arr: string[] = dataurl.split(",");
|
||||
if (!arr.length) return;
|
||||
|
||||
const mime = arr[0].match(/:(.*?);/);
|
||||
if (mime) {
|
||||
const bstr = atob(arr[1]);
|
||||
let n = bstr.length;
|
||||
const u8arr = new Uint8Array(n);
|
||||
while (n--) {
|
||||
u8arr[n] = bstr.charCodeAt(n);
|
||||
}
|
||||
return new File([u8arr], filename, { type: mime[1] });
|
||||
}
|
||||
};
|
||||
// canvas 画图
|
||||
function paint(
|
||||
startX: number,
|
||||
startY: number,
|
||||
endX: number,
|
||||
endY: number,
|
||||
ctx: CanvasRenderingContext2D
|
||||
) {
|
||||
ctx.beginPath();
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.moveTo(startX, startY);
|
||||
ctx.lineTo(endX, endY);
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.canvas-dom {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 20px;
|
||||
background-color: var(--el-bg-color);
|
||||
|
||||
canvas {
|
||||
border: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 8px;
|
||||
|
||||
.eraser-option {
|
||||
display: flex;
|
||||
|
||||
label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+1
-3
@@ -2,7 +2,7 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-link
|
||||
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/auto-opreation-column.vue"
|
||||
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/table/auto-operation-column.vue"
|
||||
type="primary"
|
||||
target="_blank"
|
||||
class="mb-10"
|
||||
@@ -48,8 +48,6 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import OperationColumn from "@/components/OperationColumn/index.vue";
|
||||
|
||||
const checked1 = ref(true);
|
||||
const checked2 = ref(false);
|
||||
const checked3 = ref(false);
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="page-container h-full flex flex-1 flex-col">
|
||||
<div class="mb-10">
|
||||
<el-link
|
||||
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/curd-demo.vue"
|
||||
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/table/curd-single.vue"
|
||||
type="primary"
|
||||
target="_blank"
|
||||
>
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -2,7 +2,7 @@
|
||||
<div class="page-container h-full flex flex-1 flex-col">
|
||||
<div class="flex-x-between mb-10">
|
||||
<el-link
|
||||
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/curd/index.vue"
|
||||
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/table/curd/index.vue"
|
||||
type="primary"
|
||||
target="_blank"
|
||||
>
|
||||
File renamed without changes.
Reference in new issue
Block a user