左右两侧定位的效果,vue3

javascript 复制代码
<template>
  <div class="app-container">
    <!-- 顶部工具栏 -->
    <div class="toolbar">
      <el-button type="warning" :icon="Refresh">数据更新</el-button>
      <el-button :icon="Download">全部下载</el-button>
    </div>

    <!-- 主体内容 -->
    <div class="main-layout">
      <!-- 左侧目录 -->
      <aside ref="sidebarRef" class="sidebar">
        <h2 class="sidebar-title">危险废物规范化台账</h2>
        <div v-for="(item, index) in catalogData" :key="index" :ref="el => setMenuItemRef(el, index)"
          :class="['menu-item', { active: activeIndex === index }]" @click="handleMenuClick(index)">
          <div class="menu-header">
            <span>{{ item.title }}</span>
            <el-icon>
              <ArrowRight v-if="activeIndex !== index" />
              <ArrowDown v-else />
            </el-icon>
          </div>
          <div v-if="item.children && item.children.length" class="menu-content">
            <ul>
              <li v-for="(child, cIndex) in item.children" :key="cIndex">
                {{ child }}
              </li>
            </ul>
          </div>
        </div>
      </aside>

      <!-- 右侧内容 -->
      <main ref="contentRef" class="content-area" @scroll="handleScroll">
        <div v-for="(item, index) in catalogData" :id="'section-' + index" :key="index" class="section">
          <div class="section-title">{{ item.title }}</div>

          <el-table :data="item.tableData" border class="custom-table">
            <el-table-column type="index" label="序号" width="80" align="center" />
            <el-table-column prop="name" label="附件名称" min-width="200">
              <template #default="{ row }">
                <el-link type="primary" :underline="false" class="file-link">
                  <el-icon class="file-icon">
                    <Document />
                  </el-icon>
                  {{ row.name }}
                </el-link>
              </template>
            </el-table-column>
            <el-table-column prop="size" label="文件大小" width="120" align="center" />
            <el-table-column prop="source" label="来源" min-width="300" show-overflow-tooltip />
            <el-table-column label="操作" width="150" align="center" fixed="right">
              <template #default>
                <el-button link type="primary" size="small">预览</el-button>
                <el-button link type="primary" size="small">下载</el-button>
              </template>
            </el-table-column>
          </el-table>

          <div class="pagination-wrapper">
            <el-pagination v-model:current-page="item.currentPage" v-model:page-size="item.pageSize"
              :page-sizes="[10, 20, 50, 100]" :total="item.total" layout="total, sizes, prev, pager, next, jumper"
              background />
          </div>
        </div>
      </main>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { Refresh, Download, ArrowRight, ArrowDown, Document } from '@element-plus/icons-vue'
import {

} from '@/api/ledger/hazard'
const contentRef = ref(null)
const sidebarRef = ref(null)
const activeIndex = ref(0)
const menuItemRefs = ref([])
const isManualClick = ref(false)
let clickTimer = null

// 设置菜单项引用
const setMenuItemRef = (el, index) => {
  if (el) {
    menuItemRefs.value[index] = el
  }
}

// 模拟数据
const catalogData = ref([
  {
    title: '一、污染',
    children: [
      '1.一、污染',
      '2.一、污染',
      '3.一、污染',
      '4.一、污染',
      '5.一、污染',
      '6.一、污染'
    ],
    tableData: [
      { name: '文件名称.pdf', size: '0.25M', source: '一、污染-内部管理制度-附件' },
      { name: '文件名称2.pdf', size: '0.26M', source: '一、污染-内部管理制度-附件' },
      { name: '文件名称.pdf', size: '0.25M', source: '一、污染-内部管理制度-附件' },
      { name: '文件名称2.pdf', size: '0.26M', source: '一、污染-内部管理制度-附件' },
      { name: '文件名称2.pdf', size: '0.26M', source: '一、污染-危险废物贮存设施-危险废物信息公示照片' },
      { name: '文件名称2.pdf', size: '0.26M', source: '一、污染-危险废物贮存设施-危险废物信息公示照片' }
    ],
    currentPage: 1,
    pageSize: 10,
    total: 85
  },
  {
    title: '二、标识',
    children: [
      '1.二、标识',
      '2.二、标识'
    ],
    tableData: [
      { name: '文件名称2.pdf', size: '0.26M', source: '二、标识-危险废物贮存设施-贮存设施照片' },
      { name: '文件名称2.pdf', size: '0.26M', source: '二、标识-危险废物贮存设施-警示标识照片' }
    ],
    currentPage: 1,
    pageSize: 10,
    total: 85
  },
  {
    title: '三、管理',
    children: [
      '1.三、管理',
      '2.三、管理'
    ],
    tableData: [
      { name: '2024年度管理计划.pdf', size: '1.2M', source: '三、管理-管理计划-附件' },
      { name: '备案证明.pdf', size: '0.5M', source: '三、管理-管理计划-备案证明' }
    ],
    currentPage: 1,
    pageSize: 10,
    total: 42
  },
  {
    title: '四、申报',
    children: ['四、申报'],
    tableData: [
      { name: '2024年申报登记表.pdf', size: '0.8M', source: '四、申报-申报登记-附件' }
    ],
    currentPage: 1,
    pageSize: 10,
    total: 15
  },
  {
    title: '五、源头',
    children: ['五、源头'],
    tableData: [
      { name: '分类存放照片.pdf', size: '2.1M', source: '五、源头-源头分类-现场照片' },
      { name: '间隔标识照片.pdf', size: '1.5M', source: '五、源头-源头分类-现场照片' }
    ],
    currentPage: 1,
    pageSize: 10,
    total: 28
  },
  {
    title: '六、转移',
    children: [
      '1.六、转移',
      '2.六、转移',
    ],
    tableData: [
      { name: '转移联单-2024-001.pdf', size: '0.3M', source: '六、转移-转移联单-附件' },
      { name: '转移联单-2024-002.pdf', size: '0.3M', source: '六、转移-转移联单-附件' },
    ],
    currentPage: 1,
    pageSize: 10,
    total: 56
  },
])

// 获取所有章节元素
const getSections = () => {
  return document.querySelectorAll('.section')
}

// 计算当前应该激活的索引
const calculateActiveIndex = () => {
  if (!contentRef.value) return 0

  const scrollTop = contentRef.value.scrollTop
  const scrollHeight = contentRef.value.scrollHeight
  const clientHeight = contentRef.value.clientHeight
  const sections = getSections()

  if (sections.length === 0) return 0

  // 判断是否滚动到底部(允许10px误差)
  const isAtBottom = scrollTop + clientHeight >= scrollHeight - 10

  // 如果滚动到底部,直接返回最后一个索引
  if (isAtBottom) {
    return sections.length - 1
  }

  // 找到当前滚动位置所在的章节
  // 从后往前遍历,找到第一个顶部在视口上方的章节
  for (let i = sections.length - 1; i >= 0; i--) {
    const section = sections[i]
    const offsetTop = section.offsetTop

    // 当滚动位置超过章节顶部减去一个偏移量(提前触发)
    if (scrollTop >= offsetTop - 80) {
      return i
    }
  }

  return 0
}

// 滚动左侧菜单到可视区域
const scrollMenuToView = (index) => {
  if (!sidebarRef.value || !menuItemRefs.value[index]) return

  const sidebar = sidebarRef.value
  const menuItem = menuItemRefs.value[index]

  const sidebarRect = sidebar.getBoundingClientRect()
  const itemRect = menuItem.getBoundingClientRect()

  // 判断元素是否在可视区域内
  const isAbove = itemRect.top < sidebarRect.top + 20
  const isBelow = itemRect.bottom > sidebarRect.bottom - 20
  if (isAbove) {
    menuItem.scrollIntoView({ behavior: 'smooth', block: 'start' })
  } else if (isBelow) {
    menuItem.scrollIntoView({ behavior: 'smooth', block: 'start' })
  }
}

// 处理菜单点击
const handleMenuClick = (index) => {
  isManualClick.value = true

  // 立即更新激活状态
  activeIndex.value = index

  // 滚动右侧到对应位置
  const element = document.getElementById(`section-${index}`)
  if (element && contentRef.value) {
    // 如果是最后一个,滚动到底部
    if (index === catalogData.value.length - 1) {
      contentRef.value.scrollTo({
        top: contentRef.value.scrollHeight,
        behavior: 'smooth'
      })
    } else {
      contentRef.value.scrollTo({
        top: element.offsetTop - 80,
        behavior: 'smooth'
      })
    }
  }

  // 滚动左侧菜单到可视区域
  scrollMenuToView(index)

  // 清除之前的定时器
  if (clickTimer) {
    clearTimeout(clickTimer)
  }

  // 延迟恢复滚动监听,避免冲突
  clickTimer = setTimeout(() => {
    isManualClick.value = false
  }, 800)
}

// 处理右侧滚动
const handleScroll = () => {
  // 如果是手动点击触发的滚动,不处理
  if (isManualClick.value) return

  const newIndex = calculateActiveIndex()
  console.log('滚动位置:', newIndex)
  if (activeIndex.value !== newIndex) {
    activeIndex.value = newIndex
    scrollMenuToView(newIndex)
  }
}

onMounted(() => {
  // 初始化时计算一次当前位置
  nextTick(() => {
    const index = calculateActiveIndex()
    if (index !== activeIndex.value) {
      activeIndex.value = index
    }
  })
})

onUnmounted(() => {
  if (clickTimer) {
    clearTimeout(clickTimer)
  }
})
</script>

<style scoped>
.app-container {
  height: calc(100vh - 84px);
  display: flex;
  flex-direction: column;
  background-color: #f5f7fa;
  padding: 20px;
}

/* 顶部工具栏 */
.toolbar {
  background: #fff;
  padding: 16px 24px;
  border-bottom: 1px solid #e4e7ed;
  display: flex;
  gap: 12px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}

/* 主体布局 */
.main-layout {
  flex: 1;
  display: flex;
  overflow: hidden;
}

/* 左侧目录 */
.sidebar {
  width: 360px;
  background: #fff;
  border-right: 1px solid #e4e7ed;
  overflow-y: auto;
  padding: 16px;
  scroll-behavior: smooth;
  margin-bottom: 0;
}

.sidebar-title {
  margin-bottom: 16px;
  color: #303133;
  font-size: 20px;
  font-weight: 600;
}

.menu-item {
  margin-bottom: 12px;
  border: 1px solid #e4e7ed;
  border-radius: 8px;
  overflow: hidden;
  transition: all 0.3s;
  cursor: pointer;
}

.menu-item:hover {
  border-color: #409eff;
  box-shadow: 0 2px 12px rgba(64, 158, 255, 0.1);
}

.menu-item.active {
  border-color: #409eff;
  background: #ecf5ff;
}

.menu-header {
  padding: 16px;
  background: #f5f7fa;
  font-weight: 600;
  color: #303133;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}

.menu-item.active .menu-header {
  background: #409eff;
  color: #fff;
}

.menu-content {
  padding: 12px 16px;
  background: #fff;
}

.menu-content ul {
  list-style: none;
  margin: 0;
  padding: 0;
}

.menu-content li {
  padding: 8px 0;
  padding-left: 20px;
  color: #606266;
  font-size: 14px;
  line-height: 1.6;
  position: relative;
}

.menu-content li::before {
  content: '';
  position: absolute;
  left: 6px;
  top: 16px;
  width: 6px;
  height: 6px;
  background: #c0c4cc;
  border-radius: 50%;
}

.menu-item.active .menu-content li {
  color: #303133;
}

.menu-item.active .menu-content li::before {
  background: #409eff;
}

/* 右侧内容区 */
.content-area {
  flex: 1;
  overflow-y: auto;
  padding: 16px 16px 0 16px;
  scroll-behavior: smooth;
}

.section {
  margin-bottom: 16px;
  background: #fff;
  border-radius: 8px;
  padding: 16px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
}

.section:last-child {
  margin-bottom: 0px;
}

.section-title {
  font-size: 18px;
  font-weight: 600;
  color: #303133;
  margin-bottom: 16px;
  padding-left: 12px;
  border-left: 4px solid #409eff;
}

/* 表格样式优化 */
.custom-table {
  margin-top: 16px;
}

.file-link {
  display: inline-flex;
  align-items: center;
}

.file-icon {
  margin-right: 4px;
}

/* 分页居中 */
.pagination-wrapper {
  margin-top: 20px;
  display: flex;
  justify-content: center;
}

/* 滚动条美化 */
.sidebar::-webkit-scrollbar,
.content-area::-webkit-scrollbar {
  width: 8px;
  height: 8px;
}

.sidebar::-webkit-scrollbar-track,
.content-area::-webkit-scrollbar-track {
  background: #f1f1f1;
}

.sidebar::-webkit-scrollbar-thumb,
.content-area::-webkit-scrollbar-thumb {
  background: #c1c1c1;
  border-radius: 4px;
}

.sidebar::-webkit-scrollbar-thumb:hover,
.content-area::-webkit-scrollbar-thumb:hover {
  background: #a8a8a8;
}
</style>
相关推荐
Rsun045513 小时前
Vue相关面试题
前端·javascript·vue.js
好名字08213 小时前
Vue2转Word方法(html-docx-js库)
javascript·html·word
冴羽4 小时前
资深前端都在用的 9 个调试偏方
前端·javascript
兆子龙4 小时前
xx.d.ts 文件有什么用,为什么不引入都能生效?
javascript
吴声子夜歌4 小时前
小程序——界面API(一)
java·javascript·小程序
左夕4 小时前
深入理解Vue中的插槽:概念、原理与应用
前端·vue.js
兆子龙4 小时前
万字解析 OpenClaw 源码架构:从入门到精通
前端·javascript
@大迁世界4 小时前
精通 React 面试:从零到中高级
前端·javascript·react.js·面试·前端框架
lichenyang4534 小时前
虚拟 DOM、Diff 算法与 Fiber
前端·javascript·面试