CRUD 页面的重复劳动该结束了做中后台的同学一定不陌生这个流程建一张表然后写列表页、搜索栏、新建表单、编辑表单、详情页……几乎每个字段都要在前端和后端各写一遍——后端定义字段类型和校验规则前端再对应写一遍表格列、表单控件、搜索条件。Django Admin 的出现缓解了这个问题它能根据 Model 自动生成增删改查页面。但代价也不小服务端模板渲染、前端技术栈老旧、界面定制困难、不支持 SSR。如果能在定义数据结构的同时直接声明这个字段在表格里怎么显示、在表单里用什么控件岂不是一步到位VonaJS 做的就是这件事。二、VonaJS 是什么VonaJS 是一款全栈框架支持在同一个代码库中构建 SSR/SPA/Web 网站/Admin 中后台。它的核心能力有两点DTO 动态推断与生成基于 Zod4 的统一 Schema一份定义同时用于参数校验、OpenAPI 文档生成、Table/Form 渲染、数据序列化与脱敏CRUD 动态渲染根据 DTO 中声明的渲染元数据自动生成列表页、条目页、搜索表单底层基于 Tanstack Table / Tanstack Form / Tanstack Query 的最佳实践技术栈方面后端是 Koa Knex Zod4 Redis前端是 Vue3 Vite8 Tanstack 全家桶UI 层可搭配 Daisyui/Tailwindcss/Quasar/Vuetify。一句话用 DTO 驱动 CRUD 渲染让中后台开发从写页面变成配字段。三、Entity字段渲染的起点VonaJS 的渲染配置从 Entity 开始。每个字段的渲染元数据直接写在字段定义旁边一目了然EntityIEntityOptionsStudent(demoStudent, { openapi: { title: $locale(Student) }, fields: { id: $makeMetadata(ZovaRender.order(1, core)), iid: $makeMetadata(ZovaRender.visible(false)), deleted: $makeMetadata(ZovaRender.visible(false)), createdAt: $makeMetadata( ZovaRender.order(-2, max), ZovaRender.field(basic-date:formFieldDate), ZovaRender.cell(basic-date:date), ), updatedAt: $makeMetadata( ZovaRender.order(-1, max), ZovaRender.field(basic-date:formFieldDate), ZovaRender.cell(basic-date:date), ), }, }) export class EntityStudent extends EntityBase { Api.field( v.title($locale(Name)), v.required(), v.min(2), ZovaRender.order(1), ZovaRender.cell(basic-table:actionView), ) name: string; Api.field( v.title($locale(Description)), v.optional(), ZovaRender.order(2), ZovaRender.field(basic-select:formFieldSelect, { placeholder: Please Select, items: [ { title: Male, value: 1 }, { title: Female, value: 2 }, ], }), ZovaRender.cell(basic-select:select, { items: [ { title: Male, value: 1 }, { title: Female, value: 2 }, ], }), ) description?: string; }逐行看关键的渲染配置配置含义ZovaRender.order(1)字段排在第 1 位ZovaRender.visible(false)隐藏该字段不渲染ZovaRender.cell(basic-table:actionView)表格中渲染为可点击查看的链接ZovaRender.cell(basic-date:date)表格中渲染为日期格式ZovaRender.field(basic-select:formFieldSelect, {...})表单中渲染为下拉选择框并传入选项数据ZovaRender.field(basic-date:formFieldDate)表单中渲染为日期选择器核心思路渲染配置紧跟字段定义改一个字段时校验规则和渲染行为一起调整不用再去前端组件里翻找对应位置。ZovaRender.cell()控制表格列怎么显示ZovaRender.field()控制表单用什么控件。配置格式统一为模块名:组件名并可通过第二个参数传入组件 props比如下拉框的选项列表、class、style 等。四、DTO 组装页面声明式定义页面结构Entity 定义了字段级的渲染元数据DTO 则负责把这些字段组装成完整的页面。一个 DTO 就是一个页面页面结构通过 blocks 声明式定义。1. 列表页DtoIDtoOptionsStudentSelectResItem({ blocks: [ ZovaRender.block(basic-page:blockPage, { blocks: [ ZovaRender.block(basic-page:blockFilter), ZovaRender.block(basic-page:blockToolbarBulk, { actions: [ZovaRender.tableActionBulk(basic-table:actionCreate)], }), ZovaRender.block(basic-page:blockTable), ZovaRender.block(basic-page:blockPager), ], }), ], }) export class DtoStudentSelectResItem extends $Dto.get(() ModelStudent) { Api.field( v.title($locale(Operations)), ZovaRender.order(1, max), ZovaRender.cell(basic-table:actionOperationsRow, { actions: [ ZovaRender.tableActionRow(basic-table:actionUpdate), ZovaRender.tableActionRow(basic-table:actionDelete), ], }), ) _operationsRow?: unknown; }这个列表页由四个 block 组成搜索区 → 批量操作栏 → 数据表格 → 分页器从上到下依次排列。操作栏里放了一个新建按钮表格行末尾自动追加编辑和删除操作列。DTO 继承自$Dto.get(() ModelStudent)这意味着列表的字段直接从 Model进而从 Entity继承不需要重复定义。2. 搜索条件DtoIDtoOptionsStudentSelectReq({ openapi: { filter: { table: demoStudent } }, fields: { name: $makeSchema(v.optional(), z.string()), createdAt: $makeSchema( ZovaRender.field(basic-date:formFieldDateRange), v.filterTransform(a-web:dateRange), v.optional(), z.string(), ), }, }) export class DtoStudentSelectReq extends $Dto.queryPage(EntityStudent, [name, createdAt]) {}搜索条件的 DTO 独立于列表数据。这里name是普通文本搜索createdAt渲染为日期范围选择器formFieldDateRange并通过v.filterTransform自动将前端选择的日期范围转换为后端查询格式。3. 新建/编辑页DtoIDtoOptionsStudentCreate({ blocks: [ ZovaRender.block(basic-pageentry:blockPageEntry, { blocks: [ ZovaRender.block(basic-pageentry:blockForm), ZovaRender.block(basic-pageentry:blockToolbarRow, { actions: [ ZovaRender.formActionRow(basic-form:actionSubmit, { permission: { actionInherit: update, formScene: [create, edit] }, }), ZovaRender.formActionRow(basic-form:actionBack, { permission: { public: true } }), ], }), ], }), ], }) export class DtoStudentCreate extends $Dto.create(() ModelStudent) {}新建页和编辑页结构相同表单区 操作栏提交/返回。$Dto.create和$Dto.update分别继承自 Model自动带上 Entity 中定义的字段渲染配置。区别在于formScene控制提交按钮的权限——创建和编辑时显示查看时隐藏。4. 详情页DtoIDtoOptionsStudentView({ blocks: [ ZovaRender.block(basic-pageentry:blockPageEntry, { blocks: [ ZovaRender.block(basic-pageentry:blockForm), ZovaRender.block(basic-pageentry:blockToolbarRow, { actions: [ ZovaRender.formActionRow(basic-form:actionBack, { permission: { public: true } }), ], }), ], }), ], }) export class DtoStudentView extends $Dto.get(() ModelStudent) {}详情页只比编辑页少了一个提交按钮继承自$Dto.get表单自动为只读模式。总结一下 DTO 的页面组装模式用 blocks 声明页面由哪些区域组成用 actions 声明操作按钮字段渲染则自动继承 Entity 的配置。整个过程不需要写 Vue 组件、不需要拼模板一个 DTO 文件就是一个完整的 CRUD 页面。五、与 Django Admin 对比为什么值得换特性VonaJSDjango Admin后端技术栈NodeJS TypeScriptPython 服务端模板语言前端技术栈Vue3 Vite8 TypeScriptHTML CSS JS渲染机制同构 SSR服务端模板渲染双层页签导航支持不支持界面定制自由定制组件级可控定制成本高需覆盖模板SSR支持含侧边栏、主题等不支持Django Admin 的核心问题是它用服务端模板渲染页面前端技术栈停留在传统 HTML/CSS/JS 时代。想定制一个下拉框的样式、加一个自定义交互就得去覆盖模板文件维护成本随业务复杂度急剧上升。VonaJS 采用前后端分离架构前端是完整的 Vue3 应用渲染配置通过 DTO 声明、组件按需替换定制一个字段控件只需要换一个ZovaRender.field()的组件名。同时Admin 中后台也支持 SSR刷新页面时侧边栏、多语言、主题等不会闪烁跳动。