Vue3+Element Plus如何实现左树右表页面案例:即根据左边的树筛选右侧表功能实现

发布于:2025-04-15 ⋅ 阅读:(30) ⋅ 点赞:(0)


前端用的vue3+Element Plus


可以自行创建脚手架vue3 命令使用手册及常用概念

也可以用已经创建的,Vue3-Project-Basic在github上;(注意安装node,版本建议16以上)
下载后直接

npm i

npm run dev

即可,组件路由都配了,快速验证,如果用到,帮忙点个Follow(可能要代理)
下载后把Emp.vue原本的代码删除即可。


一、最终效果

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
当然实际中是从后端拿数据,本案例为模拟数据。

二、源代码

2.1 AddDataSource.vue

<template>
  <el-dialog
    v-model="localVisible"
    title="新增数据源"
    width="500px"
    :close-on-click-modal="false"
    class="add-dialog"
  >
    <el-form :model="form" ref="formRef" label-width="100px" class="add-form">
      <el-form-item label="数据源名称" required>
        <el-input 
          v-model="form.name" 
          placeholder="请输入数据源名称"
          maxlength="50"
          show-word-limit
        />
      </el-form-item>
    </el-form>
    <template #footer>
      <span class="dialog-footer">
        <el-button @click="localVisible = false">取消</el-button>
        <el-button type="primary" @click="handleSubmit">确认</el-button>
      </span>
    </template>
  </el-dialog>
</template>

<script setup>
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'

const props = defineProps({
  modelValue: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue', 'success'])

const localVisible = ref(props.modelValue)
const form = reactive({ name: '' })
const formRef = ref()

function handleSubmit() {
  if (form.name.trim() === '') {
    ElMessage.error('请输入数据源名称')
    return
  }
  ElMessage.success('新增数据源成功')
  // 模拟返回新增数据源对象,使用当前时间戳作为 id
  emit('success', { id: Date.now(), db_name: form.name, label: form.name })
  localVisible.value = false
  form.name = '' // 清空表单
}

watch(() => props.modelValue, (newVal) => {
  localVisible.value = newVal
})

watch(localVisible, (newVal) => {
  emit('update:modelValue', newVal)
  if (!newVal) {
    form.name = '' // 关闭弹窗时清空表单
  }
})
</script>

<style scoped>
.add-dialog {
  :deep(.el-dialog__header) {
    margin: 0;
    padding: 20px;
    border-bottom: 1px solid #e6e6e6;
  }
  :deep(.el-dialog__body) {
    padding: 30px 20px;
  }
  :deep(.el-dialog__footer) {
    padding: 20px;
    border-top: 1px solid #e6e6e6;
  }
}
.add-form {
  :deep(.el-form-item__label) {
    font-weight: 500;
  }
}
.dialog-footer {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
}
</style>

2.2 LeftTree.vue

<template>
  <div class="left-tree">
    <div class="tree-header">
      <el-button type="primary" @click="handleAdd" class="add-btn">
        <el-icon><Plus /></el-icon>新增数据源
      </el-button>
    </div>
    <el-tree
      :data="treeData"
      :props="defaultProps"
      @node-click="handleNodeClick"
      node-key="id"
      class="custom-tree"
    >
      <template #default="{ node }">
        <span class="custom-tree-node">
          <el-icon><Connection /></el-icon>
          <span>{{ node.label }}</span>
        </span>
      </template>
    </el-tree>
    <AddDataSource v-model="addDialogVisible" @success="handleAddSuccess" />
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { Plus, Connection } from '@element-plus/icons-vue'
import AddDataSource from './AddDataSource.vue'

// 此处使用硬编码的数据模拟分组情况
const treeData = ref([
  { id: 1, db_name: '数据源A', label: '数据源A' },
  { id: 2, db_name: '数据源B', label: '数据源B' }
])
const defaultProps = {
  children: 'children',
  label: 'db_name'
}
const addDialogVisible = ref(false)
const emit = defineEmits(['node-click'])

const handleNodeClick = (data) => {
  // 若节点存在 children,则为组节点,不触发点击事件
  if (data.children) return
  emit('node-click', data)
}

const handleAdd = () => {
  addDialogVisible.value = true
}

const handleAddSuccess = (newDataSource) => {
  // 将新增的数据源添加到树中
  treeData.value.push(newDataSource)
}

onMounted(() => {
  // 这里不从后端加载,而是使用硬编码数据
})
</script>

<style scoped>
.left-tree {
  height: 100%;
  display: flex;
  flex-direction: column;
  padding: 16px;
}
.tree-header {
  margin-bottom: 16px;
}
.add-btn {
  width: 100%;
}
.custom-tree {
  flex: 1;
  overflow: auto;
}
.custom-tree-node {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 14px;
}
:deep(.el-tree-node__content) {
  height: 36px;
}
:deep(.el-tree-node:focus > .el-tree-node__content) {
  background-color: #f0f7ff;
}
:deep(.el-tree-node__content:hover) {
  background-color: #f5f7fa;
}
</style>

2.3 FieldDrawer.vue

<template>
  <el-drawer
    v-model="localVisible"
    title="字段信息"
    direction="rtl"
    size="30%"
    :before-close="handleClose"
  >
    <el-table :data="fieldList" border style="width: 100%;">
      <el-table-column prop="fieldEnglishName" label="字段英文名称" />
      <el-table-column prop="fieldType" label="字段类型" />
      <el-table-column prop="fieldRemark" label="字段备注" />
    </el-table>
  </el-drawer>
</template>

<script setup>
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'

// props: tableId 用于选择不同的模拟数据;
// 使用 modelValue 来实现 v-model 绑定
const props = defineProps({
  tableId: { type: [Number, String], required: true },
  modelValue: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue'])

const localVisible = ref(props.modelValue)
const fieldList = ref([])

// 模拟数据,根据不同的 tableId 返回不同字段数据
const simulateFieldData = (tableId) => {
  if (tableId == 101) {
    return [
      { id: 1, fieldEnglishName: 'empId', fieldType: 'INT', fieldRemark: '员工ID' },
      { id: 2, fieldEnglishName: 'name', fieldType: 'VARCHAR', fieldRemark: '姓名' },
      { id: 3, fieldEnglishName: 'age', fieldType: 'INT', fieldRemark: '年龄' }
    ]
  } else if (tableId == 102) {
    return [
      { id: 4, fieldEnglishName: 'salaryId', fieldType: 'INT', fieldRemark: '工资ID' },
      { id: 5, fieldEnglishName: 'amount', fieldType: 'DECIMAL', fieldRemark: '金额' }
    ]
  } else if (tableId == 201) {
    return [
      { id: 6, fieldEnglishName: 'orderId', fieldType: 'INT', fieldRemark: '订单ID' },
      { id: 7, fieldEnglishName: 'customerId', fieldType: 'INT', fieldRemark: '客户ID' }
    ]
  } else if (tableId == 202) {
    return [
      { id: 8, fieldEnglishName: 'custId', fieldType: 'INT', fieldRemark: '客户ID' },
      { id: 9, fieldEnglishName: 'custName', fieldType: 'VARCHAR', fieldRemark: '客户姓名' }
    ]
  } else {
    return []
  }
}

const loadFieldList = () => {
  fieldList.value = simulateFieldData(props.tableId)
  if (fieldList.value.length === 0) {
    ElMessage.warning('未获取到字段信息')
  }
}

const handleClose = (done) => {
  done()
}

watch(() => props.modelValue, (newVal) => {
  localVisible.value = newVal
  if (newVal) {
    loadFieldList()
  }
})

watch(localVisible, (newVal) => {
  emit('update:modelValue', newVal)
})
</script>

<style scoped>
/* 根据需要添加样式 */
</style>

2.4 RightTable.vue

<template>
  <div class="right-table">
    <div class="table-header">
      <el-input
        v-model="searchKeyword"
        placeholder="搜索表名"
        class="search-input"
        clearable
        @input="handleSearch"
      />
    </div>
    <el-table :data="pagedTableList" border style="width: 100%;" @row-click="handleRowClick">
      <el-table-column prop="tableEnglishName" label="表名" />
      <el-table-column prop="tableRemark" label="表备注" />
      <el-table-column prop="fieldCount" label="字段数量" width="100" />
      <el-table-column prop="dataCount" label="数据量" width="100" />
      <el-table-column prop="createTime" label="创建时间" width="180" />
      <el-table-column label="操作" width="120">
        <template #default="{ row }">
          <el-button link type="primary" @click.stop="handleViewDetails(row)">查看详情</el-button>
        </template>
      </el-table-column>
    </el-table>
    <div class="pagination-container">
      <el-pagination
        background
        :current-page="currentPage"
        :page-size="pageSize"
        :total="total"
        layout="total, prev, pager, next, jumper"
        @current-change="handlePageChange"
      />
    </div>
  </div>
</template>

<script setup>
import { ref, computed, watch, onMounted } from 'vue'

const props = defineProps({
  dataSourceId: { type: [Number, String], required: true }
})
const emit = defineEmits(['row-select'])
const searchKeyword = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)

// 模拟不同数据源的表数据,这里硬编码两组数据
const tableList = ref([])
const loadTableData = () => {
  // 根据 dataSourceId 模拟不同数据
  if (props.dataSourceId === 1) {
    tableList.value = [
      { id: 101, tableEnglishName: '员工表', tableRemark: '员工基本信息', fieldCount: 5, dataCount: 10, createTime: '2022-01-01' },
      { id: 102, tableEnglishName: '工资表', tableRemark: '员工工资信息', fieldCount: 3, dataCount: 10, createTime: '2022-02-01' }
    ]
  } else if (props.dataSourceId === 2) {
    tableList.value = [
      { id: 201, tableEnglishName: '订单表', tableRemark: '订单数据', fieldCount: 8, dataCount: 20, createTime: '2022-03-01' },
      { id: 202, tableEnglishName: '客户表', tableRemark: '客户信息', fieldCount: 6, dataCount: 15, createTime: '2022-04-01' }
    ]
  } else {
    tableList.value = []
  }
  total.value = tableList.value.length
}

const filteredTableList = computed(() => {
  if (!searchKeyword.value) return tableList.value
  return tableList.value.filter(item =>
    item.tableEnglishName.toLowerCase().includes(searchKeyword.value.toLowerCase())
  )
})

const pagedTableList = computed(() => {
  const start = (currentPage.value - 1) * pageSize.value
  return filteredTableList.value.slice(start, start + pageSize.value)
})

function handleSearch() {
  currentPage.value = 1
}
function handlePageChange(page) {
  currentPage.value = page
}
function handleRowClick(row) {
  // 如果直接点击行,也可触发详情显示
  emit('row-select', row.id)
}
function handleViewDetails(row) {
  // 点击“查看详情”按钮
  emit('row-select', row.id)
}

watch(() => props.dataSourceId, () => {
  currentPage.value = 1
  loadTableData()
})

onMounted(() => {
  loadTableData()
})
</script>

<style scoped>
.right-table {
  height: 100%;
  padding: 10px;
  display: flex;
  flex-direction: column;
}
.table-header {
  margin-bottom: 10px;
}
.search-input {
  width: 300px;
}
.pagination-container {
  margin-top: 15px;
  display: flex;
  justify-content: flex-end;
}
</style>

2.5 Emp.vue

<template>
  <div class="conn-container">
    <div class="left-panel">
      <LeftTree @node-click="handleNodeClick" />
    </div>
    <div class="right-panel">
      <RightTable v-if="currentDataSourceId" :dataSourceId="currentDataSourceId" @row-select="handleRowSelect" />
      <div v-else class="empty-tip">
        <el-empty description="请选择左侧数据源查看表信息" />
      </div>
    </div>
    <FieldDrawer v-model="drawerVisible" :tableId="currentTableId" />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import LeftTree from './LeftTree.vue'
import RightTable from './RightTable.vue'
import FieldDrawer from './FieldDrawer.vue'

const currentDataSourceId = ref(null)
const currentTableId = ref(null)
const drawerVisible = ref(false)

const handleNodeClick = (data) => {
  currentDataSourceId.value = data.id
}
const handleRowSelect = (tableId) => {
  currentTableId.value = tableId
  drawerVisible.value = true
}
</script>

<style scoped>
.conn-container {
  display: flex;
  height: 100%;
  background-color: #f5f7fa;
  padding: 20px;
  gap: 20px;
}
.left-panel {
  width: 300px;
  height: 100%;
  background-color: #fff;
  border-radius: 8px;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  overflow: hidden;
}
.right-panel {
  flex: 1;
  height: 100%;
  background-color: #fff;
  border-radius: 8px;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  overflow: hidden;
}
.empty-tip {
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}
</style>

三、代码解读

在项目中,我们使用了 Vue3 的组合式 API(Composition API)与 Element Plus UI 组件库,完成了一个数据管理的基本示例。整个示例主要包含五个组件,每个组件承担着特定的业务逻辑与视图展现,下面详细解释各模块的技术亮点和实现思路。

3.1 AddDataSource.vue —— 数据源新增对话框

  • 业务功能与交互
    该组件通过 Element Plus 的 el-dialog 实现模态对话框,为用户提供了添加数据源的入口。通过 v-model 实现双向绑定,保证父子组件之间的数据同步。
  • 核心代码讲解
    • 双向绑定与响应式数据:利用 refreactive 建立响应数据,props 接收父组件传入的 modelValue 并通过 watch 监听实现状态同步。
    • 表单验证:在提交前先进行字符串去除空格的简单校验,通过 Element Plus 的 ElMessage 提供即时的反馈提醒用户。
    • 事件通信:通过 emit 触发 success 事件,将新数据源对象传递给父组件;同时利用 update:modelValue 实现 v-model 的模式更新。
  • 技术术语说明
    • ref 与 reactiveref 用于基本类型或单一对象的响应式包装,reactive 则适合包装较复杂的对象。
    • watch:实现数据副作用处理,在响应式数据发生变化时自动执行指定函数。

3.2 LeftTree.vue —— 数据源树视图

  • 业务功能与交互
    使用 Element Plus 的 el-tree 展示数据源树,并通过按钮触发新增数据源对话框。
  • 核心代码讲解
    • 硬编码数据模拟:在初始加载时,通过预设数据构成树结构,后续可根据业务需求替换为后端数据。
    • 自定义节点模板:通过 #default 插槽自定义树节点的展示,采用图标和文本结合的形式,提升用户体验。
    • 事件传递:点击节点或新增成功后,通过事件向父组件传递数据(例如 node-clicksuccess 事件),实现组件间解耦协同。
  • 专业名词解释
    • 插槽(slot):Vue 中的一种内容分发机制,用于灵活地将父组件的内容传递给子组件指定位置,从而实现高度复用的自定义模板。

3.3 FieldDrawer.vue —— 字段详情抽屉

  • 业务功能与交互
    该组件通过抽屉(Drawer)展示选中表的字段详细信息,同样采用了 Element Plus 的 el-drawerel-table 组合展示数据。
  • 核心代码讲解
    • 根据 tableId 加载模拟数据:通过对不同 tableId 传入对应的硬编码数据,示例化如何根据参数返回不同字段列表。
    • 生命周期与状态监听:使用 watch 监听 modelValue,当抽屉状态变为打开时触发加载数据,确保数据与组件状态同步。
    • 模块隔离与复用:由于字段详情只关注数据展现,整个组件实现了高内聚、低耦合,可以方便地在其他业务场景中复用。
  • 专业术语解释
    • 抽屉(Drawer):常用于移动端或桌面端 UI 设计中的侧边弹出窗口,简化界面操作同时不干扰主体页面的展示。

3.4 RightTable.vue —— 表数据展示

  • 业务功能与交互
    主要负责展示选中数据源下的表信息,并提供搜索、分页以及“查看详情”功能。
  • 核心代码讲解
    • 数据处理与分页:通过 computed 属性实现数据过滤与分页处理,确保在搜索和分页操作中数据始终正确渲染。
    • 事件处理:监听行点击以及按钮点击,通过自定义事件 row-select 通知父组件显示详细信息。
    • 表格组件:Element Plus 的 el-table 提供丰富的功能支持,如边框、固定列、行点击事件等,便于根据业务需求进一步扩展交互。
  • 专业术语解释
    • 计算属性(computed):在 Vue 中用于声明依赖其他响应式数据并返回计算结果的属性,具有缓存功能,有效提升性能。

3.5 Emp.vue —— 综合展示视图

  • 业务功能与交互
    综合展示左侧数据源树和右侧表数据,在用户选择左侧数据源后展示右侧表信息,并通过抽屉展示字段详情。
  • 核心代码讲解
    • 组件间通信:主要体现在子组件事件传递到父组件,通过监听 node-clickrow-select 事件动态更新当前数据源和选中表 ID,进而控制抽屉的显示。
    • 状态管理:利用局部状态 ref 保存当前选中的数据源和表 ID,实现页面整体交互的状态联动。
    • 布局设计:左右分栏布局利用 Flex 布局实现,同时引入盒子阴影、边框圆角等视觉提升元素,保证企业级系统页面整洁美观。
  • 专业术语解释
    • 状态管理:在前端应用中,指的是数据的集中处理与管理方式,通过响应式数据管理,使应用对外部事件有一致且稳定的响应。

通过以上解析,我们不仅明确了每个组件的技术实现,也揭示了 Vue3 生态中重要的设计理念和最佳实践。希望通过这一章的深入解读,能帮助开发者在面对更复杂业务时能够灵活应用这些技术,打破常规思维,实现更高效的工程交付。


网站公告

今日签到

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