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,7 +1,8 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import type { NoticeDetail, NoticeItem, NoticeQueryParams } from "@/api/system/notice";
|
||||
import NoticeAPI from "@/api/system/notice";
|
||||
import { useSse, SseTopics } from "@/composables";
|
||||
import { useSse } from "@/utils/sse";
|
||||
import { SseTopics } from "@/enums/sse";
|
||||
import router from "@/router";
|
||||
|
||||
/** 下拉面板每页展示条数 */
|
||||
@@ -1,8 +1,3 @@
|
||||
// SSE 服务
|
||||
export { setupSse, cleanupSseServices } from "./sse";
|
||||
export { useSse, useDictSync, useOnlineUsers, cleanupSse, SseConnectionState, SseTopics } from "./sse";
|
||||
export type { DictChangeMessage, DictChangeCallback, SseTopic } from "./sse";
|
||||
|
||||
// 表格相关
|
||||
export { useTableSelection } from "./useTableSelection";
|
||||
export { usePageTable } from "./usePageTable";
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useDictSync } from "./useDictSync";
|
||||
import { useOnlineUsers } from "./useOnlineUsers";
|
||||
import { useSse, cleanupSse } from "./useSse";
|
||||
|
||||
/**
|
||||
* 初始化所有 SSE 服务
|
||||
*/
|
||||
export function setupSse() {
|
||||
const sse = useSse();
|
||||
sse.connect();
|
||||
|
||||
const dictSync = useDictSync();
|
||||
dictSync.initialize();
|
||||
|
||||
const onlineUsers = useOnlineUsers();
|
||||
onlineUsers.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有 SSE 连接
|
||||
*/
|
||||
export function cleanupSseServices() {
|
||||
const dictSync = useDictSync();
|
||||
dictSync.cleanup();
|
||||
|
||||
const onlineUsers = useOnlineUsers();
|
||||
onlineUsers.cleanup();
|
||||
|
||||
cleanupSse();
|
||||
}
|
||||
|
||||
export { useDictSync } from "./useDictSync";
|
||||
export { useOnlineUsers } from "./useOnlineUsers";
|
||||
export { useSse, cleanupSse, SseConnectionState } from "./useSse";
|
||||
export { SseTopics } from "./sseTopics";
|
||||
export type { DictChangeMessage, DictChangeCallback } from "./useDictSync";
|
||||
export type { SseTopic } from "./sseTopics";
|
||||
@@ -1,17 +0,0 @@
|
||||
/** SSE 事件名常量,与后端 SseTopics.java 一一对应 */
|
||||
export const SseTopics = {
|
||||
/** 字典变更事件 */
|
||||
DICT: "dict",
|
||||
/** 在线用户数事件 */
|
||||
ONLINE_USERS: "online-users",
|
||||
/** 系统消息事件 */
|
||||
SYSTEM: "system",
|
||||
/** 心跳事件 */
|
||||
PING: "ping",
|
||||
/** 通知事件 */
|
||||
NOTICE: "notice",
|
||||
/** 通知撤回事件 */
|
||||
NOTICE_REVOKE: "notice-revoke",
|
||||
} as const;
|
||||
|
||||
export type SseTopic = (typeof SseTopics)[keyof typeof SseTopics];
|
||||
@@ -1,88 +0,0 @@
|
||||
import { useDictStoreHook } from "@/stores/dict";
|
||||
import { useSse } from "./useSse";
|
||||
import { SseTopics } from "./sseTopics";
|
||||
|
||||
/** 字典变更消息体 */
|
||||
export interface DictChangeMessage {
|
||||
/** 字典编码 */
|
||||
dictCode: string;
|
||||
}
|
||||
|
||||
/** 字典变更回调函数类型 */
|
||||
export type DictChangeCallback = (message: DictChangeMessage) => void;
|
||||
|
||||
let globalInstance: ReturnType<typeof createDictSyncComposable> | null = null;
|
||||
|
||||
function createDictSyncComposable() {
|
||||
const dictStore = useDictStoreHook();
|
||||
const sse = useSse();
|
||||
|
||||
const callbacks: DictChangeCallback[] = [];
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let initialized = false; // 防止重复初始化导致重复订阅
|
||||
|
||||
// 处理字典变更消息:清除指定字典缓存,并通知所有已注册回调
|
||||
const handleDictChange = (data: DictChangeMessage) => {
|
||||
const { dictCode } = data;
|
||||
if (!dictCode) {
|
||||
console.warn("[DictSync] 收到无效的字典变更消息:缺少 dictCode");
|
||||
return;
|
||||
}
|
||||
|
||||
dictStore.removeDictItem(dictCode);
|
||||
callbacks.forEach((cb) => {
|
||||
try {
|
||||
cb(data);
|
||||
} catch (err) {
|
||||
console.error("[DictSync] 回调执行失败:", err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 订阅 SSE 字典变更事件(幂等:重复调用不会产生重复订阅)
|
||||
const initialize = () => {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
unsubscribe = sse.on(SseTopics.DICT, handleDictChange);
|
||||
};
|
||||
|
||||
// 取消 SSE 订阅并清空所有回调
|
||||
const cleanup = () => {
|
||||
initialized = false;
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
callbacks.length = 0;
|
||||
};
|
||||
|
||||
// 注册字典变更回调,返回取消注册函数
|
||||
const onDictChange = (cb: DictChangeCallback) => {
|
||||
callbacks.push(cb);
|
||||
return () => {
|
||||
const idx = callbacks.indexOf(cb);
|
||||
if (idx !== -1) callbacks.splice(idx, 1);
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
isConnected: sse.isConnected,
|
||||
connectionState: sse.connectionState,
|
||||
initialize,
|
||||
cleanup,
|
||||
onDictChange,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 字典同步组合式函数(单例模式)
|
||||
*
|
||||
* 监听 SSE 字典变更事件,收到变更时自动清除对应字典缓存,
|
||||
* 并通知所有已注册的回调函数。
|
||||
*
|
||||
* @returns 字典同步实例,包含连接状态、初始化、清理和回调注册方法
|
||||
*/
|
||||
export function useDictSync() {
|
||||
if (!globalInstance) {
|
||||
globalInstance = createDictSyncComposable();
|
||||
}
|
||||
return globalInstance;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { ref, readonly } from "vue";
|
||||
import { useSse } from "./useSse";
|
||||
import { SseTopics } from "./sseTopics";
|
||||
|
||||
let globalInstance: ReturnType<typeof createOnlineUsersComposable> | null = null;
|
||||
|
||||
function createOnlineUsersComposable() {
|
||||
const onlineUserCount = ref(0);
|
||||
const lastUpdateTime = ref(0);
|
||||
|
||||
const sse = useSse();
|
||||
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
const handleOnlineUsersMessage = (count: number) => {
|
||||
if (!Number.isFinite(count) || count < 0) return;
|
||||
onlineUserCount.value = count;
|
||||
lastUpdateTime.value = Date.now();
|
||||
};
|
||||
|
||||
const initialize = () => {
|
||||
unsubscribe = sse.on(SseTopics.ONLINE_USERS, handleOnlineUsersMessage);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = null;
|
||||
}
|
||||
onlineUserCount.value = 0;
|
||||
lastUpdateTime.value = 0;
|
||||
};
|
||||
|
||||
return {
|
||||
onlineUserCount: readonly(onlineUserCount),
|
||||
lastUpdateTime: readonly(lastUpdateTime),
|
||||
isConnected: sse.isConnected,
|
||||
connectionState: sse.connectionState,
|
||||
initialize,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
/** 在线用户数组合式函数(单例模式) */
|
||||
export function useOnlineUsers() {
|
||||
if (!globalInstance) {
|
||||
globalInstance = createOnlineUsersComposable();
|
||||
}
|
||||
return globalInstance;
|
||||
}
|
||||
@@ -10,3 +10,4 @@ export * from "./business";
|
||||
export * from "./codegen";
|
||||
export * from "./common";
|
||||
export * from "./settings";
|
||||
export * from "./sse";
|
||||
|
||||
24
src/enums/sse.ts
Normal file
24
src/enums/sse.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* SSE 相关枚举
|
||||
*
|
||||
* @description
|
||||
* SSE 事件名枚举,与后端 SseTopics.java 一一对应
|
||||
*/
|
||||
|
||||
/**
|
||||
* SSE 事件名枚举
|
||||
*/
|
||||
export const enum SseTopics {
|
||||
/** 字典变更事件 */
|
||||
DICT = "dict",
|
||||
/** 在线用户数事件 */
|
||||
ONLINE_USERS = "online-users",
|
||||
/** 系统消息事件 */
|
||||
SYSTEM = "system",
|
||||
/** 心跳事件 */
|
||||
PING = "ping",
|
||||
/** 通知事件 */
|
||||
NOTICE = "notice",
|
||||
/** 通知撤回事件 */
|
||||
NOTICE_REVOKE = "notice-revoke",
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export const constantRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/detail/:id(\\d+)",
|
||||
name: "DemoDetail",
|
||||
component: () => import("@/views/demo/detail.vue"),
|
||||
component: () => import("@/views/demo/route/detail.vue"),
|
||||
meta: { title: "详情页缓存", icon: "user", hidden: true, keepAlive: true },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -2,6 +2,17 @@ import { store } from "@/stores";
|
||||
import DictAPI from "@/api/system/dict";
|
||||
import type { DictItemOption } from "@/api/system/dict";
|
||||
import { STORAGE_KEYS } from "@/constants";
|
||||
import { useSse } from "@/utils/sse";
|
||||
import { SseTopics } from "@/enums/sse";
|
||||
|
||||
/** 字典变更消息体 */
|
||||
export interface DictChangeMessage {
|
||||
/** 字典编码 */
|
||||
dictCode: string;
|
||||
}
|
||||
|
||||
/** 字典变更回调函数类型 */
|
||||
export type DictChangeCallback = (message: DictChangeMessage) => void;
|
||||
|
||||
export const useDictStore = defineStore("dict", () => {
|
||||
// 字典数据缓存
|
||||
@@ -67,11 +78,70 @@ export const useDictStore = defineStore("dict", () => {
|
||||
dictCache.value = {};
|
||||
};
|
||||
|
||||
// ---------- SSE 字典同步 ----------
|
||||
|
||||
// 字典变更回调列表(供页面在缓存失效时执行自定义逻辑)
|
||||
const dictChangeCallbacks: DictChangeCallback[] = [];
|
||||
let unsubscribeDictChange: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* 处理字典变更消息:清除指定字典缓存,并通知所有已注册回调
|
||||
* @param message SSE 推送的字典变更消息
|
||||
*/
|
||||
const handleDictChange = (message: DictChangeMessage) => {
|
||||
const { dictCode } = message;
|
||||
if (!dictCode) {
|
||||
console.warn("[DictStore] 收到无效的字典变更消息:缺少 dictCode");
|
||||
return;
|
||||
}
|
||||
|
||||
removeDictItem(dictCode);
|
||||
dictChangeCallbacks.forEach((cb) => {
|
||||
try {
|
||||
cb(message);
|
||||
} catch (err) {
|
||||
console.error("[DictStore] 字典变更回调执行失败:", err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 订阅 SSE 字典变更事件(幂等:重复调用不会产生重复订阅)
|
||||
*/
|
||||
const setupDictSync = () => {
|
||||
if (unsubscribeDictChange) return;
|
||||
unsubscribeDictChange = useSse().on(SseTopics.DICT, handleDictChange);
|
||||
};
|
||||
|
||||
/**
|
||||
* 取消 SSE 字典订阅并清空所有回调
|
||||
*/
|
||||
const teardownDictSync = () => {
|
||||
unsubscribeDictChange?.();
|
||||
unsubscribeDictChange = null;
|
||||
dictChangeCallbacks.length = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* 注册字典变更回调,返回取消注册函数
|
||||
* @param cb 字典变更回调
|
||||
*/
|
||||
const onDictChange = (cb: DictChangeCallback) => {
|
||||
dictChangeCallbacks.push(cb);
|
||||
return () => {
|
||||
const idx = dictChangeCallbacks.indexOf(cb);
|
||||
if (idx !== -1) dictChangeCallbacks.splice(idx, 1);
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
loadDictItems,
|
||||
getDictItems,
|
||||
removeDictItem,
|
||||
clearDictCache,
|
||||
setupDictSync,
|
||||
teardownDictSync,
|
||||
onDictChange,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,11 @@ import { AuthStorage } from "@/utils/auth";
|
||||
import { usePermissionStoreHook } from "@/stores/permission";
|
||||
import { useDictStoreHook } from "@/stores/dict";
|
||||
import { useTagsViewStore } from "@/stores";
|
||||
import { cleanupSseServices } from "@/composables";
|
||||
import { cleanupSse } from "@/utils/sse";
|
||||
import router from "@/router";
|
||||
|
||||
// 防止并发会话失效触发重复跳转登录页
|
||||
let redirectingToLogin = false;
|
||||
|
||||
export const useUserStore = defineStore("user", () => {
|
||||
// 用户信息
|
||||
@@ -83,10 +87,11 @@ export const useUserStore = defineStore("user", () => {
|
||||
// 2. 重置其他模块状态
|
||||
usePermissionStoreHook().resetRouter();
|
||||
useDictStoreHook().clearDictCache();
|
||||
useDictStoreHook().teardownDictSync();
|
||||
useTagsViewStore().delAllViews();
|
||||
|
||||
// 3. 清理 SSE 连接
|
||||
cleanupSseServices();
|
||||
cleanupSse();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,6 +104,43 @@ export const useUserStore = defineStore("user", () => {
|
||||
userInfo.value = {} as UserInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话失效的统一出口:通知用户、清理全局状态、携带当前路由跳转登录页
|
||||
*
|
||||
* @param message 通知文案
|
||||
* @param notify 是否弹出通知
|
||||
*/
|
||||
async function redirectToLogin(
|
||||
message: string = "请重新登录",
|
||||
notify: boolean = true
|
||||
): Promise<void> {
|
||||
if (redirectingToLogin) return;
|
||||
redirectingToLogin = true;
|
||||
|
||||
try {
|
||||
if (notify) {
|
||||
ElNotification({
|
||||
title: "提示",
|
||||
message,
|
||||
type: "warning",
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
await resetAllState();
|
||||
|
||||
// 跳转到登录页,保留当前路由用于登录后跳转
|
||||
const currentPath = router.currentRoute.value.fullPath;
|
||||
await router.push(`/login?redirect=${encodeURIComponent(currentPath)}`);
|
||||
} catch (error) {
|
||||
console.error("Redirect to login error:", error);
|
||||
// 强制跳转,即使路由重定向失败
|
||||
window.location.href = "/login";
|
||||
} finally {
|
||||
redirectingToLogin = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 token
|
||||
*/
|
||||
@@ -124,6 +166,7 @@ export const useUserStore = defineStore("user", () => {
|
||||
getUserInfo,
|
||||
resetAllState,
|
||||
resetUserState,
|
||||
redirectToLogin,
|
||||
refreshToken: doRefreshToken,
|
||||
refreshTokenOnce,
|
||||
};
|
||||
|
||||
37
src/views/dashboard/composables/useOnlineUsers.ts
Normal file
37
src/views/dashboard/composables/useOnlineUsers.ts
Normal file
@@ -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]"
|
||||
@@ -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后缀
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
>
|
||||
@@ -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"
|
||||
>
|
||||
Reference in New Issue
Block a user