提示条贴合右侧边栏

发布于:2025-08-10 ⋅ 阅读:(16) ⋅ 点赞:(0)

好的,很高兴能为你提供这个Vue3 + Element Plus项目中自定义加载组件的设计思路和具体代码实现。这个需求非常典型,因为它不仅包含组件的视觉设计,还涉及到了组件间通信、状态管理和 DOM 交互,非常适合用来学习和巩固 Vue 的核心概念。

我将按照一个互联网大厂前端开发的标准和流程,为你深入浅出地讲解。


1. 设计思路与架构(Design Philosophy)

在动手写代码之前,我们先进行高层设计。这是一个好习惯,能让我们的代码结构更清晰、更易于维护。

1.1. 组件拆分(Component Decomposition)

首先,我们会将这个功能拆分为两个核心部分:

  1. 父组件 (Parent Component): 比如叫做 DataDisplayPage.vue。这个组件是“指挥官”,它负责:

    • 包含可拖动的右侧边栏和主内容区域。
    • 管理核心的业务逻辑状态,例如:'idle' (空闲), 'loading' (加载中), 'error' (错误), 'success' (成功)。
    • 发起获取数据的API请求。
    • 根据API请求的结果,更新状态。
    • 控制加载提示组件的显示和隐藏,并传递当前的状态给它。
    • 实现侧边栏的拖动逻辑,并把侧边栏的宽度实时同步。
  2. 子组件 (Child Component): 我们就叫它 LoadingNotification.vue。这个组件是“展示者”,它非常“纯粹”,只负责:

    • 接收父组件传递过来的状态 (props)。
    • 根据不同的状态,显示不同的UI(“正在加载中…”+旋转图标,或“数据异常”+重试按钮)。
    • 当用户点击“重新获取数据”按钮时,通知父组件 (emits)。
    • 它不关心数据是怎么来的,也不关心侧边栏是怎么拖动的,只关心自己的外观和位置。

为什么这么拆分?
这就是关注点分离 (Separation of Concerns) 的设计原则。父组件管逻辑和状态,子组件管UI展示。这样做的好处是:

  • 高内聚,低耦合: LoadingNotification.vue 组件可以被复用到项目的任何地方,因为它不依赖任何特定的页面逻辑。
  • 易于维护: 修改提示条的样式只需要改子组件,修改数据获取逻辑只需要改父组件,互不影响。
  • 清晰的单向数据流: 数据从父组件通过 props 流向子组件,子组件通过 emits 将事件发送给父组件。这是 Vue 推荐的最佳实践。
1.2. 定位与拖动策略 (Positioning Strategy)

这是本需求的难点和亮点。提示框要跟随可拖动侧边栏移动。

  • 我们不能将提示框放在侧边栏内部,因为提示框在侧边栏的左侧
  • 我们也不能简单地使用 fixed 定位,因为它需要相对侧边栏移动。

最佳方案是:

  1. 父组件的主内容区域(Main Content Area)使用 position: relative;
  2. LoadingNotification.vue 组件将被放置在主内容区域中,并使用 position: absolute;
  3. 父组件会有一个 ref 变量,比如 sidebarWidth,用来存储侧边栏的实时宽度。
  4. 我们会通过一个 computed 属性来动态计算 LoadingNotificationstyle。具体来说,它的 right 值将是 sidebarWidth.value + 'px' 再加上一些间距。
  5. 当用户拖动侧边栏时,sidebarWidth 的值会改变,computed 属性会重新计算,从而 Vue 会自动更新 LoadingNotificationstyle,实现跟随效果。

2. 代码实现 (Implementation)

现在,我们开始一步步编写代码。

步骤 1: 创建 LoadingNotification.vue 子组件

这是我们的UI展示组件。

// src/components/LoadingNotification.vue

<script setup lang="ts">
import { computed } from 'vue';

// 1. 定义组件接收的props
// 使用TS来定义类型,更规范
// 'loading': 加载中状态
// 'error': 错误状态
const props = defineProps<{
  state: 'loading' | 'error';
}>();

// 2. 定义组件可以触发的事件
const emit = defineEmits<{
  (e: 'retry'): void; // 定义一个名为 'retry' 的事件
}>();

// 3. 使用计算属性来根据 state 返回不同的文本
const message = computed(() => {
  return props.state === 'loading' ? '正在加载中' : '数据异常';
});

// 4. 定义重试按钮的点击事件处理函数
function handleRetry() {
  // 当按钮被点击时,触发 retry 事件通知父组件
  emit('retry');
}
</script>

<template>
  <div class="loading-notification">
    <div class="message-area">
      {{ message }}
    </div>
    <div class="spinner-area">
      <div v-if="state === 'loading'" class="loading-spinner"></div>
      <button v-if="state === 'error'" @click="handleRetry" class="retry-button">
        重新获取
      </button>
    </div>
  </div>
</template>

<style scoped>
/* 使用 scoped 来确保样式只作用于当前组件,避免全局污染 */
.loading-notification {
  /* 绝对定位,相对于父容器 */
  position: absolute;
  top: 20px; /* 距离顶部20px */
  /* right 属性将由父组件动态提供 */
  z-index: 2000; /* 确保在顶层 */
  display: flex;
  align-items: center;
  padding: 10px 16px;
  background-color: #ffffff;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  transition: opacity 0.3s ease, transform 0.3s ease;
  color: #303133;
}

.message-area {
  font-size: 14px;
  margin-right: 12px;
}

.spinner-area {
  display: flex;
  align-items: center;
  justify-content: center;
}

/* 这是我们手写的旋转空心圆 */
.loading-spinner {
  width: 16px;
  height: 16px;
  border-radius: 50%;
  border: 2px solid #E4E7ED; /* 灰色部分 */
  border-top-color: #409EFF; /* 蓝色部分 */
  animation: spin 1s linear infinite;
}

/* 定义旋转动画 */
@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

.retry-button {
  /* 简单的按钮样式,你可以使用 Element Plus 的 ElButton 替代 */
  font-size: 12px;
  padding: 4px 8px;
  border: 1px solid #409EFF;
  color: #409EFF;
  background-color: transparent;
  border-radius: 4px;
  cursor: pointer;
  outline: none;
}

.retry-button:hover {
  background-color: #ecf5ff;
}
</style>
步骤 2: 创建父组件 DataDisplayPage.vue

这是我们的页面和逻辑控制中心。

// src/views/DataDisplayPage.vue

<script setup lang="ts">
import { ref, computed } from 'vue';
import LoadingNotification from '@/components/LoadingNotification.vue';

// --- State Management ---
// 侧边栏的初始宽度
const sidebarWidth = ref<number>(250);
// 加载状态,'idle' 表示初始/成功状态,此时不显示提示
type LoadingState = 'idle' | 'loading' | 'error';
const loadingState = ref<LoadingState>('idle');

// 控制提示框是否应该显示
const showNotification = computed(() => {
  return loadingState.value === 'loading' || loadingState.value === 'error';
});

// --- API Simulation ---
// 模拟API调用
function mockApiCall(shouldFail: boolean = false): Promise<string> {
  console.log('开始获取数据...');
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (shouldFail) {
        console.error('API请求失败');
        reject(new Error('Backend error'));
      } else {
        console.log('API请求成功');
        resolve('这是从后端获取的数据');
      }
    }, 2000); // 模拟2秒的网络延迟
  });
}

// --- Event Handlers ---
// "获取数据" 按钮的点击事件
async function handleGetData() {
  loadingState.value = 'loading'; // 进入加载中状态
  try {
    const data = await mockApiCall(false); // 模拟成功
    // const data = await mockApiCall(true); // 取消注释此行以模拟失败
    console.log('数据处理成功:', data);
    loadingState.value = 'idle'; // 数据成功返回,恢复idle状态,提示框消失
  } catch (error) {
    loadingState.value = 'error'; // 捕获到异常,进入错误状态
  }
}

// "获取异常数据" 按钮的点击事件,用于演示
async function handleGetErrorData() {
    loadingState.value = 'loading';
    try {
        await mockApiCall(true); // 强制失败
    } catch (error) {
        loadingState.value = 'error';
    }
}


// 处理子组件发出的 'retry' 事件
async function handleRetry() {
  console.log('接收到重试请求,重新获取数据...');
  // 重新尝试获取数据,可以复用 handleGetData 逻辑
  // 这里我们直接调用会失败的接口,方便演示
  await handleGetErrorData();
}

// --- Draggable Sidebar Logic ---
const isDragging = ref(false);

function startDrag(event: MouseEvent) {
  isDragging.value = true;
  // 监听整个窗口的 mousemove 和 mouseup 事件
  window.addEventListener('mousemove', onDrag);
  window.addEventListener('mouseup', stopDrag);
}

function onDrag(event: MouseEvent) {
  if (!isDragging.value) return;
  // 实时计算侧边栏宽度
  // window.innerWidth 是浏览器窗口的总宽度
  // event.clientX 是鼠标当前的X坐标
  // 新的宽度 = 窗口宽度 - 鼠标X坐标
  const newWidth = window.innerWidth - event.clientX;
  // 设置一个最小和最大宽度防止拖动出界
  if (newWidth > 200 && newWidth < 600) {
    sidebarWidth.value = newWidth;
  }
}

function stopDrag() {
  isDragging.value = false;
  // 拖动结束,移除事件监听器,避免不必要的性能开销
  window.removeEventListener('mousemove', onDrag);
  window.removeEventListener('mouseup', stopDrag);
}

// --- Computed Styles ---
// 动态计算 LoadingNotification 的样式
const notificationStyle = computed(() => {
  return {
    // 关键:right 的值依赖于 sidebarWidth
    // 20px 是提示框和侧边栏之间的间距
    right: `${sidebarWidth.value + 20}px`,
  };
});
</script>

<template>
  <div class="page-layout">
    <main class="main-content">
      <div class="controls">
        <el-button type="primary" @click="handleGetData">点击获取数据 (模拟成功)</el-button>
        <el-button type="danger" @click="handleGetErrorData">点击获取数据 (模拟失败)</el-button>
      </div>
      
      <h1>主内容区域</h1>
      <p>这里的宽度会随着右侧边栏的拖动而变化。</p>

      <LoadingNotification
        v-if="showNotification"
        :style="notificationStyle"
        :state="loadingState"
        @retry="handleRetry"
      />
    </main>

    <aside class="sidebar" :style="{ width: `${sidebarWidth}px` }">
      <div class="drag-handle" @mousedown="startDrag"></div>
      <div class="sidebar-content">
        <h2>可拖动侧边栏</h2>
        <p>从左侧边缘拖动来改变宽度。</p>
      </div>
    </aside>
  </div>
</template>

<style scoped>
.page-layout {
  display: flex;
  height: 100vh; /* 占满整个视口高度 */
  width: 100%;
  overflow: hidden; /* 防止出现滚动条 */
}

/* 主内容区域需要相对定位,作为 LoadingNotification 的定位父级 */
.main-content {
  flex-grow: 1; /* 占据剩余所有空间 */
  position: relative; /* 关键! */
  padding: 20px;
  background-color: #f0f2f5;
}

.controls {
  margin-bottom: 20px;
}

.sidebar {
  flex-shrink: 0; /* 防止侧边栏被压缩 */
  position: relative; /* 为拖动条定位 */
  background-color: #ffffff;
  border-left: 1px solid #dcdfe6;
}

.sidebar-content {
  padding: 20px;
}

.drag-handle {
  position: absolute;
  left: -5px; /* 稍微突出一点,方便点击 */
  top: 0;
  height: 100%;
  width: 10px;
  cursor: col-resize; /* 鼠标样式变为列拖动 */
  z-index: 10;
}
</style>

3. 如何使用与讲解 (How to Use & Explanation)

  1. 文件结构:

    • LoadingNotification.vue 文件放在 src/components/ 目录下。
    • DataDisplayPage.vue 文件放在 src/views/ 目录下。
    • 确保你的项目已经安装了 vue, element-plus
  2. 核心概念讲解 (适合初学者):

    • <script setup>: 这是 Vue 3 中推荐的组合式 API (Composition API) 语法糖。它让代码更简洁,所有在 <script setup> 中定义的变量和函数都可以直接在 <template> 中使用。
    • ref: 用来创建一个响应式变量。当 ref 变量的值改变时,Vue 会自动更新界面上用到这个变量的地方。我们用它来存储 sidebarWidthloadingState
    • computed: 计算属性。它像一个智能的变量,会根据其他响应式变量(如 ref)的变化而自动重新计算自己的值。我们用它来动态生成 notificationStyle,这是实现跟随效果的灵魂。
    • propsemits: 这是父子组件通信的“标准协议”。props 让父组件可以“发号施令”给子组件(比如告诉它当前是什么状态),emits 让子组件可以“汇报情况”给父组件(比如告诉它“用户点击了重试按钮”)。
    • CSS position: 我们利用 position: relativeposition: absolute 建立了一个定位上下文。子组件的 absolute 定位是相对于第一个 position 不是 static 的祖先元素。在这里,就是我们的 .main-content
    • 事件监听 (@mousedown, @mousemove, @mouseup): 这是实现拖动功能的标准 DOM 操作。我们在父组件中处理这些事件,因为是父组件控制着侧边栏的宽度,体现了“谁的数据谁负责”的原则。

4. 大厂规范与其它方案

  • 代码规范: 以上代码遵循了 TypeScript 类型定义、ESLint 推荐规则(如 const 优先)、单向数据流等,符合大厂的开发规范。注释清晰,变量命名有意义。
  • CSS 变量: 在大型项目中,颜色、间距等值可以使用 CSS 变量 (var(--el-color-primary)) 来定义,方便进行全局主题切换。
  • 替代方案: Element Plus 自带 Loading: 对于简单场景,可以直接使用 Element Plus 提供的 ElLoading 服务或指令。
    • 服务调用: const loading = ElLoading.service({ text: 'Loading...' }),然后在数据返回后调用 loading.close()
    • 指令: <div v-loading="isLoading"></div>
    • 为什么我们不直接用? 因为你的需求非常具体(特定的样式、错误状态和重试按钮、跟随侧边栏移动的定位),手写一个自定义组件能提供最大的灵活性和控制力,这也是一个很好的学习过程。

希望这份详尽的指南能帮助你成功实现这个功能,并对 Vue 的核心思想有更深入的理解!


网站公告

今日签到

点亮在社区的每一天
去签到