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:
Ray.Hao
2026-09-01 07:22:19 +08:00
parent d36ccf54d5
commit d136266acd
47 changed files with 207 additions and 430 deletions

View File

@@ -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";
/** 下拉面板每页展示条数 */

View File

@@ -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";

View File

@@ -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";

View File

@@ -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];

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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
View 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",
}

View File

@@ -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 },
},
],

View File

@@ -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,
};
});

View File

@@ -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,
};

View 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,
};
}

View File

@@ -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();

View File

@@ -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) {
// 更新最后更新时间

View File

@@ -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]"

View File

@@ -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后缀

View File

@@ -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"

View File

@@ -18,6 +18,8 @@
</template>
<script setup lang="ts">
// 必须显式导入:页面文件名 text-scroll.vue 与标签 <TextScroll> 同名,会触发 Vue 隐式自引用
// (自动导入对同名组件不生效,运行时解析到页面自身 → 递归栈溢出白屏)
import TextScroll from "@/components/TextScroll/index.vue";
</script>

View File

@@ -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

View File

@@ -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("初始化内容");

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);

View File

@@ -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"
>

View File

@@ -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"
>