公共组件
公共组件
业务用途
后台管理系统中存在大量重复的 UI 模式:带分页的数据表格、查询条件表单、卡片容器、状态标签、操作按钮组、字典下拉、空状态等。如果每个页面都从零拼装 Element Plus 组件,代码会冗余且风格不一。本平台在 frontend/src/components/ 下封装了一套「Pro 系列」公共组件,统一交互与样式,让业务页面只需关注数据与逻辑。
涉及文件
frontend/src/components/index.ts-- 统一导出frontend/src/components/ProTable.vue-- 通用表格(分页 + 选择 + 序号)frontend/src/components/ProCard.vue-- 通用卡片容器frontend/src/components/QueryForm.vue-- 查询表单(搜索 / 重置)frontend/src/components/DictSelect.vue-- 字典 / SQL 下拉选择frontend/src/components/StatusTag.vue-- 状态标签frontend/src/components/ActionButton.vue-- 操作按钮组frontend/src/components/EmptyState.vue-- 空状态frontend/src/components/TenantUserSelect.vue-- 租户用户远程选择
实现机制
ProTable:通用表格
ProTable.vue 封装了 el-table + el-pagination,内置序号列、选择列、分页、加载状态,并通过 v-bind="$attrs" 透传所有 el-table 原生属性。
<template>
<div class="pro-table">
<el-table
ref="tableRef"
v-loading="loading"
:data="tableData"
:border="border"
:stripe="stripe"
style="width: 100%"
v-bind="$attrs"
@selection-change="handleSelectionChange"
>
<el-table-column v-if="showSelection" type="selection" width="55" align="center" fixed="left" />
<el-table-column v-if="showIndex" type="index" label="序号" width="60" align="center" fixed="left">
<template #default="{ $index }">
{{ (pageNum - 1) * pageSize + $index + 1 }}
</template>
</el-table-column>
<slot />
</el-table>
<el-pagination
v-if="showPagination"
:current-page="pageNum"
:page-size="pageSize"
:total="total"
:page-sizes="pageSizes"
:layout="layout"
background
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</template>Props
| Prop | 类型 | 默认值 | 说明 |
|---|---|---|---|
tableData | Array | [] | 表格数据 |
loading | Boolean | false | 加载中状态 |
total | Number | 0 | 数据总条数 |
pageNum | Number | 1 | 当前页码 |
pageSize | Number | 10 | 每页条数 |
pageSizes | number[] | [10,20,50,100] | 可选每页条数 |
layout | String | 'total, sizes, prev, pager, next, jumper' | 分页布局 |
showSelection | Boolean | false | 是否显示多选列 |
showIndex | Boolean | true | 是否显示序号列 |
showPagination | Boolean | true | 是否显示分页 |
border | Boolean | true | 是否显示边框 |
stripe | Boolean | true | 是否斑马纹 |
Emits 与 v-model
ProTable 通过 update:pageNum / update:pageSize 支持与父组件的 v-model:pageNum / v-model:pageSize 双向绑定,分页变化时同时触发 query 事件让父组件重新加载数据:
const emit = defineEmits(['update:pageNum', 'update:pageSize', 'query', 'selection-change'])
const handleSizeChange = (val: number) => {
emit('update:pageSize', val)
emit('query')
}
const handleCurrentChange = (val: number) => {
emit('update:pageNum', val)
emit('query')
}defineExpose 暴露方法
defineExpose({
tableRef,
selectedRows,
clearSelection: () => tableRef.value?.clearSelection(),
toggleRowSelection: (row: any, selected?: boolean) => tableRef.value?.toggleRowSelection(row, selected)
})用法示例
<template>
<ProTable
v-model:pageNum="pageNum"
v-model:pageSize="pageSize"
:tableData="tableData"
:total="total"
:loading="loading"
:showSelection="true"
@query="loadData"
@selection-change="onSelectionChange"
>
<el-table-column prop="username" label="用户名" />
<el-table-column prop="nickname" label="昵称" />
<el-table-column label="操作" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
</template>
</el-table-column>
</ProTable>
</template>
<script setup lang="ts">
import { ProTable } from '@/components'
import { ref } from 'vue'
const pageNum = ref(1)
const pageSize = ref(10)
const total = ref(0)
const tableData = ref([])
const loading = ref(false)
const loadData = async () => {
loading.value = true
const res = await getUserList({ pageNum: pageNum.value, pageSize: pageSize.value })
tableData.value = res.data.records
total.value = res.data.total
loading.value = false
}
</script>
序号自动跨页连续
序号列模板 {{ (pageNum - 1) * pageSize + $index + 1 }},第 2 页第 1 条显示为 11,而非 1。
ProCard:通用卡片容器
ProCard.vue 封装 el-card,提供统一的标题、阴影、头部插槽与渐变样式:
<template>
<ProCard title="用户列表">
<template #action>
<el-button type="primary" @click="handleAdd">新增</el-button>
</template>
<!-- 卡片内容 -->
<ProTable ... />
</ProCard>
</template>Props:title(标题)、shadow('always'|'hover'|'never',默认 'never')、bodyPadding(默认 '20px')。
插槽:header / title / action(头部右侧操作区)/ default。
QueryForm:查询表单
QueryForm.vue 提供内联表单 + 搜索 / 重置按钮的固定布局:
<template>
<QueryForm :model="queryForm" @query="loadData" @reset="handleReset">
<el-form-item label="用户名">
<el-input v-model="queryForm.username" placeholder="请输入" clearable />
</el-form-item>
<el-form-item label="状态">
<DictSelect v-model="queryForm.status" dictType="sys_normal_disable" />
</el-form-item>
<template #extra>
<el-button type="success" @click="handleExport">导出</el-button>
</template>
</QueryForm>
</template>- Props:
model(表单数据对象,必填)。 - 插槽:
default(查询字段)、extra(搜索 / 重置按钮右侧的额外按钮)。 - Emits:
query(点击搜索)、reset(点击重置)。
配合 v-model 与重置
QueryForm 只负责触发事件,实际清空查询条件由父组件在 handleReset 中处理。通常用 Object.assign(queryForm, initialForm) 重置。

DictSelect:字典 / SQL 下拉
DictSelect.vue 是平台最具特色的封装之一,支持三种数据来源,自动加载选项:
<!-- 1. 按字典类型加载 -->
<DictSelect v-model="form.status" dictType="sys_normal_disable" />
<!-- 2. 按数据表字段加载(支持字典和 SQL) -->
<DictSelect v-model="form.productId" :tableId="tableId" columnName="product_id" />
<!-- 3. 按 SQL 加载(dictSql) -->
<DictSelect v-model="form.userId" dictSql="SELECT id, nickname FROM sys_user" />
数据加载优先级
loadOptions 方法按优先级依次尝试:
const loadOptions = async () => {
// 优先通过 column options API(支持字典和SQL)
if (props.tableId && props.columnName) {
const res = await getColumnOptions(props.tableId, props.columnName)
if (Array.isArray(res)) {
options.value = res
return
}
}
// 回退:直接通过字典类型加载
if (props.dictType) {
const res = await getDictDataByType(props.dictType)
if (Array.isArray(res)) {
options.value = res.map((d: any) => ({ label: d.dictLabel, value: d.dictValue }))
return
}
}
options.value = []
}为什么优先用 column options
低代码平台的表单字段配置可能绑定字典类型,也可能直接配置 SQL 取值。getColumnOptions(tableId, columnName) 是后端提供的统一接口,根据字段元数据自动判断用字典还是 SQL 返回选项,前端无需关心数据来源。只有脱离表设计器(如系统管理页面)才回退到 getDictDataByType。
Props 变化时自动重新加载:watch(() => props.dictType)、watch(() => props.dictSql)、watch(() => [props.tableId, props.columnName])。
StatusTag:状态标签
StatusTag.vue 根据状态值和映射表自动显示对应文案与样式的标签:
<StatusTag :status="row.status" :statusMap="statusMap" />const statusMap = {
'0': '正常',
'1': '停用'
}Props:status(状态值)、statusMap(状态到文案的映射对象)、type(el-tag 类型)、size、effect。
label 通过 computed 从 statusMap 取值:props.statusMap[props.status] || props.status,未匹配时直接显示原始值。
ActionButton:操作按钮组
ActionButton.vue 用于表格操作列,根据配置渲染一组按钮,支持按行数据控制显示 / 禁用:
<el-table-column label="操作" fixed="right" width="200">
<template #default="{ row }">
<ActionButton :buttons="actionButtons" :row="row" />
</template>
</el-table-column>import { Edit, Delete } from '@element-plus/icons-vue'
const actionButtons = [
{
label: '编辑',
type: 'primary',
icon: Edit,
onClick: (row: any) => handleEdit(row)
},
{
label: '删除',
type: 'danger',
icon: Delete,
show: (row: any) => row.status === '0', // 仅正常状态显示
disabled: (row: any) => !canDelete(row), // 条件禁用
onClick: (row: any) => handleDelete(row)
}
]ButtonConfig 接口:
interface ButtonConfig {
show?: (row: any) => boolean // 是否显示
type?: 'primary' | 'success' | 'warning' | 'info' | 'danger' | 'default' | 'text'
disabled?: (row: any) => boolean // 是否禁用
onClick?: (row: any) => void // 点击回调
icon?: any // 图标组件
label: string // 按钮文字
}按钮渲染为 el-button link size="small",紧凑排列。v-if="!btn.show || btn.show(row)" 控制显示,:disabled="btn.disabled?.(row)" 控制禁用。
EmptyState:空状态
EmptyState.vue 封装 el-empty,支持自定义描述、图片与操作按钮:
<EmptyState description="暂无用户数据" showAction actionText="去添加" @action="handleAdd" />Props:description(默认「暂无数据」)、image、showAction(是否显示操作按钮)、actionText(默认「去添加」)。插槽:default、image。
TenantUserSelect:租户用户远程选择
TenantUserSelect.vue 是一个支持远程搜索的 el-select,用于选择人员,常用于审批人配置、任务分配等场景:
<TenantUserSelect v-model="form.assigneeId" :tenantId="tenantId" @change="onUserChange" />实现机制
const loadUsers = async (query = '') => {
loading.value = true
try {
const res = await request.get('/system/user/list', {
params: {
pageNum: 1,
pageSize: 50,
username: query, // 按用户名远程搜索
tenantId: props.tenantId || undefined,
status: '0' // 只查正常状态用户
}
})
users.value = res.data?.records || res.data || []
} finally {
loading.value = false
}
}
const formatUserLabel = (user: any) => {
const name = user.nickname || user.username
return user.mobile ? `${name}(${user.mobile})` : name
}特性:
filterable remote:输入关键词远程搜索。@focus="loadUsers(keyword)":聚焦时加载一次。watch(() => props.tenantId):租户切换时重新加载。@change事件回传完整的 user 对象(不仅仅是 id)。
组件统一导出
frontend/src/components/index.ts 统一导出公共组件,业务页面可一次性导入:
export { default as ProCard } from './ProCard.vue'
export { default as ProTable } from './ProTable.vue'
export { default as QueryForm } from './QueryForm.vue'
export { default as StatusTag } from './StatusTag.vue'
export { default as ActionButton } from './ActionButton.vue'
export { default as ConfirmDialog } from './ConfirmDialog.vue'
export { default as PageHeader } from './PageHeader.vue'
export { default as EmptyState } from './EmptyState.vue'import { ProTable, ProCard, QueryForm, ActionButton } from '@/components'DictSelect 与 TenantUserSelect 未在 index.ts 导出
index.ts 未导出 DictSelect 和 TenantUserSelect,使用时需单独导入:import DictSelect from '@/components/DictSelect.vue'。这两个组件依赖业务 API(字典接口、用户接口),与纯 UI 组件性质不同,因此单独引入。
操作步骤
搭建一个标准列表页
一个典型的后台列表页由 QueryForm + ProCard + ProTable + ActionButton 组合而成:
<template>
<div class="page-container">
<QueryForm :model="queryForm" @query="loadData" @reset="handleReset">
<el-form-item label="用户名">
<el-input v-model="queryForm.username" clearable />
</el-form-item>
<el-form-item label="状态">
<DictSelect v-model="queryForm.status" dictType="sys_normal_disable" />
</el-form-item>
</QueryForm>
<ProCard title="用户列表">
<template #action>
<el-button type="primary" @click="handleAdd">新增</el-button>
</template>
<ProTable
v-model:pageNum="queryForm.pageNum"
v-model:pageSize="queryForm.pageSize"
:tableData="tableData"
:total="total"
:loading="loading"
@query="loadData"
>
<el-table-column prop="username" label="用户名" />
<el-table-column prop="nickname" label="昵称" />
<el-table-column label="状态">
<template #default="{ row }">
<StatusTag :status="row.status" :statusMap="{ '0': '正常', '1': '停用' }" />
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="160">
<template #default="{ row }">
<ActionButton :buttons="buttons" :row="row" />
</template>
</el-table-column>
</ProTable>
</ProCard>
</div>
</template>常见问题
ProTable 透传属性不生效
ProTable 用 v-bind="$attrs" 将未声明的属性透传给 el-table。如果透传不生效:
- 确认该属性是
el-table支持的(如row-key、height、default-sort)。 - 确认没有在 ProTable 的
props中重复声明同名属性(声明了就不会进入$attrs)。 - 事件透传同理,
@row-click等事件会通过$attrs传给el-table。
ProTable 分页不触发查询
分页变化通过 @query 事件通知父组件。如果父组件没监听 @query,分页点击不会重新加载数据。确保 <ProTable @query="loadData" ...>。
DictSelect 选项为空
- 检查
dictType是否正确(对应后台「字典管理」中的字典类型编码)。 - 检查字典数据是否已启用(
status = '0')。 - 如果用
tableId + columnName,确认表设计器中该字段配置了字典或 SQL。 - 打开 Network 面板查看
getColumnOptions或getDictDataByType接口返回。
ActionButton 图标不显示
icon 需要传入组件对象而非字符串。正确写法:
import { Edit } from '@element-plus/icons-vue'
const buttons = [{ label: '编辑', icon: Edit, onClick: ... }]错误写法:icon: 'Edit'(字符串无法渲染为组件)。
下一步
- 了解表单 / 流程 / BPMN 设计器的实现:
