news 2026/9/15 5:40:04

基于Vite5+Vue3+AntDesignVue4的配置化中后台脚手架实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Vite5+Vue3+AntDesignVue4的配置化中后台脚手架实践

简介:基于 Vite5.x + Vue3.x + ant-design-vue4.x 与 TypeScript hooks 的后台管理系统源码包,面向需要搭建中后台前端框架的开发者。项目聚焦 RBAC 权限系统、JSON Schema 动态表单、动态表等常见业务模块,并展示了 Vue3 全家桶(Vuex、Vue Router)及 Composition API 的实际组合方式,可帮助读者快速建立从权限校验到动态渲染的完整链路。压缩包共 33 个文件,以 mjs、json、ts、yaml、md 为主,涵盖 Vite 构建脚本、依赖配置、TS 类型声明、Dockerfile、环境变量拆分及 README 文档,整体仅 147KB,轻量便于逐行研读。源码中实践了 Vue3 新特性,对 Composition API 与 Options API 的区别做了示范性演示,同时收集了 UnoCSS、ESLint、Stylelint 等工程化配置,并给出 openapi 配置示例,便于结合 Swagger 文档与 json2ts 工具自动生成类型;整体来看,这份源码既适合作为学习 Vue3 中后台开发的练手素材,也可以在此基础上快速扩展业务模块,沉淀为团队内部的基础工程。已有 158 人学习下载,适合具备一定 Vue 基础、想进阶工程化和后台权限设计的开发者参考。

1. 这套技术栈为什么值得复制

中后台管理系统做到第三四个页面,大多数团队会意识到:路由守卫里的权限判断、表单的十几个 v-if 分支、表格写死的 columns,才是真正把开发拖慢的地方。标题里这套组合,vite5.x 管构建速度,vue3.x 管组合式复用,ant-design-vue4.x 管组件覆盖度,TypeScript hooks 管类型推导,最后落点在 RBAC 权限、JSON Schema 动态表单、动态表这三件套上。一句话说清它的价值:权限、表单、表格全部从“写页面”变成“写配置”,新增一个带增删改查的页面,核心工作量被压到定义一份菜单权限码、一份字段 schema、一份列配置。适合准备搭中后台脚手架的前端,也适合正在做低代码配置化改造的团队参考。

2. Vite5.x 与 TypeScript hooks 的工程底座

2.1 初始化项目时把版本锁死在 Vite5 与 Vue3.4+

直接用 create-vite 最新版拉模板,可能会生成 vite6 或更高版本的项目骨架,再回头降级反而麻烦。我一般会显式指定版本线:

npm create vite@5 my-admin -- --template vue-ts cd my-admin npm install npm install ant-design-vue@^4.2.0 vue-router@^4.3.0 pinia@^2.1.0 axios

这组命令的效果是:create-vite 走 5.x 版本生成模板,项目依赖里 vite 落在 5.x,vue 落在 3.4+,同时补上 ant-design-vue 4.x、路由、状态管理、请求库。ant-design-vue 4.x 的a-form校验机制是自己实现的一套 rules,和 Vue 生态其他表单库不通用,这也是后面 JSON Schema 动态表单需要单独做一层映射的原因。版本锁定后,把 package.json 里的依赖范围明确出来:

版本线选用理由
vite^5.4.0esbuild 预构建加原生 ESM,冷启动和 HMR 表现稳定,配置心智负担小
vue^3.4.0defineModel、useTemplateRef 等 API 让自定义组件和 hooks 更精简
ant-design-vue^4.2.0表格、表单、弹窗组件覆盖度够,且 Table 的 change 事件签名自带分页和排序信息
typescript~5.5.0泛型、satisfies、模板字符串类型在处理 schema 配置时非常有用
pinia^2.1.0去掉了 mutations 概念,storeToRefs 解构后不丢响应式,权限状态读写比 vuex 少一层模板代码

状态管理这里选 pinia 而不是 vuex,核心原因是权限系统里“用户信息、权限码、菜单树”会被路由守卫、侧边栏、指令三类模块读取,pinia 的组合式 store 写法可以直接复用 TypeScript hooks 的返回类型,不需要额外定义 Getter 类型。安装完成后先跑一次npm run dev确认基础链路易位,再继续改配置。

2.2 vite.config.ts 多环境变量与按需拆包

开发期最闹心的两件事,一个是接口跨域,一个是打包后 vendor 文件巨大。跨域在server.proxy里解决,拆包在build.rollupOptions里配置,这两个都属于项目初始化就要写好的东西:

// vite.config.ts import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { fileURLToPath, URL } from 'node:url' export default defineConfig(({ mode }) => { const isProd = mode === 'production' return { plugins: [vue()], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), }, }, server: { host: '0.0.0.0', port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, // 如果后端接口没有 /api 前缀,这里去掉 rewrite: (path) => path.replace(/^\/api/, ''), }, }, }, build: { sourcemap: !isProd, chunkSizeWarningLimit: 1024, rollupOptions: { output: { manualChunks: { 'antd': ['ant-design-vue'], 'vue-core': ['vue', 'vue-router', 'pinia'], }, }, }, }, } })

server.proxy的作用是让前端代码里统一写/api/xxx,开发时由 Vite 转发到真实后端,避免每个接口都写全路径,也绕开浏览器跨域限制。manualChunks把 ant-design-vue 单独拆成一个 chunk,vue 全家桶拆成另一个,这样 antd 的样式和组件缓存可以独立于业务代码,改业务逻辑时浏览器只需要拉业务包,首屏体积保持在 1MB 以下。.env.production里只放VITE_API_BASEVITE_APP_TITLE这类以VITE_开头的变量,Vite 打包时会把它内联到产物里,import.meta.env.VITE_API_BASE就能读到。打包后如果出现“布局异常”这类问题,第一反应检查base是否设置为相对路径,第二检查路由是否用了createWebHistory而服务器没有做 history 回退。

2.3 useTable 的 hooks 抽象:状态与请求绑在一起

组合式 API 在这个工程里最常见的落地形态,是把“分页、加载、数据列表、查询条件”封装成一个useTablehooks,页面组件里十行代码完成列表初始化:

import { ref, computed } from 'vue' import type { Ref } from 'vue' import type { TableProps } from 'ant-design-vue' import { message } from 'ant-design-vue' export interface PageQuery { page: number pageSize: number [key: string]: unknown } export interface PageResult<T> { list: T[] total: number } export function useTable<T extends Record<string, unknown>>( fetcher: (query: PageQuery) => Promise<PageResult<T>> ) { const loading = ref(false) const list = ref<T[]>([]) as Ref<T[]> const total = ref(0) const query = ref<PageQuery>({ page: 1, pageSize: 10 }) const pagination = computed<TableProps['pagination']>({ get: () => ({ current: query.value.page, pageSize: query.value.pageSize, total: total.value, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条`, }), set: (val) => { if (val?.current) query.value.page = val.current if (val?.pageSize) query.value.pageSize = val.pageSize }, }) async function fetchData() { loading.value = true try { const res = await fetcher(query.value) list.value = res.list total.value = res.total } catch (e) { message.error('列表加载失败') console.error(e) } finally { loading.value = false } } return { loading, list, total, query, pagination, fetchData } }

pagination用 computed 的 get/set 写法,是为了直接绑定到a-tablev-model:pagination上,组件内部触发分页变化时自动回到 set 分支更新 query。调用方拿到fetchData而不是在 hooks 内部自动触发 onMounted,原因很实际:列表页需要先拉字典再查数据,或者复用时想先填默认筛选条件再请求,把触发时机交给页面控制比“自动跑”更灵活。这个 hooks 不绑定 ant-design-vue 的 Table 实例,只绑定分页和数据结构,后面动态表格组件会基于同样的返回结构再包一层。

2.4 让 TypeScript 在 Vue 模板里不飘红的三处配置

vue-tsc 和 Vite 的 esbuild 转译是两套体系,前者做类型检查,后者只负责去类型。项目里最常见的飘红来自三个地方:@别名不识别、import.meta.env没有类型、.vue文件自身类型缺失。这三处一次性配置完,后面写 schema 类型时才不会满屏报错:

{ "compilerOptions": { "strict": true, "baseUrl": ".", "paths": { "@/*": ["src/*"] }, "types": ["vite/client"], "moduleResolution": "bundler", "skipLibCheck": true }, "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"] }

types: ["vite/client"]会带上ImportMetaEnv的接口声明,import.meta.env.VITE_XXX才能推导出string | undefinedmoduleResolution: "bundler"是 Vite 项目的推荐值,它按打包器的模块解析规则找依赖,避免 Node 的 CommonJS 解析方式把 ESM 包类型路径弄错。skipLibCheck: true不是偷懒,ant-design-vue 的 .d.ts 里偶尔会有跟 Vue 3.4 类型定义冲突的边界,跳过第三方声明文件检查能省掉大量无效报错。配置完跑一次npm run build,能过就说明类型链路是通的。

3. RBAC 权限系统:从菜单表到动态路由与按钮指令

3.1 后端菜单接口的数据结构:带权限码的树

RBAC 的核心关系是“用户属于角色,角色拥有权限码,菜单上挂权限码”,前端拿到的不是扁平列表,而是一棵可以递归渲染的菜单树。先约定接口返回结构,前端的所有逻辑都围绕它展开:

export interface MenuVO { id: string parentId: string | null name: string // 路由名称,必须唯一 path: string // 完整路径,如 /system/user component: string // 组件路径字符串,如 system/user/index meta: { title: string icon?: string hidden?: boolean permissions: string[] // 按钮权限码,如 system:user:add } children?: MenuVO[] }

component存字符串而不是直接存组件对象,是因为后端数据库里只能存字符串,前端用import.meta.glob按路径动态加载组件。权限码三段式约定为模块:资源:操作,例如system:user:addsystem:user:delete,每段用冒号分隔,前端判断时按前缀做粗粒度校验,后端做细粒度鉴权。有人问为什么不做“菜单表、按钮表、角色表”三张表,经验是按钮数量一多,维护成本和权限码直接写进 meta 没有本质差别,权限码本身就能表达按钮级权限,建表反而让前端要多一次联表查询。这样一个典型的 RBAC 对应关系是:

层次主体表达方式
用户层登录用户用户 id、token、角色 id 列表
角色层管理员/运营/访客每个角色持有若干权限码集合
资源层菜单、路由、按钮菜单树节点携带权限码数组

前端拿到登录用户信息时,同时返回permissions数组和菜单树,这两个数据放到 pinia 的 auth store 里,后续路由守卫和按钮指令都从这里取。

3.2 动态路由注册:刷新白屏的三种修复思路

动态路由的标准流程是:登录后拉取菜单树,buildRoutes把菜单树递归转换成RouteRecordRaw,再逐个router.addRoute。这里最经典的坑是刷新页面白屏,原因不是路由注册失败,而是addRoute执行后当前导航已经结束了,刷新后首次导航是在菜单加载完成前发生的。先看完整的守卫写法:

import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import { useAuthStore } from '@/stores/auth' const modules = import.meta.glob('../views/**/*.vue') function buildRoutes(menus: MenuVO[]): RouteRecordRaw[] { return menus.map((menu) => { const record: RouteRecordRaw = { path: menu.path, name: menu.name, component: modules[`../views/${menu.component}.vue`], meta: { ...menu.meta, permissions: menu.meta.permissions ?? [], }, children: menu.children ? buildRoutes(menu.children) : undefined, } return record }) } router.beforeEach(async (to) => { const auth = useAuthStore() if (!auth.token) { return { path: '/login', query: { redirect: to.fullPath } } } if (auth.menusLoaded) return true const menus = await api.getMenus() const routes = buildRoutes(menus) routes.forEach((r) => router.addRoute(r)) auth.menusLoaded = true return { ...to, replace: true } })

关键在最后一行return { ...to, replace: true }addRoute后当前导航的匹配记录里还没有这些新路由,直接放行会命中 404,所以守卫返回一份和当前目标相同的路由信息并标记 replace,让 vue-router 按新注册的路由重新解析一遍。常见做法里还有两种补充方案:一是把菜单树存 sessionStorage,刷新后先恢复 store 再跳转,减少一次请求;二是在布局路由下用router.addRoute('Layout', child)注册子页,否则儿童路由的路径拼接可能不符合预期。menusLoaded只放内存即可,刷新后自然清空,守卫重新拉菜单并重新注册,不需要把它持久化。

3.3 按钮级权限:v-permission 与 usePermission 二选一

页面里最常用的是按钮级权限控制,两种做法各有用处。第一种是自定义指令,适合权限码在渲染前就确定的静态按钮:

import type { Directive, DirectiveBinding } from 'vue' import { useAuthStore } from '@/stores/auth' function check(codes: string | string[], all = false): boolean { const auth = useAuthStore() const list = Array.isArray(codes) ? codes : [codes] return all ? list.every((c) => auth.permCodes.includes(c)) : list.some((c) => auth.permCodes.includes(c)) } export const permission: Directive<HTMLElement, string | string[]> = { mounted(el: HTMLElement, binding: DirectiveBinding<string | string[]>) { if (!check(binding.value, binding.arg === 'every')) { el.parentNode?.removeChild(el) } }, }

v-permission的使用方式是<a-button v-permission="'system:user:add'">新增</a-button>,多个权限码传数组。指令在 mounted 阶段移除 DOM,简单直接,但它只执行一次,如果权限是登录后异步加载的,按钮已经渲染再更新权限码不会恢复。第二种做法是回到模板条件渲染:

export function usePermission() { const auth = useAuthStore() const has = (code: string | string[]): boolean => check(code) return { has } }

页面里const { has } = usePermission(),模板用<template v-if="has('system:user:add')">包住按钮。因为has内部读取的是响应式的permCodes,权限码更新时模板会跟着重新渲染,登录态恢复类场景更可靠。两条路线的取舍是:静态按钮用指令更干净,权限受异步状态影响的按钮用usePermission更稳。记住一点,两个都要在 main.ts 里注册好 store,否则指令里访问 pinia 会报 no active pinia。

3.4 权限指令的边界:表格列和菜单树不能靠指令

自定义指令有明确的应用边界。a-table的 columns 是普通 JavaScript 对象,指令无法作用到对象属性上,所以动态表格里的列权限要在列配置里过滤;侧边栏菜单同理,菜单树的 visible 判断要在 store 初始化时用permissions过滤一遍,而不是渲染完再用指令删。另一个边界是 v-if 和 v-permission 同时写在一个元素上时,v-if 的优先级更高,先被移除的节点根本轮不到指令执行,逻辑上就会变成“权限通过 but 不渲染”。实际项目中,我更倾向把按钮封装成AuthButton组件,内部用usePermission决定渲染a-button还是渲染一个禁用态 Tooltip,因为直接删除按钮会让用户不知道为什么功能没了,防误点场景下禁用比删除体验更好。

4. JSON Schema 动态表单:定义字段、生成校验、注册组件

4.1 从标准 JSON Schema 到表单 DSL 的取舍

标准 JSON Schema 核心字段是typepropertiesrequiredenum,它描述的是数据合法性,不管界面怎么渲染。表单场景需要额外的 UI 信息:label 文案、placeholder、栅格宽度、显隐条件,所以工程里常见做法是定义一套扩展版 schema,既保留 JSON 的序列化能力,又能直接被渲染器消费:

export type FieldType = | 'input' | 'textarea' | 'number' | 'select' | 'radio' | 'date' | 'switch' | 'custom' export interface FieldSchema { type: FieldType key: string label: string required?: boolean placeholder?: string options?: { label: string; value: string | number }[] rules?: Record<string, any>[] hidden?: string // 显隐表达式,如 'status === 2' span?: number // 栅格占位,默认 12 }

字段的具体配置和渲染对应关系如下:

schema 字段类型影响范围
typeinput/select/date...决定渲染哪个组件
keystring对应表单 state 的字段名
requiredboolean生成必填校验规则
options数组下拉/单选的数据源
hidden字符串表达式控制当前字段是否渲染

hidden是字符串而不是函数,是因为 schema 要从后端下发或存数据库,函数无法序列化。代价是前端要写一个小解释器来求值,这是配置化表单绕不开的一层。

4.2 渲染器:schema 到 a-form-item 的映射

渲染组件要做三件事:遍历字段、按 type 映射到a-input等组件、用v-model:value绑定到 model 的对应 key。这个组件不做任何业务逻辑,纯粹是 schema 的执行器:

<script setup lang="ts"> import { computed } from 'vue' import { Form, FormItem, Input, Select, DatePicker, Switch } from 'ant-design-vue' const props = defineProps<{ fields: FieldSchema[] model: Record<string, any> }>() function simpleEval(expr: string, model: Record<string, any>): boolean { const match = expr.trim().match(/^(\w+)\s*(===|==|!==|!=|>=|<=|>|<)\s*(.+)$/) if (!match) return true const [, key, op, raw] = match const actual = model[key] const expect = raw.replace(/^['"]|['"]$/g, '') switch (op) { case '===': return actual === expect case '==': return actual == expect case '!==': return actual !== expect case '!=': return actual != expect case '>=': return Number(actual) >= Number(expect) case '<=': return Number(actual) <= Number(expect) default: return true } } const visualFields = computed(() => props.fields.filter((f) => !f.hidden || simpleEval(f.hidden, props.model)) ) </script> <template> <a-form :model="model"> <a-row :gutter="16"> <a-col v-for="field in visualFields" :key="field.key" :span="field.span || 12"> <a-form-item :label="field.label" :name="field.key"> <a-input v-if="field.type === 'input'" v-model:value="model[field.key]" :placeholder="field.placeholder" /> <a-select v-else-if="field.type === 'select'" v-model:value="model[field.key]" :options="field.options" /> <a-date-picker v-else-if="field.type === 'date'" v-model:value="model[field.key]" style="width: 100%" /> <a-switch v-else-if="field.type === 'switch'" v-model:value="model[field.key]" /> <a-input v-else-if="field.type === 'textarea'" v-model:value="model[field.key]" type="textarea" :rows="3" /> </a-form-item> </a-col> </a-row> </a-form> </template>

simpleEval故意只支持单个字段和操作符的简单表达式,不引入new Function,后端配置的字符串不能被当成代码执行。实际业务里的联动大多是“支付方式等于转账时显示收款账户”这种单条件,设计时引导后端同学拼这种最简单的表达式就够了。a-colspan让表单自动排列成栅格,一个字段一行还是半行由配置决定,页面不需要关心。visualFields用 computed 包裹,model 里某个联动字段变化时,关联字段会自动重新计算显隐。

4.3 rules 生成:required 与 trigger 的匹配问题

a-form的校验规则从 schema 的requiredrules字段合并生成,比手写少一半重复代码:

export function toRules(field: FieldSchema): Record<string, any>[] { const rules: Record<string, any>[] = [] if (field.required) { rules.push({ required: true, message: `${field.label}不能为空`, trigger: field.type === 'select' ? 'change' : 'blur', }) } if (field.rules) { rules.push(...field.rules) } return rules }

trigger的类型是最容易踩的坑。输入框用blur,用户离开输入框才提示;下拉框如果也用 blur,键盘操作时会出现“选择选项前先触发失焦校验”的错觉,而且 ant-design-vue 的 Select 在展开面板时 input 会先 blur 一次,错误提示会在选择完成前冒出来,所以 select 和 date 这类组件固定用change触发。toRules返回的数组直接在模板的a-form-item上做:rules绑定,但注意a-form上还要设置:model:rules才能让 formRef.validate() 整体生效,组件的a-form-item上只做字段级声明。这里建议把toRules放在独立工具文件里,因为列配置、抽屉表单、步骤表单都要复用同一套 schema-to-rules 逻辑。

4.4 自定义组件注册表:不要改动 SchemaForm

custom类型是给业务特殊组件留的口子,比如用户选择器、部门树、富文本。SchemaForm 组件内部维护一个组件注册表,通过动态组件渲染:

import type { Component } from 'vue' import { Input, Select, DatePicker, Switch } from 'ant-design-vue' import { UserPicker } from '@/components/UserPicker' import { DeptTree } from '@/components/DeptTree' export const componentMap: Record<string, Component> = { input: Input, select: Select, date: DatePicker, switch: Switch, userPicker: UserPicker, deptTree: DeptTree, }

模板里加一行兜底渲染:

<component :is="componentMap[field.type]" v-else-if="componentMap[field.type]" v-model:value="model[field.key]" />

新增一个自定义字段时,只需要把组件注册进componentMap,schema 的 type 填对应的 key,SchemaForm 的代码完全不用动。这个注册表的另外一种形态是直接在 main.ts 里用app.component全局注册,但显式 map 的好处是类型提示和依赖关系清晰,编辑器能列出所有可注册的字段类型。到这里表单层已经完整:定义 schema、渲染组件、生成规则、扩展组件,四个环节相互独立,任何一环都可以单独替换。

5. 动态表格:列配置、权限过滤与服务端交互

5.1 ColumnSchema 到 a-table columns 的转换

动态表格的思路和 dynamic form 一样,列来源于配置而非硬编码。定义一个最小可用的列 schema:

export interface ColumnSchema { key: string title: string dataIndex?: string width?: number slot?: string // 作用域插槽名,用于自定义单元格 sortable?: boolean // 是否支持服务端排序 permission?: string // 权限码,无权限时整列隐藏 } export interface ActionSchema { label: string code: string permission?: string danger?: boolean hidden?: (record: any) => boolean }

把配置转换成 ant-design-vue 的 columns,需要做两件事:过滤不可见列,把 slot 配置映射成customRender

const visibleColumns = computed(() => { const auth = useAuthStore() return props.columns .filter((col) => !col.permission || auth.permCodes.includes(col.permission)) .map((col) => ({ ...col, customRender: col.slot ? ({ record }: { record: any }) => slots[col.slot!]?.(record) : undefined, })) })

dataIndex支持user.name这种路径式写法时,需要自己做一个按路径取值的小函数,ant-design-vue 的列配置里 dataIndex 的路径解析能力和渲染器的预期不一定完全一致。表格的列配置、数据、分页都由外部传入,这个组件就是“给什么列显示什么列”的纯渲染层,页面里的业务逻辑集中在列 slot 里。

5.2 分页、排序、过滤怎么和后端参数对齐

服务端分页时,a-table 的 change 事件会同时给出分页、过滤、排序三份状态,常见做法是在事件回调里统一转成后端参数:

<script setup lang="ts"> import type { TablePaginationConfig } from 'ant-design-vue' function onTableChange(pagination: TablePaginationConfig, _filters: any, sorter: any) { const params: Record<string, any> = { ...query, page: pagination.current, pageSize: pagination.pageSize, } if (sorter && sorter.columnKey) { params.sortField = sorter.columnKey params.sortOrder = sorter.order === 'ascend' ? 'asc' : 'desc' } emit('change', params) } </script>

传给后端参数时注意大小写。很多后端框架默认用小驼峰pageSize,而 a-table 的pagination对象上是pageSize,对齐一次就不会到处出问题。sorter.columnKey取的是列配置里的 key 而不是 dataIndex,如果列配置里两者不一致,排序字段会传错,建议列 schema 里强制 key 与 dataIndex 相同或者显式指定 sortField 映射。过滤条件_filters的 value 是数组,后端只接受单个值时取filters[field]?.join(',')处理后再拼接。动态表格组件只负责把 change 事件转换成统一参数对象向上 emit,请求逻辑依然交给 useTable 的 fetcher。

5.3 操作列保留插槽:配置化不是万能

操作列(编辑、删除、权限分配)最常出现 Popconfirm、Dropdown、Tooltip 组合,这些交互很难在配置里表达函数,强行配置化会引入 JSX 或函数类型的 fields,结果 schema 就没法从后端下发了。实操上的折中是:普通按钮走配置,复杂交互走插槽。

<a-table :columns="visibleColumns" :data-source="data" :loading="loading"> <template v-if="$slots.opColumn" #opColumn="{ record }"> <slot name="opColumn" :record="record"></slot> </template> </a-table>

列 schema 里给操作列留一个固定的slot: 'opColumn',页面组件的插槽写<template #opColumn="{ record }">,里面自由使用 Popconfirm 和 Dropdown。纯配置模式负责数据列,插槽模式负责操作列,这套边界几轮迭代下来比“全部配置化”稳得多,因为表格列本身就是数据展示,操作列本质是组件组合。

5.4 表单和表格配置同源的页面组合

动态表单和动态表格分开用只是第一步,组合起来效果才明显。某个管理页面的典型组合方式是:后端返回的页面配置里同时包含 columns 和 form 配置,列表页用 TableSchema 渲染表格,点新增时把 form 配置塞进 Drawer 里的 SchemaForm:

export interface PageConfig { columns: ColumnSchema[] form: FieldSchema[] permissions?: { query: string[]; create: string[]; update: string[]; delete: string[] } } export function usePageConfig(config: PageConfig) { const { columns, form } = config return { columns, form } }

最初从零写一个页面要两个文件加三处字段定义,现在变成一份配置和两个通用组件。配置化节约的不只是开发量,最重要的是产品调整字段顺序、增删字段时,改配置就能上线,前端代码不重新发版。动态表单和动态表格的分工边界就一句话:表单管录入校验,表格管查询展示,配置里各自独立,页面里通过同一个 config 对象拼装。

6. 收尾技巧:给 schema 补上完整的类型推导

配置化的最大隐患是 key、type 这些字符串拼错,运行时才发现。Vue 3.3 开始 SFC 支持泛型组件,TypeScript 的satisfies可以校验对象数组符合 schema 定义但不改变值的类型,这两个特性组合起来,让动态 schema 在编码阶段就具备完整提示。

先在 FieldSchema 上加一个泛型参数,让 key 限定在具体业务模型的字段集里:

export type FieldSchema<K extends string = string> = Omit<BaseFieldSchema, 'key'> & { key: K }

页面里定义业务表单配置时用satisfies校验:

export interface UserForm { name: string role: string status: number } export const userFormSchema = [ { type: 'input', key: 'name', label: '姓名', required: true }, { type: 'select', key: 'role', label: '角色', options: [], trigger: 'change' }, ] satisfies FieldSchema<keyof UserForm>[]

如上配置存在一个显著收益:把 key 错写成userName时,编辑器会给出字面量类型上“''userName'' 不能赋值给 ''keyof UserForm''”的错误提示,不再等到运行时才在表单字段上发现绑定失效。SchemaForm 组件对应调整为:

<script setup lang="ts" generic="T extends Record<string, any>"> defineProps<{ fields: FieldSchema<keyof T>[] model: T }>() </script>

模板里使用 SchemaForm 时会自动把model的类型传进去,fields里每个字段的 key 都被约束在 T 的属性里。动态表格的 ColumnSchema 也按同样方式约束 dataIndex。再补上指令的类型声明,让模板里的v-permission也有提示:

declare module 'vue' { export interface ComponentCustomDirectives { permission: { value: string | string[] } } } export {}

这段代码放到src/types/directives.d.ts,vue-tsc 就会在模板里校验 v-permission 传入的参数类型。最后提醒一点:TypeScript 版本最好整体对齐到 5.x 的同一小版本线,常见类型推导失效都源于项目根目录和编辑器的 TS 版本不一致。这套配置跑完,从字段 schema 到列 schema 再到权限指令,配置化代码的编辑体验就接近强类型业务代码了。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 5:39:55

Linux内核MDIO Clause 45驱动开发实战指南

简介&#xff1a;本资源是一份面向嵌入式Linux网络驱动开发者的MDIO接口核心代码学习包&#xff0c;聚焦IEEE 802.3 Clause 45标准下的PHY设备管理机制&#xff0c;适用于需深入理解以太网物理层通信、编写或调试MAC-PHY交互逻辑的中高级开发者。压缩包共含2个关键文件&#xf…

作者头像 李华
网站建设 2026/9/15 5:39:54

工业AI技术趋势与落地实践分析

1. 工业AI赛道现状与核心价值工业AI正在重塑全球制造业的竞争格局。根据麦肯锡最新报告&#xff0c;到2025年工业AI市场规模预计突破2000亿美元&#xff0c;其中预测性维护、质量检测和流程优化三大场景占据60%以上应用份额。这个领域的技术迭代速度远超传统工业软件&#xff0…

作者头像 李华
网站建设 2026/9/15 5:39:45

React Native与鸿蒙生态的跨平台会员中心开发实践

1. 项目背景与核心价值会员体系作为电商平台的核心功能模块&#xff0c;直接影响用户留存率和复购率。传统开发模式下&#xff0c;Android和iOS双端需要分别实现会员中心功能&#xff0c;开发成本高且维护困难。而基于React Native的跨平台方案&#xff0c;配合鸿蒙生态的扩展能…

作者头像 李华
网站建设 2026/9/15 5:39:14

大模型工程师转型指南:核心技术与高薪岗位解析

1. 大模型岗位市场现状与薪资解析2023年被称为"大模型元年"&#xff0c;全球科技巨头和初创企业纷纷布局大语言模型领域。据行业薪酬报告显示&#xff0c;具备大模型相关技能的中高级人才年薪普遍突破50万元人民币&#xff0c;部分头部企业开出的薪资包甚至达到百万级…

作者头像 李华
网站建设 2026/9/15 5:39:10

微信小程序仿Apple Music播放器全解析:音频内核与真机调试

简介&#xff1a;一份仿Apple Music界面的微信小程序音乐播放器源码&#xff0c;面向想入门小程序开发、或希望自建音乐类应用的开发者。项目完整实现了我的音乐、为你推荐、浏览、广播、搜索、播放列表、专辑列表、演唱者列表等典型功能模块&#xff0c;可从中学习小程序目录结…

作者头像 李华
网站建设 2026/9/15 5:38:48

Python网络编程实战:Socket套接字+TCP开发+多进程并发服务端

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华