Appearance
BizCrudPage 增删改查页
一页式 CRUD 容器:把 BizSearchForm(查询)、BizTable 工具栏(刷新 / 密度 / 列设置)、远程表格、BizFormDialog(新增 / 编辑二合一弹窗)、BizConfirmButton(删除确认)编排为整体。提供 schema 与 request 函数即可生成完整管理页,组件不含任何业务接口。
基础用法
搜索 → 表格 → 新增 / 编辑 / 删除全链路,提交与删除实现由
submit-request / delete-request 注入,成功后自动提示并刷新列表。<script setup lang="ts">
/** 基础用法:搜索 + 工具栏 + 远程表格 + 新增/编辑弹窗 + 删除确认,一屏成页 */
import { ref } from 'vue'
import { BizCrudPage } from 'vue3-ep-biz-lib'
import type { BizFormField, BizSearchField, BizTableColumn } from 'vue3-ep-biz-lib'
type UserRow = {
id: number
name: string
mobile: string
dept: string
status: 'enabled' | 'disabled'
createdAt: string
}
/** 模拟接口数据(真实项目替换为服务端请求) */
let seedId = 21
const users = ref<UserRow[]>(
Array.from({ length: 20 }, (_unused, i) => ({
id: i + 1,
name: `用户${i + 1}号`,
mobile: `138${String(10000000 + i * 137).slice(0, 8)}`,
dept: i % 3 === 0 ? '研发部' : i % 3 === 1 ? '运营部' : '财务部',
status: i % 4 === 0 ? 'disabled' : 'enabled',
createdAt: `2026-0${(i % 9) + 1}-1${i % 9}`,
}))
)
const searchFields: BizSearchField[] = [
{ prop: 'keyword', label: '关键词', placeholder: '姓名 / 手机号' },
{
prop: 'dept',
label: '部门',
type: 'select',
options: [
{ label: '研发部', value: '研发部' },
{ label: '运营部', value: '运营部' },
{ label: '财务部', value: '财务部' },
],
},
]
const columns: BizTableColumn<UserRow>[] = [
{ type: 'selection' },
{ type: 'index', label: '序号', width: 60 },
{ prop: 'name', label: '姓名' },
{ prop: 'mobile', label: '手机号', width: 140 },
{ prop: 'dept', label: '部门', width: 100 },
{ prop: 'status', label: '状态', width: 90, slot: 'status' },
{ prop: 'createdAt', label: '创建时间', width: 120 },
]
const formFields: BizFormField[] = [
{ prop: 'name', label: '姓名', required: true, span: 24 },
{ prop: 'mobile', label: '手机号', required: true, span: 24, rules: [{ pattern: /^1\d{10}$/, message: '手机号格式不正确', trigger: 'blur' }] },
{
prop: 'dept',
label: '部门',
type: 'select',
required: true,
span: 24,
options: [
{ label: '研发部', value: '研发部' },
{ label: '运营部', value: '运营部' },
{ label: '财务部', value: '财务部' },
],
},
]
const request = ({ currentPage, pageSize, params }: { currentPage: number; pageSize: number; params: Record<string, unknown> }): Promise<{ list: UserRow[]; total: number }> => {
const keyword = String(params.keyword ?? '')
const dept = String(params.dept ?? '')
const filtered = users.value.filter(
(row) => (keyword === '' || row.name.includes(keyword) || row.mobile.includes(keyword)) && (dept === '' || row.dept === dept)
)
return new Promise((resolve) => {
setTimeout(() => resolve({ list: filtered.slice((currentPage - 1) * pageSize, currentPage * pageSize), total: filtered.length }), 300)
})
}
const submitRequest = (values: Record<string, unknown>, mode: 'create' | 'update'): Promise<void> => {
return new Promise((resolve) => {
setTimeout(() => {
if (mode === 'create') {
users.value.unshift({ ...(values as unknown as UserRow), id: seedId++, status: 'enabled', createdAt: '2026-09-21' })
} else {
const index = users.value.findIndex((row) => row.id === values.id)
if (index >= 0) users.value[index] = { ...users.value[index], ...(values as unknown as Partial<UserRow>) }
}
resolve()
}, 300)
})
}
const deleteRequest = (row: UserRow): Promise<void> => {
return new Promise((resolve) => {
setTimeout(() => {
users.value = users.value.filter((item) => item.id !== row.id)
resolve()
}, 300)
})
}
</script>
<template>
<!-- 内嵌工具栏含 ElPopover,包 ClientOnly 规避文档站水合期弹层交叉接线 -->
<ClientOnly>
<BizCrudPage
title="用户管理"
:search-fields="searchFields"
:columns="columns"
:form-fields="formFields"
:request="request"
:submit-request="submitRequest"
:delete-request="deleteRequest"
:form-dialog-attrs="{ width: '560px' }"
>
<template #status="{ row }">
<el-tag :type="row.status === 'enabled' ? 'success' : 'info'">{{ row.status === 'enabled' ? '启用' : '停用' }}</el-tag>
</template>
</BizCrudPage>
</ClientOnly>
</template>
显示代码
手动提交契约与插槽
未传
submit-request 时通过 submit 事件拿到 values / mode / done 自行处理;列插槽(columns[].slot)与工具栏左侧插槽自由扩展渲染。<script setup lang="ts">
/** 进阶:列插槽自定义渲染(BizStatusTag / 加粗姓名)、工具栏左侧插槽、手动 submit 契约 */
import { ref } from 'vue'
import { ElButton, ElMessage } from 'element-plus'
import { BizCrudPage, BizStatusTag } from 'vue3-ep-biz-lib'
import type { BizFormField, BizSearchField, BizTableColumn } from 'vue3-ep-biz-lib'
type OrderRow = {
id: number
orderNo: string
channel: string
amount: number
status: 'pending' | 'paid' | 'closed'
createdAt: string
}
let seedId = 16
const orders = ref<OrderRow[]>(
Array.from({ length: 15 }, (_unused, i) => ({
id: i + 1,
orderNo: `SO2026092${i + 1}`,
channel: i % 2 === 0 ? '线上商城' : '线下门店',
amount: (i + 1) * 199,
status: (['pending', 'paid', 'closed'] as const)[i % 3],
createdAt: `2026-09-${String(i + 1).padStart(2, '0')}`,
}))
)
const searchFields: BizSearchField[] = [
{ prop: 'orderNo', label: '订单号' },
{
prop: 'status',
label: '状态',
type: 'select',
options: [
{ label: '待支付', value: 'pending' },
{ label: '已支付', value: 'paid' },
{ label: '已关闭', value: 'closed' },
],
},
]
const columns: BizTableColumn<OrderRow>[] = [
{ prop: 'orderNo', label: '订单号', width: 140, slot: 'orderNo' },
{ prop: 'channel', label: '渠道', width: 110 },
{ prop: 'amount', label: '金额(元)', width: 110, align: 'right' },
{ prop: 'status', label: '状态', width: 100, slot: 'status' },
{ prop: 'createdAt', label: '下单时间', width: 120 },
]
const formFields: BizFormField[] = [
{ prop: 'orderNo', label: '订单号', required: true, span: 24, attrs: { disabled: true } },
{ prop: 'channel', label: '渠道', type: 'select', required: true, span: 24, options: [{ label: '线上商城', value: '线上商城' }, { label: '线下门店', value: '线下门店' }] },
]
const request = ({ currentPage, pageSize, params }: { currentPage: number; pageSize: number; params: Record<string, unknown> }): Promise<{ list: OrderRow[]; total: number }> => {
const orderNo = String(params.orderNo ?? '')
const status = String(params.status ?? '')
const filtered = orders.value.filter((row) => (orderNo === '' || row.orderNo.includes(orderNo)) && (status === '' || row.status === status))
return Promise.resolve({ list: filtered.slice((currentPage - 1) * pageSize, currentPage * pageSize), total: filtered.length })
}
/** 未传 submit-request 时走手动契约:submit 事件拿到 values / mode / done 自行处理 */
const onManualSubmit = ({ values, mode, done }: { values: Record<string, unknown>; mode: 'create' | 'update'; done: () => void }): void => {
setTimeout(() => {
if (mode === 'update') {
const index = orders.value.findIndex((row) => row.id === values.id)
if (index >= 0) orders.value[index] = { ...orders.value[index], ...(values as unknown as Partial<OrderRow>) }
} else {
orders.value.unshift({ ...(values as unknown as OrderRow), id: seedId++, status: 'pending', amount: 0, createdAt: '2026-09-21' })
}
done()
ElMessage.success('已保存(手动契约)')
}, 300)
}
const deleteRequest = (row: OrderRow): Promise<void> => {
return new Promise((resolve) => {
setTimeout(() => {
orders.value = orders.value.filter((item) => item.id !== row.id)
resolve()
}, 300)
})
}
</script>
<template>
<ClientOnly>
<BizCrudPage
:search-fields="searchFields"
:columns="columns"
:form-fields="formFields"
:request="request"
:delete-request="deleteRequest"
:page-size="5"
@submit="onManualSubmit"
>
<template #orderNo="{ row }">
<span style="font-weight: 600">{{ row.orderNo }}</span>
</template>
<template #status="{ row }">
<BizStatusTag
:status="row.status"
:status-map="{
pending: { text: '待支付', type: 'warning' },
paid: { text: '已支付', type: 'success' },
closed: { text: '已关闭', type: 'info' },
}"
/>
</template>
<template #toolbar-left>
<ElButton plain @click="ElMessage.info('自定义批量操作')">批量导出</ElButton>
</template>
</BizCrudPage>
</ClientOnly>
</template>
显示代码
精简列表页(无表单弹窗)
未知
暂无数据
不传 formFields 即无新增/编辑能力(操作列仅删除);toolbar 关闭、每页 10 条——只读台账/日志类页面的最省配置。
只读台账场景:不传
form-fields 即无新增 / 编辑(操作列仅删除),toolbar 关闭、page-size 收到 10、删除确认文案定制——设备台账 / 日志类页面的最省配置。<script setup lang="ts">
/** 场景:精简列表页——无新增/编辑弹窗(不传 formFields),关工具栏、小分页、仅关键词搜索 + 删除 */
import { BizCrudPage } from 'vue3-ep-biz-lib'
import type { BizSearchField, BizTableColumn } from 'vue3-ep-biz-lib'
import { BizStatusTag } from 'vue3-ep-biz-lib'
type DeviceRow = {
id: number
sn: string
location: string
online: 'online' | 'offline'
updatedAt: string
}
const devices: DeviceRow[] = Array.from({ length: 35 }, (_unused, i) => ({
id: i + 1,
sn: `DEV-${String(1000 + i)}`,
location: `${i % 3 === 0 ? '一号' : i % 3 === 1 ? '二号' : '三号'}车间 ${i % 6 + 1} 号工位`,
online: i % 5 === 0 ? 'offline' : 'online',
updatedAt: `2026-09-23 0${i % 9}:${String(i % 60).padStart(2, '0')}`,
}))
const searchFields: BizSearchField[] = [
{ prop: 'keyword', label: '序列号', placeholder: '输入设备 SN 过滤' },
]
const columns: BizTableColumn<DeviceRow>[] = [
{ type: 'index', label: '#', width: 55 },
{ prop: 'sn', label: '设备序列号', width: 130 },
{ prop: 'location', label: '安装位置' },
{ prop: 'online', label: '在线状态', width: 100, slot: 'online' },
{ prop: 'updatedAt', label: '最后上报', width: 150 },
]
/** 关键词过滤 + 分页(真实项目替换为服务端接口) */
const request = (params: { params: Record<string, unknown>; currentPage: number; pageSize: number }) => {
const keyword = String(params.params.keyword ?? '').trim()
const filtered = keyword === '' ? devices : devices.filter((d) => d.sn.toLowerCase().includes(keyword.toLowerCase()))
const start = (params.currentPage - 1) * params.pageSize
return Promise.resolve({ list: filtered.slice(start, start + params.pageSize), total: filtered.length })
}
const deleteRequest = (row: DeviceRow) => {
const index = devices.findIndex((d) => d.id === row.id)
if (index >= 0) devices.splice(index, 1)
return Promise.resolve()
}
</script>
<template>
<div>
<BizCrudPage
title="设备台账"
:search-fields="searchFields"
:columns="columns"
:request="request"
:delete-request="deleteRequest"
:toolbar="false"
:page-size="10"
action-width="90"
delete-message="解绑后设备将停止上报,是否继续?"
>
<template #online="{ row }">
<BizStatusTag
:status="row.online"
:status-map="{
online: { text: '在线', type: 'success' },
offline: { text: '离线', type: 'info' },
}"
/>
</template>
</BizCrudPage>
<div style="margin-top: 8px; font-size: 13px; color: var(--el-text-color-secondary)">
不传 formFields 即无新增/编辑能力(操作列仅删除);toolbar 关闭、每页 10 条——只读台账/日志类页面的最省配置。
</div>
</div>
</template>
显示代码
订单管理页
未知
¥0.00
暂无数据
订单场景:searchInitial 预置「待支付」、状态列 BizStatusTag、金额列 BizAmount(分转元)、createText / editText 定制(手工录单 / 改价)。
<script setup lang="ts">
/** 场景:订单管理页——默认时间范围搜索 + 状态枚举 + 金额列渲染 */
import { BizAmount, BizCrudPage, BizStatusTag } from 'vue3-ep-biz-lib'
import type { BizFormField, BizSearchField, BizTableColumn } from 'vue3-ep-biz-lib'
type OrderRow = {
id: number
orderNo: string
buyer: string
status: 'pending' | 'paid' | 'shipped' | 'closed'
amount: number
createdAt: string
}
const orders: OrderRow[] = Array.from({ length: 23 }, (_unused, i) => ({
id: i + 1,
orderNo: `SO2026092${i % 10}${String(1000 + i)}`,
buyer: `客户${String.fromCharCode(65 + (i % 6))}`,
status: (['pending', 'paid', 'shipped', 'closed'] as const)[i % 4],
amount: (i % 7 + 1) * 9900,
createdAt: `2026-09-${String(20 + (i % 4)).padStart(2, '0')} 1${i % 9}:0${i % 6}`,
}))
const searchFields: BizSearchField[] = [
{ prop: 'buyer', label: '买家', placeholder: '买家名称' },
{
prop: 'status',
label: '订单状态',
type: 'select',
options: [
{ label: '待支付', value: 'pending' },
{ label: '已支付', value: 'paid' },
{ label: '已发货', value: 'shipped' },
{ label: '已关闭', value: 'closed' },
],
},
]
const columns: BizTableColumn<OrderRow>[] = [
{ type: 'index', label: '#', width: 55 },
{ prop: 'orderNo', label: '订单号', width: 160 },
{ prop: 'buyer', label: '买家', width: 100 },
{ prop: 'status', label: '状态', width: 96, slot: 'status' },
{ prop: 'amount', label: '金额', width: 130, align: 'right', slot: 'amount' },
{ prop: 'createdAt', label: '下单时间', width: 160 },
]
const formFields: BizFormField[] = [
{ prop: 'buyer', label: '买家', required: true },
{ prop: 'amount', label: '改后金额(分)', type: 'number', required: true },
]
const request = (params: { params: Record<string, unknown>; currentPage: number; pageSize: number }) => {
const status = String(params.params.status ?? '')
const buyer = String(params.params.buyer ?? '').trim()
const filtered = orders.filter((o) => (status === '' || o.status === status) && (buyer === '' || o.buyer.includes(buyer)))
const start = (params.currentPage - 1) * params.pageSize
return Promise.resolve({ list: filtered.slice(start, start + params.pageSize), total: filtered.length })
}
const submitRequest = (_values: Record<string, unknown>, mode: 'create' | 'update') => {
void mode
return Promise.resolve()
}
</script>
<template>
<BizCrudPage
title="订单管理"
:search-fields="searchFields"
:search-initial="{ status: 'pending' }"
:columns="columns"
:request="request"
:form-fields="formFields"
:submit-request="submitRequest"
create-text="手工录单"
edit-text="改价"
action-width="120"
:page-size="10"
>
<template #status="{ row }">
<BizStatusTag
:status="row.status"
:status-map="{
pending: { text: '待支付', type: 'warning' },
paid: { text: '已支付', type: 'primary' },
shipped: { text: '已发货', type: 'success' },
closed: { text: '已关闭', type: 'info' },
}"
/>
</template>
<template #amount="{ row }">
<BizAmount :value="row.amount" unit="cent" symbol="¥" />
</template>
</BizCrudPage>
</template>
显示代码
固定资产台账
固定资产台账
未知
暂无数据
资产场景:toolbar 细项全开(refresh / density / columnSetting / fullscreen)、selection 多选列、deleteText 改「报废」+ 定制确认文案。
<script setup lang="ts">
/** 场景:资产管理——工具栏全开 + 多选 + 批量删除,操作列放宽 */
import { BizCrudPage, BizStatusTag } from 'vue3-ep-biz-lib'
import type { BizSearchField, BizTableColumn } from 'vue3-ep-biz-lib'
type AssetRow = {
id: number
code: string
name: string
category: string
status: 'inuse' | 'idle' | 'scrapped'
keeper: string
}
const assets: AssetRow[] = Array.from({ length: 28 }, (_unused, i) => ({
id: i + 1,
code: `ZC-${String(202600 + i)}`,
name: ['联想 ThinkPad X1', '戴尔 U2723 显示器', '罗技 MX 键盘'][i % 3],
category: i % 3 === 0 ? '电脑' : i % 3 === 1 ? '显示器' : '外设',
status: (['inuse', 'idle', 'scrapped'] as const)[i % 3],
keeper: ['张三', '李四', '王五', '—'][i % 4],
}))
const searchFields: BizSearchField[] = [
{ prop: 'keyword', label: '资产', placeholder: '编号 / 名称' },
{
prop: 'status',
label: '状态',
type: 'select',
options: [
{ label: '在用', value: 'inuse' },
{ label: '闲置', value: 'idle' },
{ label: '已报废', value: 'scrapped' },
],
},
]
const columns: BizTableColumn<AssetRow>[] = [
{ type: 'selection' },
{ prop: 'code', label: '资产编号', width: 120 },
{ prop: 'name', label: '名称' },
{ prop: 'category', label: '类别', width: 90 },
{ prop: 'status', label: '状态', width: 90, slot: 'status' },
{ prop: 'keeper', label: '保管人', width: 90 },
]
const request = (params: { params: Record<string, unknown>; currentPage: number; pageSize: number }) => {
const keyword = String(params.params.keyword ?? '').trim()
const status = String(params.params.status ?? '')
const filtered = assets.filter(
(a) => (status === '' || a.status === status) && (keyword === '' || a.code.includes(keyword.toUpperCase()) || a.name.includes(keyword))
)
const start = (params.currentPage - 1) * params.pageSize
return Promise.resolve({ list: filtered.slice(start, start + params.pageSize), total: filtered.length })
}
const deleteRequest = (row: AssetRow) => {
const index = assets.findIndex((a) => a.id === row.id)
if (index >= 0) assets.splice(index, 1)
return Promise.resolve()
}
</script>
<template>
<BizCrudPage
title="固定资产台账"
:search-fields="searchFields"
:columns="columns"
:request="request"
:delete-request="deleteRequest"
:toolbar="{ refresh: true, density: true, columnSetting: true, fullscreen: true }"
delete-text="报废"
delete-message="报废后资产进入报废库且不可再领用,是否继续?"
action-width="110"
>
<template #status="{ row }">
<BizStatusTag
:status="row.status"
:status-map="{
inuse: { text: '在用', type: 'success' },
idle: { text: '闲置', type: 'warning' },
scrapped: { text: '已报废', type: 'info' },
}"
/>
</template>
</BizCrudPage>
</template>
显示代码
操作审计查询
未知
暂无数据
只读场景:无表单无删除(不传 formFields / deleteRequest),toolbar 关闭、每页 15 条——日志类页面最省配置。
<script setup lang="ts">
/** 场景:审计日志查询——只读列表(无表单无删除),默认搜索当天 */
import { BizCrudPage, BizStatusTag } from 'vue3-ep-biz-lib'
import type { BizSearchField, BizTableColumn } from 'vue3-ep-biz-lib'
type AuditRow = {
id: number
operator: string
action: string
target: string
result: 'success' | 'failed'
ip: string
time: string
}
const logs: AuditRow[] = Array.from({ length: 40 }, (_unused, i) => ({
id: i + 1,
operator: ['zhangsan', 'lisi', 'wangwu'][i % 3],
action: ['登录', '导出报表', '修改配置', '删除记录'][i % 4],
target: ['—', '月度经营表', '网关超时阈值', '测试租户'][i % 4],
result: i % 6 === 0 ? 'failed' : 'success',
ip: `10.20.${i % 5}.${30 + i}`,
time: `2026-09-23 ${String(8 + (i % 9)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}`,
}))
const searchFields: BizSearchField[] = [
{ prop: 'operator', label: '操作人', placeholder: '账号' },
{ prop: 'action', label: '动作', type: 'input', placeholder: '如 导出' },
]
const columns: BizTableColumn<AuditRow>[] = [
{ prop: 'time', label: '时间', width: 150 },
{ prop: 'operator', label: '操作人', width: 100 },
{ prop: 'action', label: '动作', width: 100 },
{ prop: 'target', label: '对象' },
{ prop: 'ip', label: '来源 IP', width: 130 },
{ prop: 'result', label: '结果', width: 80, slot: 'result' },
]
const request = (params: { params: Record<string, unknown>; currentPage: number; pageSize: number }) => {
const operator = String(params.params.operator ?? '').trim()
const action = String(params.params.action ?? '').trim()
const filtered = logs.filter((l) => (operator === '' || l.operator.includes(operator)) && (action === '' || l.action.includes(action)))
const start = (params.currentPage - 1) * params.pageSize
return Promise.resolve({ list: filtered.slice(start, start + params.pageSize), total: filtered.length })
}
</script>
<template>
<BizCrudPage
title="操作审计"
:search-fields="searchFields"
:columns="columns"
:request="request"
:toolbar="false"
:page-size="15"
>
<template #result="{ row }">
<BizStatusTag
:status="row.result"
:status-map="{ success: { text: '成功', type: 'success' }, failed: { text: '失败', type: 'danger' } }"
/>
</template>
</BizCrudPage>
</template>
显示代码
筛选预设联动
订单查询
暂无数据
search-preset 透传 BizSearchForm 的 preset 通道:搜索区出现「保存方案 / 应用」控件,应用后回填条件并自动查询;方案持久化在 localStorage(storageKey),适合运营反复切「常用条件组合」的查询页。<script setup lang="ts">
/** 场景:筛选预设联动——searchPreset 透传 BizSearchForm,保存/应用方案后自动查询 */
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { BizCrudPage } from 'vue3-ep-biz-lib'
import type { BizTableColumn } from 'vue3-ep-biz-lib'
type OrderRow = {
id: number
orderNo: string
channel: 'online' | 'offline' | 'partner'
status: 'paid' | 'pending' | 'refunded'
amount: number
}
const orders = ref<OrderRow[]>(
Array.from({ length: 23 }, (_unused, i) => ({
id: i + 1,
orderNo: `SO-2026${String(1000 + i)}`,
channel: (['online', 'offline', 'partner'] as const)[i % 3],
status: (['paid', 'pending', 'refunded'] as const)[i % 3],
amount: 99 + i * 37,
}))
)
const request = ({ currentPage, pageSize, params }: { currentPage: number; pageSize: number; params: Record<string, unknown> }): Promise<{ list: OrderRow[]; total: number }> => {
const channel = String(params.channel ?? '')
const status = String(params.status ?? '')
return new Promise((resolve) => {
setTimeout(() => {
const filtered = orders.value.filter(
(o) => (channel === '' || o.channel === channel) && (status === '' || o.status === status),
)
resolve({ list: filtered.slice((currentPage - 1) * pageSize, currentPage * pageSize), total: filtered.length })
}, 300)
})
}
const columns: BizTableColumn<OrderRow>[] = [
{ type: 'index', label: '#', width: 50 },
{ prop: 'orderNo', label: '订单号', width: 130 },
{ prop: 'channel', label: '渠道', width: 100 },
{ prop: 'status', label: '状态', width: 100 },
{ prop: 'amount', label: '金额', width: 100 },
]
const onPresetApply = (values: Record<string, unknown>): void => {
ElMessage.success(`已应用方案并查询:${JSON.stringify(values)}`)
}
</script>
<template>
<BizCrudPage
title="订单查询"
:request="request"
:columns="columns"
:page-size="5"
:toolbar="{ columnSetting: false }"
:search-fields="[
{ prop: 'channel', label: '渠道', type: 'select', options: [
{ label: '线上商城', value: 'online' },
{ label: '线下门店', value: 'offline' },
{ label: '合作渠道', value: 'partner' },
] },
{ prop: 'status', label: '状态', type: 'select', options: [
{ label: '已支付', value: 'paid' },
{ label: '待支付', value: 'pending' },
{ label: '已退款', value: 'refunded' },
] },
]"
:search-preset="{ storageKey: 'crud-preset-demo', max: 6 }"
@preset-apply="onPresetApply"
/>
</template>
显示代码
工具栏导出
暂无数据
export-request 注入后工具栏自动出现导出按钮:接收当前查询参数返回 Blob(CSV / xlsx 文件流),确认 → loading → 落盘的编排由 BizExportButton 承担;export-filename 定制文件名。<script setup lang="ts">
/** 场景:工具栏导出——exportRequest 注入后自动出现导出按钮,携带当前查询参数 */
import { BizCrudPage } from 'vue3-ep-biz-lib'
import type { BizTableColumn } from 'vue3-ep-biz-lib'
type AssetRow = {
id: number
code: string
name: string
place: string
price: number
}
const rows: AssetRow[] = Array.from({ length: 18 }, (_unused, i) => ({
id: i + 1,
code: `ZC-${String(800 + i)}`,
name: `办公设备-${i + 1}`,
place: i % 2 === 0 ? '总部A栋' : '园区B栋',
price: 1200 + i * 150,
}))
const request = ({ currentPage, pageSize, params }: { currentPage: number; pageSize: number; params: Record<string, unknown> }): Promise<{ list: AssetRow[]; total: number }> => {
const keyword = String(params.keyword ?? '')
return new Promise((resolve) => {
setTimeout(() => {
const filtered = keyword === '' ? rows : rows.filter((r) => r.name.includes(keyword) || r.code.includes(keyword))
resolve({ list: filtered.slice((currentPage - 1) * pageSize, currentPage * pageSize), total: filtered.length })
}, 250)
})
}
const columns: BizTableColumn<AssetRow>[] = [
{ type: 'index', label: '#', width: 50 },
{ prop: 'code', label: '资产编号', width: 110 },
{ prop: 'name', label: '资产名称' },
{ prop: 'place', label: '存放地', width: 110 },
{ prop: 'price', label: '原值(元)', width: 110 },
]
/** 模拟后端导出:按当前查询参数生成 CSV(真实项目为 xlsx 文件流) */
const exportRequest = (params: Record<string, unknown>): Promise<Blob> => {
const keyword = String(params.keyword ?? '')
return new Promise((resolve) => {
setTimeout(() => {
const filtered = keyword === '' ? rows : rows.filter((r) => r.name.includes(keyword) || r.code.includes(keyword))
const csv = ['编号,名称,存放地,原值', ...filtered.map((r) => `${r.code},${r.name},${r.place},${r.price}`)].join('\n')
resolve(new Blob([`\ufeff${csv}`], { type: 'text/csv;charset=utf-8' }))
}, 600)
})
}
</script>
<template>
<BizCrudPage
title="资产台账"
:request="request"
:columns="columns"
:page-size="5"
:toolbar="{ columnSetting: false }"
:search-fields="[{ prop: 'keyword', label: '关键词', placeholder: '名称 / 编号' }]"
:export-request="exportRequest"
export-filename="资产台账.csv"
/>
</template>
显示代码
行内编辑
商品管理
暂无数据
列配置
inlineEdit(input / number / select / switch)+ inline-save-request 保存实现:双击排序列 / 状态列 / 精选开关就地修改——回车或失焦保存(Esc 取消),成功后提示并刷新列表,是弹窗编辑之外的高频微编辑形态。<script setup lang="ts">
/** 场景:行内编辑——双击排序列(数字) / 状态列(下拉) / 上下架(开关)就地修改,保存后自动刷新 */
import { ref } from 'vue'
import { BizCrudPage } from 'vue3-ep-biz-lib'
import type { BizCrudColumn } from 'vue3-ep-biz-lib'
type GoodsRow = {
id: number
name: string
sort: number
status: 'on' | 'off'
featured: boolean
}
const rows = ref<GoodsRow[]>(
Array.from({ length: 12 }, (_unused, i) => ({
id: i + 1,
name: `商品-${String.fromCodePoint(0x4e00 + i * 3)}${i + 1}号`,
sort: i + 1,
status: i % 3 === 0 ? 'off' : 'on',
featured: i % 5 === 0,
}))
)
const request = ({ currentPage, pageSize }: { currentPage: number; pageSize: number }): Promise<{ list: GoodsRow[]; total: number }> => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ list: rows.value.slice((currentPage - 1) * pageSize, currentPage * pageSize), total: rows.value.length })
}, 200)
})
}
const columns: BizCrudColumn<GoodsRow>[] = [
{ type: 'index', label: '#', width: 50 },
{ prop: 'name', label: '商品名称' },
{
prop: 'sort',
label: '排序(双击)',
width: 110,
inlineEdit: { type: 'number', min: 1, max: 999 },
},
{
prop: 'status',
label: '状态(双击)',
width: 110,
inlineEdit: {
type: 'select',
options: [
{ label: '上架', value: 'on' },
{ label: '下架', value: 'off' },
],
},
},
{ prop: 'featured', label: '精选(双击)', width: 100, inlineEdit: { type: 'switch' } },
]
/** 模拟保存接口:直接写回本地数据 */
const inlineSaveRequest = async (row: GoodsRow, field: string, value: unknown): Promise<void> => {
await new Promise((r) => setTimeout(r, 400))
const target = rows.value.find((r) => r.id === row.id)
if (target !== undefined) {
;(target as unknown as Record<string, unknown>)[field] = value
}
}
</script>
<template>
<BizCrudPage
title="商品管理"
:request="request"
:columns="columns"
:page-size="6"
:toolbar="{ columnSetting: false }"
:inline-save-request="inlineSaveRequest"
/>
</template>
显示代码
左树右表(基础 + 树搜索)
传入
aside-tree 即启用左树右表:静态分类树 + 表格,进入页面自动选中首个节点,选中值以 nodeId 并入查询参数触发过滤;filterable 开启本地搜索(静态树全量过滤)——部门-人员 / 分类-商品 / 区域-门店类主从页的最常用形态。<script setup lang="ts">
/** 左树右表:静态分类树 + 商品列表,进入页面自动选中首个分类,选中值以 nodeId 并入查询参数 */
import { ref } from 'vue'
import { BizCrudPage } from 'vue3-ep-biz-lib'
import type { BizCrudAsideTree, BizTableColumn } from 'vue3-ep-biz-lib'
type GoodsRow = {
id: number
name: string
category: string
price: number
stock: number
status: 'on' | 'off'
}
/** 分类树:id 即选中后并入查询参数的值;filterable 开启本地搜索(静态树全量过滤) */
const asideTree: BizCrudAsideTree = {
title: '商品分类',
param: 'nodeId',
filterable: true,
data: [
{
id: 'drink',
label: '饮料饮品',
children: [
{ id: 'drink-hot', label: '热饮咖啡' },
{ id: 'drink-cold', label: '冰饮果汁' },
],
},
{
id: 'snack',
label: '休闲零食',
children: [
{ id: 'snack-nut', label: '坚果炒货' },
{ id: 'snack-cookie', label: '饼干糕点' },
],
},
{ id: 'fresh', label: '生鲜果蔬' },
],
}
const goods = ref<GoodsRow[]>(
Array.from({ length: 23 }, (_unused, i) => ({
id: i + 1,
name: `商品 ${String(i + 1).padStart(2, '0')}`,
category: ['drink-hot', 'drink-cold', 'snack-nut', 'snack-cookie', 'fresh'][i % 5],
price: Number((9.9 + i * 1.7).toFixed(2)),
stock: (i * 13) % 90,
status: i % 6 === 0 ? 'off' : 'on',
}))
)
const columns: BizTableColumn<GoodsRow>[] = [
{ prop: 'name', label: '商品名称' },
{ prop: 'category', label: '所属分类', width: 120 },
{ prop: 'price', label: '单价(元)', width: 110 },
{ prop: 'stock', label: '库存', width: 90 },
{ prop: 'status', label: '状态', width: 90, slot: 'status' },
]
const request = ({ currentPage, pageSize, params }: { currentPage: number; pageSize: number; params: Record<string, unknown> }): Promise<{ list: GoodsRow[]; total: number }> => {
const nodeId = String(params.nodeId ?? '')
const filtered = goods.value.filter((row) => nodeId === '' || nodeId === row.category || nodeId === row.category.split('-')[0])
return new Promise((resolve) => {
setTimeout(() => resolve({ list: filtered.slice((currentPage - 1) * pageSize, currentPage * pageSize), total: filtered.length }), 300)
})
}
</script>
<template>
<ClientOnly>
<BizCrudPage
title="商品列表"
:aside-tree="asideTree"
:columns="columns"
:request="request"
:page-size="10"
>
<template #status="{ row }">
<el-tag :type="row.status === 'on' ? 'success' : 'info'">{{ row.status === 'on' ? '上架' : '下架' }}</el-tag>
</template>
</BizCrudPage>
</ClientOnly>
</template>
显示代码
左树右表(懒加载组织树)
lazy 注入逐级拉取子节点(根层级入参 null);param 自定义并入查询参数的字段名(本例 deptId);树筛选与搜索条件叠加生效,aside-change 外抛选中节点。注:懒加载树下 filterable 仅过滤已加载节点,需要全量搜索请用静态树。<script setup lang="ts">
/** 左树右表:懒加载组织树(模拟接口逐级拉取),param 自定义为 deptId,树筛选与搜索条件叠加生效 */
import { ref } from 'vue'
import { BizCrudPage } from 'vue3-ep-biz-lib'
import type { BizCrudAsideTree, BizSearchField, BizTableColumn, BizCrudTreeNode } from 'vue3-ep-biz-lib'
type StaffRow = {
id: number
name: string
deptId: string
mobile: string
role: string
}
const asideTree: BizCrudAsideTree = {
title: '组织架构',
param: 'deptId',
width: 260,
/** 模拟懒加载接口:根层级入参 null */
lazy: (node) => {
return new Promise((resolve) => {
setTimeout(() => {
if (node === null) {
resolve([
{ id: 'hq', label: '集团总部', isLeaf: false },
{ id: 'rd', label: '研发中心', isLeaf: false },
])
} else if (node.id === 'hq') {
resolve([
{ id: 'hq-hr', label: '人力资源部', isLeaf: true },
{ id: 'hq-fin', label: '财务部', isLeaf: true },
])
} else if (node.id === 'rd') {
resolve([
{ id: 'rd-fe', label: '前端组', isLeaf: true },
{ id: 'rd-be', label: '后端组', isLeaf: true },
{ id: 'rd-qa', label: '测试组', isLeaf: true },
])
} else {
resolve([])
}
}, 400)
})
},
}
const searchFields: BizSearchField[] = [{ prop: 'keyword', label: '关键词', placeholder: '姓名 / 手机号' }]
const staff = ref<StaffRow[]>(
Array.from({ length: 18 }, (_unused, i) => ({
id: i + 1,
name: `员工${String.fromCharCode(65 + (i % 26))}${i + 1}`,
deptId: ['hq-hr', 'hq-fin', 'rd-fe', 'rd-be', 'rd-qa'][i % 5],
mobile: `139${String(20000000 + i * 321).slice(0, 8)}`,
role: i % 3 === 0 ? '主管' : '专员',
}))
)
const columns: BizTableColumn<StaffRow>[] = [
{ prop: 'name', label: '姓名' },
{ prop: 'deptId', label: '部门', width: 120, formatter: (_row, _col, value) => DEPT_NAMES[String(value)] ?? String(value) },
{ prop: 'mobile', label: '手机号', width: 140 },
{ prop: 'role', label: '岗位', width: 90 },
]
const DEPT_NAMES: Record<string, string> = {
'hq-hr': '人力资源部',
'hq-fin': '财务部',
'rd-fe': '前端组',
'rd-be': '后端组',
'rd-qa': '测试组',
}
const request = ({ currentPage, pageSize, params }: { currentPage: number; pageSize: number; params: Record<string, unknown> }): Promise<{ list: StaffRow[]; total: number }> => {
const keyword = String(params.keyword ?? '')
const deptId = String(params.deptId ?? '')
const filtered = staff.value.filter(
(row) => (keyword === '' || row.name.includes(keyword) || row.mobile.includes(keyword)) && (deptId === '' || row.deptId === deptId)
)
return new Promise((resolve) => {
setTimeout(() => resolve({ list: filtered.slice((currentPage - 1) * pageSize, currentPage * pageSize), total: filtered.length }), 300)
})
}
const lastNode = ref<string>('')
const onAsideChange = (node: BizCrudTreeNode): void => {
lastNode.value = `${node.label}(${String(node.id)})`
}
</script>
<template>
<ClientOnly>
<p v-if="lastNode !== ''" style="margin: 0 0 8px">当前选中:{{ lastNode }}</p>
<BizCrudPage
title="员工管理"
:aside-tree="asideTree"
:search-fields="searchFields"
:columns="columns"
:request="request"
@aside-change="onAsideChange"
/>
</ClientOnly>
</template>
显示代码
左树右表(可折叠 + 手动选择)
collapsible(默认开)支持把侧栏收成窄条让表格占满;auto-select-first: false 关闭自动选中改为手动点选,aside-change 联动外部统计。<script setup lang="ts">
/** 左树右表:可折叠侧栏(收起为窄条让表格占满)+ autoSelectFirst 关闭改为手动选择,aside-change 联动外部统计 */
import { ref } from 'vue'
import { BizCrudPage } from 'vue3-ep-biz-lib'
import type { BizCrudAsideTree, BizTableColumn, BizCrudTreeNode } from 'vue3-ep-biz-lib'
type DeviceRow = {
id: number
name: string
area: string
online: boolean
battery: number
}
const asideTree: BizCrudAsideTree = {
title: '监测区域',
param: 'areaId',
autoSelectFirst: false,
width: 200,
data: [
{ id: 'north', label: '北园区' },
{ id: 'south', label: '南园区' },
{ id: 'east', label: '东厂区' },
{ id: 'west', label: '西仓区' },
],
}
const devices = ref<DeviceRow[]>(
Array.from({ length: 16 }, (_unused, i) => ({
id: i + 1,
name: `监测点-${String(i + 1).padStart(2, '0')}`,
area: ['north', 'south', 'east', 'west'][i % 4],
online: i % 5 !== 0,
battery: 40 + ((i * 7) % 60),
}))
)
const columns: BizTableColumn<DeviceRow>[] = [
{ prop: 'name', label: '监测点' },
{ prop: 'area', label: '区域', width: 110 },
{ prop: 'online', label: '在线状态', width: 100, slot: 'online' },
{ prop: 'battery', label: '电量(%)', width: 100 },
]
const request = ({ currentPage, pageSize, params }: { currentPage: number; pageSize: number; params: Record<string, unknown> }): Promise<{ list: DeviceRow[]; total: number }> => {
const areaId = String(params.areaId ?? '')
const filtered = devices.value.filter((row) => areaId === '' || row.area === areaId)
return new Promise((resolve) => {
setTimeout(() => resolve({ list: filtered.slice((currentPage - 1) * pageSize, currentPage * pageSize), total: filtered.length }), 250)
})
}
const selectedArea = ref('全部区域')
const onAsideChange = (node: BizCrudTreeNode): void => {
selectedArea.value = node.label
}
</script>
<template>
<ClientOnly>
<p style="margin: 0 0 8px">侧栏可点击标题右侧图标收起 / 展开;当前筛选:{{ selectedArea }}</p>
<BizCrudPage
title="环境监测"
:aside-tree="asideTree"
:columns="columns"
:request="request"
@aside-change="onAsideChange"
>
<template #online="{ row }">
<el-tag :type="row.online ? 'success' : 'danger'" effect="light">{{ row.online ? '在线' : '离线' }}</el-tag>
</template>
</BizCrudPage>
</ClientOnly>
</template>
显示代码
左树右表(完整 CRUD 管理页)
树 + 搜索 + 新增 / 编辑弹窗 + 删除完整编排:树选中值在搜索 / 重置后保留叠加;配合
openForm expose 让新增表单自动带出当前分类。<script setup lang="ts">
/** 左树右表完整管理页:树 + 搜索 + 新增/编辑弹窗 + 删除;树选中值在搜索 / 重置后保留叠加,新增默认归入当前分类 */
import { ref } from 'vue'
import { BizCrudPage } from 'vue3-ep-biz-lib'
import type { BizCrudAsideTree, BizCrudPageInstance, BizFormField, BizSearchField, BizTableColumn, BizCrudTreeNode } from 'vue3-ep-biz-lib'
type AssetRow = {
id: number
name: string
typeId: string
user: string
amount: number
}
let seedId = 13
const asideTree: BizCrudAsideTree = {
title: '资产分类',
param: 'typeId',
data: [
{ id: 'it', label: 'IT 设备', children: [{ id: 'it-pc', label: '台式机' }, { id: 'it-nb', label: '笔记本' }] },
{ id: 'office', label: '办公家具', children: [{ id: 'office-desk', label: '办公桌' }, { id: 'office-chair', label: '座椅' }] },
],
}
const TYPE_NAMES: Record<string, string> = { it: 'IT 设备', 'it-pc': '台式机', 'it-nb': '笔记本', office: '办公家具', 'office-desk': '办公桌', 'office-chair': '座椅' }
const assets = ref<AssetRow[]>(
Array.from({ length: 12 }, (_unused, i) => ({
id: i + 1,
name: `资产-${String(i + 1).padStart(2, '0')}`,
typeId: ['it-pc', 'it-nb', 'office-desk', 'office-chair'][i % 4],
user: `员工${i + 1}`,
amount: 1 + (i % 3),
}))
)
const searchFields: BizSearchField[] = [{ prop: 'keyword', label: '资产名称' }]
const columns: BizTableColumn<AssetRow>[] = [
{ prop: 'name', label: '资产名称' },
{ prop: 'typeId', label: '分类', width: 110, formatter: (_row, _col, value) => TYPE_NAMES[String(value)] ?? String(value) },
{ prop: 'user', label: '使用人', width: 110 },
{ prop: 'amount', label: '数量', width: 80 },
]
const formFields: BizFormField[] = [
{ prop: 'name', label: '资产名称', required: true, span: 24 },
{
prop: 'typeId',
label: '所属分类',
type: 'select',
required: true,
span: 24,
options: Object.entries(TYPE_NAMES).map(([value, label]) => ({ label, value })),
},
{ prop: 'user', label: '使用人', required: true, span: 24 },
]
const request = ({ currentPage, pageSize, params }: { currentPage: number; pageSize: number; params: Record<string, unknown> }): Promise<{ list: AssetRow[]; total: number }> => {
const keyword = String(params.keyword ?? '')
const typeId = String(params.typeId ?? '')
const filtered = assets.value.filter(
(row) => (keyword === '' || row.name.includes(keyword)) && (typeId === '' || row.typeId === typeId || row.typeId.split('-')[0] === typeId)
)
return new Promise((resolve) => {
setTimeout(() => resolve({ list: filtered.slice((currentPage - 1) * pageSize, currentPage * pageSize), total: filtered.length }), 300)
})
}
/** 新增资产默认归入当前树选中的分类(父分类时留给用户自选) */
const currentType = ref('')
const onAsideChange = (node: BizCrudTreeNode): void => {
currentType.value = String(node.id)
}
const pageRef = ref<BizCrudPageInstance<AssetRow>>()
const openCreate = (): void => {
const leaf = ['it-pc', 'it-nb', 'office-desk', 'office-chair'].includes(currentType.value) ? currentType.value : undefined
pageRef.value?.openForm('create', { typeId: leaf } as Partial<AssetRow> as AssetRow)
}
const submitRequest = (values: Record<string, unknown>, mode: 'create' | 'update'): Promise<void> => {
return new Promise((resolve) => {
setTimeout(() => {
if (mode === 'create') assets.value.unshift({ ...(values as unknown as AssetRow), id: seedId++ })
else {
const index = assets.value.findIndex((row) => row.id === values.id)
if (index >= 0) assets.value[index] = { ...assets.value[index], ...(values as unknown as Partial<AssetRow>) }
}
resolve()
}, 300)
})
}
const deleteRequest = (row: AssetRow): Promise<void> => {
return new Promise((resolve) => {
setTimeout(() => {
assets.value = assets.value.filter((item) => item.id !== row.id)
resolve()
}, 250)
})
}
</script>
<template>
<ClientOnly>
<p style="margin: 0 0 8px">
左侧选分类后点「新增」,表单自动带出当前分类;搜索 / 重置不会清掉树筛选。
<el-button link type="primary" @click="openCreate">按当前分类新增</el-button>
</p>
<BizCrudPage
ref="pageRef"
title="资产管理"
:aside-tree="asideTree"
:search-fields="searchFields"
:columns="columns"
:form-fields="formFields"
:request="request"
:submit-request="submitRequest"
:delete-request="deleteRequest"
:form-dialog-attrs="{ width: '520px' }"
@aside-change="onAsideChange"
/>
</ClientOnly>
</template>
显示代码
Props
| 属性名 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| title | string | '' | 页面标题(透传 BizTable 工具栏标题) |
| search-fields | BizSearchField[] | — | 搜索字段 schema(透传 BizSearchForm) |
| search-preset | BizSearchPresetConfig | — | 搜索区筛选预设配置(透传 BizSearchForm preset:保存 / 应用 / 星标,应用后自动查询) |
| search-initial | Record<string, unknown> | {} | 初始查询参数(进入页面即生效) |
| columns | BizCrudColumn[] | —(必填) | 表格列 schema(BizTableColumn + inlineEdit 行内编辑扩展) |
| aside-tree | BizCrudAsideTree | — | 左树右表侧栏树配置,传入即启用 aside 模式:静态 data 或 lazy 懒加载、param 并入查询参数的字段名(默认 nodeId)、filterable 树搜索(静态树全量过滤;懒加载树仅过滤已加载节点)、autoSelectFirst 自动选中首节点(默认开)、title / width / collapsible / defaultExpandAll / emptyText 外观项 |
| form-fields | BizFormField[] | — | 弹窗表单字段 schema;传入后自动出现新增 / 编辑能力 |
| request | (params) => Promise<{ list, total }> | —(必填) | 远程数据源(BizTable dataSource 签名) |
| submit-request | (values, mode) => Promise<void> | — | 保存实现;resolve 后自动关弹窗、提示并刷新 |
| delete-request | (row) => Promise<void> | — | 删除实现;resolve 后自动提示并刷新 |
| export-request | (params) => Promise<Blob> | — | 导出实现(接收当前查询参数);传入后工具栏自动出现导出按钮 |
| export-filename | string | '{title}.xlsx' | 导出文件名(含扩展名) |
| inline-save-request | (row, field, value) => Promise<void> | — | 行内编辑保存实现;resolve 后自动提示并刷新列表 |
| create-text / edit-text / delete-text | string | '新增' / '编辑' / '删除' | 动作文案 |
| delete-message | string | '删除后不可恢复,是否继续?' | 删除确认弹窗内容 |
| action-label / action-width | string / number | '操作' / 150 | 自动追加的操作列标题与宽度 |
| page-size | number | 20 | 初始每页条数 |
| row-key | string | 'id' | 行 key 字段 |
| toolbar | boolean | true | BizTable 工具栏开关 |
| form-label-width | string / number | '100px' | 弹窗表单标签宽度 |
| form-dialog-attrs | Record<string, unknown> | {} | 透传 BizFormDialog(width / top / append-to-body...) |
Events
| 事件名 | 参数 | 说明 |
|---|---|---|
| search | (values) | 查询条件提交(已同步进表格 params) |
| reset | (values) | 重置(已同步进表格 params) |
| preset-apply | (values) | 筛选预设应用(回填 + 自动查询已完成) |
| submit | ({ values, mode, done }) | 未传 submit-request 时的手动保存契约;done() 结束按钮 loading,关弹窗由业务侧控制 |
| submitted | (mode) | submit-request 模式保存成功 |
| deleted | (row) | submit-request 模式删除成功(未传 delete-request 时点击确认即触发,由业务侧处理) |
| inline-saved | (row, field, value) | 行内编辑保存成功 |
| aside-change | (node: BizCrudTreeNode) | 左树右表侧栏选中节点变化(已同步进表格 params,树筛选与搜索条件叠加) |
| refresh | — | 工具栏刷新透传 |
Slots
| 插槽名 | 说明 |
|---|---|
| toolbar-left | 工具栏左侧追加区(新增按钮之后) |
| form-extra | 弹窗表单底部追加内容 |
| 动态插槽 | 搜索字段插槽(searchFields[].slot)与表格列插槽(columns[].slot)同名转发,作用域与内部组件一致 |
Expose
| 属性/方法 | 说明 |
|---|---|
| refresh() | 远程重新请求列表 |
| getSelection() | 当前多选行 |
| openForm(mode, row?) | 外部主动打开弹窗表单 |
| getAsideNode() | 当前选中的侧栏树节点(未启用 aside / 未选中时为 null) |