1. 目标与产出
- 掌握中后台表格/表单/分页的标准化开发范式。
- 深入理解
vue-request的最佳实践,包括请求缓存、轮询、防抖等高级特性。 - 通过楼栋管理实战页面,串联 Day 1 请求层 + Day 2 守卫/指令的完整数据流。
- 产出可复用的
useList、useForm、useExport组合式函数与 SchemaForm 组件。
2. 核心技术栈选型
- UI 组件库: Element Plus(表格、表单、分页、弹窗等基础组件)
- 请求库: vue-request(基于 Vue 3 的请求管理库)
- HTTP 客户端: axios + foca-axios(增强型 axios)
- 表单方案: SchemaForm(配置驱动的动态表单)
- 状态管理: Pinia(用户信息、权限状态)
依赖版本(参考项目 package.json):
json
{
"vue": "^3.5.26",
"element-plus": "^2.13.6",
"vue-request": "^2.0.4",
"axios": "^1.13.2",
"foca-axios": "^4.3.0",
"pinia": "^3.0.4"
}
3. vue-request 核心特性与最佳实践
3.1 基础用法
vue-request 提供了简洁的 API 来管理异步请求状态:
ts
import { useRequest } from 'vue-request'
import { amsAssetBuildingList } from '@/service/api/amsAsset'
// 基础用法
const { data, loading, error, runAsync } = useRequest(amsAssetBuildingList)
// 手动触发请求
const handleSearch = async () => {
const result = await runAsync({ pageNum: 1, pageSize: 10 })
}
3.2 高级特性
3.2.1 请求缓存
ts
// 缓存 5 分钟
const { data } = useRequest(getUserInfo, {
cacheKey: 'user-info',
cacheTime: 5 * 60 * 1000,
})
3.2.2 轮询
ts
// 每 3 秒轮询一次
const { data } = useRequest(getSystemStatus, {
pollingInterval: 3000,
})
3.2.3 防抖与节流
ts
// 搜索框输入防抖 500ms
const { run: search } = useRequest(searchAPI, {
debounceWait: 500,
})
// 按钮点击节流 1000ms
const { run: submit } = useRequest(submitAPI, {
throttleWait: 1000,
})
3.2.4 依赖请求
ts
// 当 userId 变化时自动重新请求
const { data } = useRequest(() => getUserDetail(userId.value), {
refreshDeps: [userId],
})
3.2.5 错误重试
ts
// 失败后自动重试 3 次,间隔 1 秒
const { data } = useRequest(getData, {
retryCount: 3,
retryDelay: 1000,
})
4. 表格开发范式
4.1 标准化表格页面结构
一个完整的表格页面应包含:
- 筛选表单区
- 操作按钮区(新增、导入、导出)
- 数据表格区
- 分页区
4.2 楼栋管理实战示例
基于项目的楼栋管理页面 (src/views/asset/building/building/index.vue):
vue
<script setup lang="ts">
import { defineField, defineSchema } from '@/components'
import { onMounted, reactive, ref, onActivated } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRequest } from 'vue-request'
import { useExport } from '@/common/hooks'
import { useCompanyOptions } from '@/utils/useSelectDict'
import {
amsAssetBuildingDelete,
amsAssetBuildingEnable,
amsAssetBuildingList,
} from '@/service/api/amsAsset'
// 产权单位选项
const companyOptions = useCompanyOptions()
// 使用 vue-request 管理请求
const { runAsync: buildingList } = useRequest(amsAssetBuildingList)
const { runAsync: toggleStatusBuilding } = useRequest(amsAssetBuildingEnable)
const { runAsync: deleteBuilding } = useRequest(amsAssetBuildingDelete)
// 筛选表单状态
const formState = reactive({
pageNum: 1,
pageSize: 10,
} as AssetBuildingListDTO)
// 动态表单配置
const formSchema = defineSchema({
fields: [
defineField.Input({ label: '楼栋名称', prop: 'buildingName', clearable: true }),
defineField.Input({ label: '楼栋编码', prop: 'buildingId', clearable: true }),
defineField.Input({ label: '所属项目', prop: 'projectName', clearable: true }),
defineField.Select({
label: '产权单位',
prop: 'ownershipUnitCode',
options: companyOptions,
props: {
value: 'unifiedSocialCreditCode',
label: 'companyName',
},
clearable: true,
filterable: true,
multiple: true,
}),
defineField.Select({
label: '状态',
prop: 'enable',
options: [
{ value: '0', label: '禁用' },
{ value: '1', label: '启用' },
],
clearable: true,
filterable: true,
}),
],
})
// 分页状态
const total = ref<number>(0)
const loading = ref<boolean>(false)
const tableData = reactive<AssetBuildingVO[]>([])
// 获取数据
const getData = async (): Promise<void> => {
loading.value = true
const params = getParams()
const { total: resTotal, data } = await buildingList({ ...params })
total.value = resTotal
tableData.length = 0
tableData.push(...data)
loading.value = false
}
const getParams = () => {
const cloneformState = { ...formState }
return cloneformState
}
// 搜索
const handleSearch = () => {
formState.pageNum = 1
getData()
}
// 分页事件
const handleSizeChange = (val: number): void => {
formState.pageSize = val
getData()
}
const handleCurrentChange = (val: number): void => {
formState.pageNum = val
getData()
}
// 路由跳转
const router = useRouter()
const addBuilding = () => router.push('/asset/management/add-building')
const editBuilding = (buildingId: string) =>
router.push(`/asset/management/edit-building/${buildingId}`)
const detailBuilding = (buildingId: string) =>
router.push(`/asset/management/detail-building/${buildingId}`)
// 修改状态
const toggleStatus = async (buildingId: string, enable: number) => {
await ElMessageBox.confirm(`是否确定${enable ? '停用' : '启用'}楼栋?`, '确认提示', {
type: 'warning',
})
await toggleStatusBuilding({ buildingId, enable: !enable })
ElMessage.success('修改成功')
getData()
}
// 删除
const deleteData = async (buildingId: string) => {
await ElMessageBox.confirm(`是否确定删除楼栋?`, '确认提示', { type: 'warning' })
await deleteBuilding({ buildingId })
ElMessage.success('删除成功')
getData()
}
// 导入导出
const handleImport = () => {
router.push('/asset/management/import-building')
}
const { exportData, loading: exportLoading } = useExport({
meta: '/ams/asset-building/list-export-meta',
url: '/ams/asset-building/list-export',
})
// 生命周期
onMounted(() => getData())
onActivated(() => getData())
</script>
<template>
<section-group title="筛选查询">
<schema-form
:schema="formSchema"
:model="formState"
@finish="handleSearch"
@reset="handleSearch"
/>
</section-group>
<section-group title="数据列表">
<template #extra>
<el-button type="primary" @click="addBuilding">新增楼栋</el-button>
<el-button @click="handleImport">导入</el-button>
<el-button @click="exportData(getParams())" :loading="exportLoading">导出</el-button>
</template>
<el-table v-loading="loading" :data="tableData" border>
<el-table-column label="序号" type="index" width="55" fixed="left" />
<el-table-column label="楼栋编码" prop="buildingId" />
<el-table-column label="楼栋名称" prop="buildingName" />
<el-table-column label="所属项目" prop="projectName" />
<el-table-column label="项目类型" prop="projectTypeName" />
<el-table-column label="产权单位" prop="ownershipUnitName" width="240" />
<el-table-column label="状态" prop="enable" width="70">
<template #default="{ row }">
<el-switch
:model-value="row.enable === 1"
@change="toggleStatus(row.buildingId, row.enable)"
/>
</template>
</el-table-column>
<el-table-column fixed="right" label="操作" width="180">
<template #default="{ row }">
<el-button link type="primary" @click="detailBuilding(row.buildingId)">
查看详情
</el-button>
<el-button link type="primary" @click="editBuilding(row.buildingId)">编辑</el-button>
<el-button link type="danger" @click="deleteData(row.buildingId)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="mt-base float-right"
background
v-model:current-page="formState.pageNum"
v-model:page-size="formState.pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</section-group>
</template>
4.3 表格开发最佳实践
4.3.1 数据管理
- 使用
reactive管理表格数据,避免直接赋值导致响应式丢失 - 使用
tableData.length = 0清空数组,然后push新数据
4.3.2 分页状态
- 将
pageNum和pageSize放入表单状态中,便于筛选时重置 - 搜索时重置
pageNum = 1,确保从第一页开始
4.3.3 生命周期
onMounted: 首次加载数据onActivated: keep-alive 缓存激活时重新加载(适用于 tab 切换场景)
4.3.4 权限控制
结合 Day 2 的权限指令:
vue
<el-button v-perm="'building:add'" type="primary" @click="addBuilding">
新增楼栋
</el-button>
<el-button v-perm="'building:delete'" link type="danger" @click="deleteData(row.buildingId)">
删除
</el-button>
5. 表单开发范式
5.1 SchemaForm 配置驱动表单
项目中的 SchemaForm 组件通过配置驱动表单生成:
ts
const formSchema = defineSchema({
fields: [
defineField.Input({ label: '楼栋名称', prop: 'buildingName', clearable: true }),
defineField.Select({
label: '产权单位',
prop: 'ownershipUnitCode',
options: companyOptions,
props: {
value: 'unifiedSocialCreditCode',
label: 'companyName',
},
clearable: true,
filterable: true,
multiple: true,
}),
],
})
5.2 useForm 组合式函数
项目中的 useForm 提供表单状态管理和重置功能:
ts
import { useForm } from '@/common/hooks'
const modelFormRef = useTemplateRef('modelFormRef')
const [form, resetForm] = useForm(
{ dicType: 1001, dicLevel: 1 } as SysDicUpsertDTO,
modelFormRef
)
// 重置表单
resetForm()
5.3 表单验证
使用 Element Plus 的表单验证规则:
ts
const rules = {
buildingName: [
{ required: true, message: '请输入楼栋名称', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' },
],
buildingId: [
{ required: true, message: '请输入楼栋编码', trigger: 'blur' },
{ pattern: /^[A-Z0-9]+$/, message: '只能输入大写字母和数字', trigger: 'blur' },
],
}
6. 分页开发范式
6.1 标准分页组件
vue
<el-pagination
class="mt-base float-right"
background
v-model:current-page="formState.pageNum"
v-model:page-size="formState.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
6.2 分页事件处理
ts
const handleSizeChange = (val: number): void => {
formState.pageSize = val
getData()
}
const handleCurrentChange = (val: number): void => {
formState.pageNum = val
getData()
}
6.3 分页参数传递
确保后端接口接收正确的分页参数:
ts
const getParams = () => {
const cloneformState = { ...formState }
return cloneformState
}
const getData = async (): Promise<void> => {
loading.value = true
const params = getParams()
const { total: resTotal, data } = await buildingList({ ...params })
total.value = resTotal
tableData.length = 0
tableData.push(...data)
loading.value = false
}
7. 导入导出功能
7.1 导出功能
项目中的 useExport hook 封装导出逻辑:
ts
const { exportData, loading: exportLoading } = useExport({
meta: '/ams/asset-building/list-export-meta',
url: '/ams/asset-building/list-export',
})
// 调用导出
const handleExport = () => {
exportData(getParams())
}
7.2 导入功能
导入通常跳转到专门的导入页面:
ts
const handleImport = () => {
router.push('/asset/management/import-building')
}
8. 完整数据流图
#mermaid-svg-lDVoKKwwPNwdk4n2{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lDVoKKwwPNwdk4n2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lDVoKKwwPNwdk4n2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lDVoKKwwPNwdk4n2 .error-icon{fill:#552222;}#mermaid-svg-lDVoKKwwPNwdk4n2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lDVoKKwwPNwdk4n2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lDVoKKwwPNwdk4n2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lDVoKKwwPNwdk4n2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lDVoKKwwPNwdk4n2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lDVoKKwwPNwdk4n2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lDVoKKwwPNwdk4n2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lDVoKKwwPNwdk4n2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lDVoKKwwPNwdk4n2 .marker.cross{stroke:#333333;}#mermaid-svg-lDVoKKwwPNwdk4n2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lDVoKKwwPNwdk4n2 p{margin:0;}#mermaid-svg-lDVoKKwwPNwdk4n2 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-lDVoKKwwPNwdk4n2 .cluster-label text{fill:#333;}#mermaid-svg-lDVoKKwwPNwdk4n2 .cluster-label span{color:#333;}#mermaid-svg-lDVoKKwwPNwdk4n2 .cluster-label span p{background-color:transparent;}#mermaid-svg-lDVoKKwwPNwdk4n2 .label text,#mermaid-svg-lDVoKKwwPNwdk4n2 span{fill:#333;color:#333;}#mermaid-svg-lDVoKKwwPNwdk4n2 .node rect,#mermaid-svg-lDVoKKwwPNwdk4n2 .node circle,#mermaid-svg-lDVoKKwwPNwdk4n2 .node ellipse,#mermaid-svg-lDVoKKwwPNwdk4n2 .node polygon,#mermaid-svg-lDVoKKwwPNwdk4n2 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-lDVoKKwwPNwdk4n2 .rough-node .label text,#mermaid-svg-lDVoKKwwPNwdk4n2 .node .label text,#mermaid-svg-lDVoKKwwPNwdk4n2 .image-shape .label,#mermaid-svg-lDVoKKwwPNwdk4n2 .icon-shape .label{text-anchor:middle;}#mermaid-svg-lDVoKKwwPNwdk4n2 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-lDVoKKwwPNwdk4n2 .rough-node .label,#mermaid-svg-lDVoKKwwPNwdk4n2 .node .label,#mermaid-svg-lDVoKKwwPNwdk4n2 .image-shape .label,#mermaid-svg-lDVoKKwwPNwdk4n2 .icon-shape .label{text-align:center;}#mermaid-svg-lDVoKKwwPNwdk4n2 .node.clickable{cursor:pointer;}#mermaid-svg-lDVoKKwwPNwdk4n2 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-lDVoKKwwPNwdk4n2 .arrowheadPath{fill:#333333;}#mermaid-svg-lDVoKKwwPNwdk4n2 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-lDVoKKwwPNwdk4n2 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-lDVoKKwwPNwdk4n2 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lDVoKKwwPNwdk4n2 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-lDVoKKwwPNwdk4n2 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lDVoKKwwPNwdk4n2 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-lDVoKKwwPNwdk4n2 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-lDVoKKwwPNwdk4n2 .cluster text{fill:#333;}#mermaid-svg-lDVoKKwwPNwdk4n2 .cluster span{color:#333;}#mermaid-svg-lDVoKKwwPNwdk4n2 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-lDVoKKwwPNwdk4n2 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-lDVoKKwwPNwdk4n2 rect.text{fill:none;stroke-width:0;}#mermaid-svg-lDVoKKwwPNwdk4n2 .icon-shape,#mermaid-svg-lDVoKKwwPNwdk4n2 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lDVoKKwwPNwdk4n2 .icon-shape p,#mermaid-svg-lDVoKKwwPNwdk4n2 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-lDVoKKwwPNwdk4n2 .icon-shape .label rect,#mermaid-svg-lDVoKKwwPNwdk4n2 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lDVoKKwwPNwdk4n2 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-lDVoKKwwPNwdk4n2 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-lDVoKKwwPNwdk4n2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户操作
表单筛选
构建请求参数
vue-request 发起请求
axios 拦截器
添加 token/签名
发送 HTTP 请求
后端接口
返回数据
axios 响应拦截器
解密/错误处理
vue-request 处理响应
更新表格数据
更新分页状态
渲染表格
9. 性能优化建议
9.1 请求优化
- 使用
vue-request的缓存功能减少重复请求 - 防抖处理搜索输入,避免频繁请求
- 轮询场景使用
pollingInterval替代setInterval
9.2 渲染优化
- 大数据量表格使用虚拟滚动(Element Plus 的
virtual-scroll) - 复杂列使用
v-if按需渲染 - 图片懒加载
9.3 状态优化
- 使用
shallowRef/shallowReactive减少响应式开销 - 合理使用
computed缓存计算结果
10. 测试建议
10.1 单元测试
ts
import { describe, it, expect, vi } from 'vitest'
import { useRequest } from 'vue-request'
describe('useRequest', () => {
it('should fetch data successfully', async () => {
const mockAPI = vi.fn().mockResolvedValue({ data: [] })
const { data, runAsync } = useRequest(mockAPI)
await runAsync()
expect(mockAPI).toHaveBeenCalled()
expect(data.value).toEqual({ data: [] })
})
})
10.2 E2E 测试
ts
import { test, expect } from '@playwright/test'
test('table pagination', async ({ page }) => {
await page.goto('/asset/management/building')
// 等待表格加载
await page.waitForSelector('.el-table')
// 测试分页
await page.click('.el-pagination button:has-text("下一页")')
await expect(page.locator('.el-pagination')).toContainText('2')
})
11. 常见问题与解决方案
11.1 表格数据不更新
问题 : 修改数据后表格不刷新
解决 : 确保使用 tableData.length = 0 清空后再 push,或直接赋值 Object.assign(tableData, newData)
11.2 分页重置问题
问题 : 搜索后分页没有重置到第一页
解决 : 搜索时显式设置 formState.pageNum = 1
11.3 表单验证不生效
问题 : 表单提交时验证不触发
解决 : 确保表单组件有正确的 prop 属性,且 rules 正确配置
11.4 导出文件名乱码
问题 : 导出的文件名中文乱码
解决 : 使用 encodeURI 对文件名进行编码
12. 总结与明日预告
12.1 今日总结
- 掌握了
vue-request的核心特性和最佳实践 - 实现了标准化的表格/表单/分页开发范式
- 通过楼栋管理页面串联了完整的数据流
- 了解了导入导出、权限控制的集成方式
12.2 明日预告
明日将讲解高级组件开发与状态管理优化,包括:
- 自定义复杂组件(树形选择器、级联选择器等)
- Pinia 状态管理最佳实践
- 组件通信与事件总线
- 性能监控与错误处理