自定义通知组件跟随右侧边栏移动
设计思路(简短、初学者可读)
- 用一个可拖拽的右侧侧栏(DraggableSidebar),把“通知挂载点(anchor)”放到侧栏 DOM 内。通知组件通过 Vue 的
<teleport>
挂载到这个 anchor 上,这样通知就是侧栏的子节点,侧栏被拖动时通知会跟随移动。 - 通知(LoadingNotification)是一个自定义小组件(不是 ElementPlus 全局 Notification),因为 ElementPlus 的全局通知通常挂在
body
,不会跟随侧栏移动。自定义组件可以精确控制位置、样式和交互。 - 转圈采用 SVG + CSS 动画:绘制一个灰色环(静止)和一个蓝色短弧(旋转),实现“空心圆圈,有一小块蓝色在转动”的效果。
- 数据请求逻辑放在父组件(App),控制通知的显隐、状态(loading / error),以及错误时显示“重新获取数据”按钮。使用
AbortController
防止旧请求和新请求冲突。 - 代码风格:使用 Vue 3
script setup
、明确变量命名、注释、可读性高、易维护。适合大厂规范(可进一步用 ESLint/Prettier 强制样式)。
代码(三个文件,完整可直接放到 Vue 项目里运行)
说明:示例使用 JavaScript + Vue 3 + Element Plus。假设你已经在
main.js
中全局引入并注册了 ElementPlus(app.use(ElementPlus)
)。
components/LoadingNotification.vue
<template><divv-if="visible"class="loading-notif"role="status"aria-live="polite"@keydown.esc="$emit('close')"><div class="loading-notif__body"><!-- SVG 转圈(灰环 + 蓝色短弧旋转) --><svg class="spinner" viewBox="0 0 40 40" width="28" height="28" aria-hidden="true"><!-- 灰色环(静止)--><circle class="spinner__ring" cx="20" cy="20" r="16" fill="none" stroke-width="4" /><!-- 蓝色短弧(旋转)--><circleclass="spinner__arc"cx="20"cy="20"r="16"fill="none"stroke-width="4"stroke-linecap="round"stroke-dasharray="40 100"/></svg><div class="loading-notif__text"><div v-if="status === 'loading'">{{ message || '正在加载中' }}</div><div v-else-if="status === 'error'">数据异常</div></div><!-- 出错时显示重试 --><el-buttonv-if="status === 'error'"size="mini"type="primary"class="loading-notif__retry"@click="$emit('retry')">重新获取数据</el-button></div></div>
</template><script setup>
/*** props:* - visible: 是否显示* - status: 'loading' | 'error'* - message: 可选自定义消息(loading 时显示)** emits:* - retry:用户点击重试* - close:用户按 ESC 或手动关闭(可扩展)*/
import { defineProps, defineEmits } from 'vue';const props = defineProps({visible: { type: Boolean, default: false },status: { type: String, default: 'loading' },message: { type: String, default: '' }
});
const emit = defineEmits(['retry', 'close']);
</script><style scoped>
/* 通知整体,定位会相对于侧栏根节点(侧栏设置 position: relative/absolute) */
.loading-notif {position: absolute;/* 放在侧栏左侧、顶部附近 */right: calc(100% + 12px);top: 12px;z-index: 1200;/* 外观 */background: #ffffff;border-radius: 8px;box-shadow: 0 6px 18px rgba(20, 30, 50, 0.12);padding: 8px 12px;min-width: 200px;user-select: none;
}/* 布局 */
.loading-notif__body {display: flex;align-items: center;gap: 10px;
}/* 文本区域 */
.loading-notif__text {font-size: 14px;color: #333;flex: 1;line-height: 1;
}/* 重试按钮样式微调 */
.loading-notif__retry {margin-left: 8px;
}/* ---------- SVG spinner 样式 ---------- */
.spinner__ring {stroke: #e6e6e6; /* 灰色环 */
}.spinner__arc {stroke: #2f86f6; /* 蓝色弧 *//* 让弧段绕中心旋转 */transform-origin: 20px 20px;transform-box: fill-box;animation: spinner-rotate 1s linear infinite;
}/* 旋转动画 */
@keyframes spinner-rotate {to {transform: rotate(360deg);}
}
</style>
components/DraggableSidebar.vue
<template><divref="root"class="draggable-sidebar":style="sidebarStyle"@pointerdown.stop="onPointerDown"@pointermove="onPointerMove"@pointerup="onPointerUp"@pointercancel="onPointerUp"><!-- header(拖动手柄)--><div class="sidebar__header sidebar-handle" aria-label="拖动侧边栏"><span class="sidebar__title">右侧边栏(可拖动)</span></div><!-- 供 teleport 挂载通知的锚点 --><div id="sidebar-anchor" class="sidebar-anchor" /><!-- 侧栏内容 --><div class="sidebar__content"><slot /></div></div>
</template><script setup>
import { ref, onMounted, computed } from 'vue';
const emit = defineEmits(['ready']);const width = 320; // 侧栏宽度,可自定义
const pos = ref({ x: window.innerWidth - width - 24, y: 80 });
const dragging = ref(false);
const start = ref({ pointerX: 0, pointerY: 0, startX: 0, startY: 0 });
const root = ref(null);const sidebarStyle = computed(() => ({position: 'absolute',width: `${width}px`,transform: `translate(${pos.value.x}px, ${pos.value.y}px)`,zIndex: 1000
}));function onPointerDown(e) {// 只有在 header(class: sidebar-handle)上按下才开始拖动,避免误操作if (!e.target.closest('.sidebar-handle')) return;dragging.value = true;start.value.pointerX = e.clientX;start.value.pointerY = e.clientY;start.value.startX = pos.value.x;start.value.startY = pos.value.y;root.value.setPointerCapture?.(e.pointerId);
}function onPointerMove(e) {if (!dragging.value) return;const dx = e.clientX - start.value.pointerX;const dy = e.clientY - start.value.pointerY;pos.value.x = start.value.startX + dx;pos.value.y = start.value.startY + dy;// 保证不拖出可见区域(简单边界约束)const minX = -width + 60;const maxX = window.innerWidth - 60;const minY = 0;const maxY = window.innerHeight - 60;pos.value.x = Math.min(Math.max(pos.value.x, minX), maxX);pos.value.y = Math.min(Math.max(pos.value.y, minY), maxY);
}function onPointerUp(e) {if (!dragging.value) return;dragging.value = false;root.value.releasePointerCapture?.(e.pointerId);
}onMounted(() => {// 通知父组件:侧栏已挂载(teleport 目标准备好)emit('ready');
});
</script><style scoped>
.draggable-sidebar {background: #ffffff;border-radius: 8px;box-shadow: 0 8px 26px rgba(30, 40, 60, 0.12);overflow: hidden;/* 给定位的上下文(通知的 absolute 会基于这个元素)*/position: absolute;
}/* header(拖动手柄)*/
.sidebar__header {height: 44px;display: flex;align-items: center;padding: 0 12px;border-bottom: 1px solid #f2f5f8;cursor: grab;background: linear-gradient(90deg, #fafafa, #fff);
}
.sidebar__title {font-weight: 600;font-size: 14px;
}/* 通知锚点位置(teleport 会把通知插入到这个节点下) */
.sidebar-anchor {position: relative;width: 0;height: 0;
}/* 内容区 */
.sidebar__content {padding: 12px;min-height: 220px;
}
</style>
App.vue
(父组件:触发请求、管理通知显示)
<template><div class="app"><el-button type="primary" :loading="isFetching" @click="onGetData">获取数据</el-button><!-- 可拖动侧边栏 --><DraggableSidebar @ready="onSidebarReady"><template #default><div><p>示例侧栏内容(可放任何东西)</p><p>拖动上面的区域以移动此侧栏</p></div></template></DraggableSidebar><!-- 当侧栏挂载完后,把通知 teleport 到侧栏内的 #sidebar-anchor --><teleport v-if="sidebarReady" to="#sidebar-anchor"><LoadingNotification:visible="visible":status="status"message="正在加载中"@retry="onGetData"@close="visible = false"/></teleport></div>
</template><script setup>
import { ref } from 'vue';
import DraggableSidebar from './components/DraggableSidebar.vue';
import LoadingNotification from './components/LoadingNotification.vue';/*** 状态管理(简单示例)* - sidebarReady: 侧栏已经挂载(teleport 目标存在)* - visible: 通知是否显示* - status: 'loading' | 'error'* - isFetching: 防止重复请求(按钮 loading)** fetchData 使用 fetch + AbortController(示范用;项目中可替换为 axios)*/const sidebarReady = ref(false);
const visible = ref(false);
const status = ref('loading'); // 'loading' | 'error'
const isFetching = ref(false);
let abortController = null;/* DraggableSidebar 发 ready 事件 */
function onSidebarReady() {sidebarReady.value = true;
}/* 触发获取数据 */
async function onGetData() {if (isFetching.value) return;visible.value = true;status.value = 'loading';isFetching.value = true;// 取消上一次请求(如果存在)if (abortController) {abortController.abort();}abortController = new AbortController();const signal = abortController.signal;try {// ---------- 示例调用,替换为你的真实后端接口 ----------// 这里为了演示会随机成功/失败const simulatedFetch = () =>new Promise((resolve, reject) => {const delay = 1000 + Math.random() * 1000;setTimeout(() => {const ok = Math.random() > 0.4; // 60% 成功率if (ok) resolve({ success: true, payload: { foo: 1 } });else reject(new Error('simulated server error'));}, delay);});const result = await simulatedFetch();// 检查后端返回结构(你项目中请根据真实后端判断)if (result && result.success) {// 成功:隐藏通知并处理数据visible.value = false;status.value = 'loading';// TODO: 处理 result.payloadconsole.log('后端数据:', result.payload);} else {// 认为是业务异常status.value = 'error';}} catch (err) {if (err.name === 'AbortError') {// 请求被取消,保持当前 UI 或者隐藏console.warn('请求被取消');} else {console.error('请求失败:', err);status.value = 'error';}} finally {isFetching.value = false;// 注意不要立即把 abortController 置空,除非确认请求全部结束abortController = null;}
}
</script><style scoped>
.app {padding: 18px;
}
</style>
讲解(一步一步,初学者友好)
-
为什么要 teleport 到侧栏?
浏览器里常见的全局通知(比如 ElementPlus 的全局 Notification)通常被插入到document.body
,它和侧栏不是父子关系,侧栏被拖动时通知不会“跟着走”。把通知挂到侧栏内部(子节点)就能自动随侧栏移动。Vue 的<teleport>
能把内容渲染到 DOM 的任意节点,非常适合这种场景。 -
为什么使用 SVG + CSS 动画?
SVG 画圆弧比纯 CSS 更精确,stroke-dasharray
可以很容易做出“只有一小段是有颜色”的弧形,再用transform
旋转该弧段,视觉上就是“蓝色在转动,其它部分灰色”的效果。 -
为什么用 AbortController?
如果用户频繁点击“获取数据”,前一个请求仍在进行,新的请求会产生竞态问题(race condition)。AbortController 能取消上一次请求,避免不必要的处理和内存浪费。 -
错误处理与重试:
当后端返回异常时,我们把通知文本换成“数据异常”,并显示重新获取数据
。点击该按钮会再次触发 fetch(父组件onGetData
),逻辑与初始请求相同。 -
可拓展建议(生产级):
- 把请求逻辑抽成 service(例如
api.fetchData()
),并集中处理错误/日志/埋点;使用axios
并结合统一拦截器会更好。 - 用 Pinia 管理全局状态(如果多处需要显示/控制此通知)。
- 增加动画(渐入/渐出)和可配置的超时时间与重试次数限制。
- 为 accessibility 加强(更完整的 aria 标签、键盘交互)。
- 把请求逻辑抽成 service(例如