第三节 基于 Vue 3 的领域模型架构——Schema 驱动的低代码后台实战

第三节 基于 Vue 3 的领域模型架构------Schema 驱动的低代码后台实战(feature/dashboard-subview 分支新增内容总结)

本文承接(《第一节:从零构建一个 Koa 企业级框架------Elpis 架构设计实战》)与(《第二节:前端工程化与签名校验闭环实战》),专门总结提交 183941d feat: 前端部分领域模型架构开发。 引入的领域模型(DSL)协议设计 、model/project 继承体系 、Schema 驱动的工作台运行时 以及搜索栏 / 表格 / 表单三件套的完整实现。


3.1 本节新增内容总览

模块 核心产出 说明
DSL 协议 docs/dashboard-model.md 约定一套 dashboard 配置结构:菜单树 + 四种模块视图 + 字段级 Schema
模型继承 model/index.js + model/{buiness,course}/** 目录扫描 + lodash mergeWith 按 key 合并,实现「模型基类 → 项目实例」继承
模型服务 app/service/model.js DSL 装载与查询,开发态清 require.cache 热加载
DSL 接口 app/controller/project.js + app/router/project.js 模型分组列表、项目配置、商品 REST CRUD、枚举接口(全内存 mock,免 DB)
函数式 schema app/router-schema/project.js + core loader router-schema 支持 (app, router) => {} 直接注册路由
项目列表页 app/pages/project-list/ 按模型分组渲染项目卡片,点击携带 proj_key 进入工作台
工作台 app/pages/dashboard/dashboard.vue DSL 运行时:URL 状态机、菜单树解析、四种视图分发渲染
Schema 三件套 app/pages/widgets/schema-{search-bar,table,form}/ 一份字段 Schema 同时驱动搜索项、表格列、弹窗表单
CRUD 编排 app/pages/dashboard/table-panel.vue eventKey 事件协议 + REST 映射 + 抽屉表单 + 删除确认
布局组件 header-container / sider-container 插槽协议的深色主题布局壳
状态管理 app/pages/store/project.js Pinia 项目列表 store(加载缓存、当前项目、跨页跳转)
测试 test/project.test.js supertest + 签名访问 /api/project/model_list,断言继承后的数据结构

3.2 问题背景:从「每个项目写一套页面」到「配置即页面」

第二节完成后,工程基建(Webpack / boot / 签名 / 中间件)已经就绪,但业务页面仍是「一个项目写一套 Vue 页面」的传统模式。观察电商业务:京东、拼多多、淘宝三个后台的页面结构高度相似又有差异:

  • 都有「商品管理」,字段集合几乎一致(商品 ID / 名称 / 价格 / 库存 / 创建时间);
  • 京东把「订单管理」做成待办页,淘宝把它做成 iframe 外链,拼多多额外有「数据分析」侧边栏;
  • 课程系统(B 站课堂 / 抖音课堂)又是另一套菜单,但复用同样的渲染能力。

若为 5 个项目各写一套页面,会产生大量复制粘贴,且新增一个项目的成本是「写代码 + 测试 + 发版」。本节的解法是引入领域模型 DSL:

javascript 复制代码
传统模式:  需求 → 写 Vue 页面 → 接口对接 → 发版
DSL 模式: 需求 → 写一份 JSON 配置(model + project)→ 服务启动即生效 → 通用运行时渲染

DSL 本质是一份声明式的页面描述协议 ,前端只保留一个与业务无关的「工作台运行时」,业务差异全部沉淀到 model/ 目录的配置文件里。


3.3 DSL 协议设计

协议全文,核心结构如下:

javascript 复制代码
{
  mode: 'dashboard',        // 模板类型,预留多模板扩展
  name: '京东',              // 项目名(页头标题)
  desc: '京东电商系统',
  homePage: '/schema?proj_key=jd&key=product',  // 默认落地模块
  menu: [/* 菜单项(可递归) */]
}
menuType 含义 可填字段
module 叶子模块,点击后渲染一个具体视图 moduleType + 对应 config
group 分组,自身不渲染视图 subMenu: [](结构与 menu 相同,可递归)

3.3.2 叶子模块:四种 moduleType

moduleType 视图形态 配置块 实例
schema 搜索栏 + 数据表格 + CRUD 弹窗 schemaConfig 京东「商品管理」
iframe 内嵌第三方页面 iframeConfig.path 京东「店铺资质」、淘宝「订单管理」
custom 注册的自定义异步组件 customConfig.path 各项目的「待办事项」「复杂视图」
sider 左侧二级菜单 + 右侧内容区 siderConfig.menu(内部模块不可再嵌套 sider) 拼多多「数据分析」、B 站「课程资料」

3.3.3 字段 Schema:一份定义,三处消费

schemaConfig 是 schema 模块的核心,遵循 JSON Schema 的 type/properties 外形,并扩展了两个 UI 维度:

javascript 复制代码
schemaConfig: {
  api: '/api/proj/product/list',   // 数据资源基址(RESTful)
  schema: {
    type: 'object',
    properties: {
      price: {
        type: 'number',            // ① 数据类型:表单控件、提交转换都靠它
        label: '价格',             // ② 中文展示名:列头 / 表单项 / 搜索项共用
        tableOption: { width: 200 },                 // ③ 表格列配置(透传 el-table-column)
        searchOption: {                                // ④ 搜索项配置;不写则该字段不进搜索栏
          comType: 'select',                          //    input/select/dynamicSelect/date-range
          enumList: [{ label: '¥39.9', value: 39.9 }] //    select 的静态枚举
        }
      },
      product_name: {
        searchOption: { comType: 'dynamicSelect', api: '/api/proj/product_enum/list' } // 动态枚举
      },
      create_time: { searchOption: { comType: 'date-range' } }
    }
  },
  tableConfig: {
    headerButtons: [{ label: '新增商品', eventKey: 'showComponent', type: 'primary' }],
    rowButtons: [
      { label: '修改', eventKey: 'showComponent', type: 'warning' },
      { label: '删除', eventKey: 'remove', type: 'danger',
        params: { product_id: 'schema::product_id' } }  // schema:: 行数据引用协议
    ]
  }
}

同一份 properties 被三个组件消费:

消费方 读取字段 产出
schema-search-bar 含 searchOption 的字段 搜索表单控件
schema-table 含 tableOption 且 visiable !== false 的字段 表格列
schema-form 全部可编辑字段(按 type / comType) 新增 / 修改弹窗表单

eventKey 是表格按钮的事件协议 :showComponent = 弹出表单组件(头部按钮无行数据 = 新增,行按钮带行数据 = 修改),remove = 删除;params 的值用 schema::字段名 声明「运行时从当前行取该字段的值」。


3.4 模型继承体系:基类模型 → 项目实例

3.4.1 目录约定

bash 复制代码
model/
├── index.js                  # 装载器:扫描 → 归类 → 继承合并
├── buiness/                  # 模型 key = 目录名 = buiness(电商系统)
│   ├── model.js              # 模型基类:商品/订单/客户三个 custom 模块
│   └── project/
│       ├── jd.js             # 京东项目配置
│       ├── pdd.js            # 拼多多项目配置
│       └── taobao.js         # 淘宝项目配置
└── course/                   # 课程系统
    ├── model.js              # 基类:视频管理 / 用户管理
    └── project/
        ├── bilibili.js       # B站课堂
        └── douyin.js         # 抖音课堂

3.4.2 装载流程(model/index.js)

  1. glob 递归扫描 model/**/*.js(排除 index.js),路径分隔符统一归一化为 /,兼容 Windows;
  2. 按路径中是否含 project 区分类别:{模型key}/model.js 是基类,{模型key}/project/{项目key}.js 是项目实例;
  3. 组装为 [{ model, project: { jd: cfg, pdd: cfg, ... } }],并把目录名作为 key 注入(model.key / project[key].key);
  4. 每个 project 通过 projectExtendModel(model, project) 继承基类。

3.4.3 继承合并的三条语义

合并基于 lodash mergeWith,自定义了数组按 key 对齐的合并器(菜单是数组,默认按下标合并会错位):

情形 语义 实例(实测)
project 与 model 有同 key 菜单项 递归覆盖(修改) 三个电商项目都把基类 product 从 custom 覆盖为 schema;淘宝把 order 覆盖为 iframe;拼多多把 client 覆盖为空 schema 表格
model 有、project 没有 保留(继承) 淘宝未定义 client,最终菜单仍有「客户管理(custom/todo)」;B 站、抖音只写了自己的专属菜单,最终都继承了基类的 video、user
project 有、model 没有 追加(新增) 京东 shop-setting 分组、拼多多 data sider、淘宝 operating sider、B 站「课程资料」sider

实际运行装载器的输出(证据):

less 复制代码
MODEL: buiness | 电商系统
  jd     menus: product:schema | order:custom | client:custom | shop-setting:group[...]
  pdd    menus: product:schema | order:custom | client:schema | data:sider[...]
  taobao menus: product:schema | order:iframe | client:custom | operating:sider[...]
MODEL: course | 课程系统
  bilibili menus: video:custom | user:custom | 课程资料:sider[...]     ← video/user 继承自基类
  douyin   menus: video:custom | user:custom | traffic:sider[...]      ← 同上

注意:对象字段走 lodash 默认深合并、标量直接覆盖;因此 project 里只需写「与基类不同的部分」,菜单文案、配置块都可以只写差异。

3.4.4 开发态热加载

Node 的 require 有模块缓存,改 DSL 后不重启进程不会生效。app/service/model.js 约定:非 production 环境,每次查询 DSL 前清除 model/ 目录的 require.cache 并重新装载:

javascript 复制代码
function clearModelCache() {
  const prefix = MODEL_DIR.split(path.sep).join('/');
  Object.keys(require.cache).forEach(filePath => {
    if (filePath.split(path.sep).join('/').startsWith(prefix)) {
      delete require.cache[filePath];
    }
  });
}

配合 nodemon 监听 .js/.vue/.json,改完 model/**.js 既能触发进程重启,也能在进程未重启时拿到最新配置。


3.5 后端:DSL 查询接口 + 内存 REST 数据源

3.5.1 路由清单(app/router/project.js)

方法 路径 controller 方法 用途
GET /api/proj/model-group-list project.getModelGroupList 项目列表页:按模型分组的项目卡片数据
GET /api/proj/config?proj_key=jd project.getProjectConfig 工作台拉取继承后的完整 DSL
GET /api/proj/product/list project.getProductList 商品列表(旧协议)
GET /api/proj/product/list/list project.restProductList 商品列表(schema-table 实际请求,见 3.7.2)
POST /api/proj/product project.restProductCreate 新增商品
PUT /api/proj/product project.restProductUpdate 修改商品
DELETE /api/proj/product project.restProductDelete 删除商品(body 带 product_id)
GET /api/proj/product_enum/list project.getProductEnumList dynamicSelect 动态枚举({label, value}[])
GET /api/project/model_list project.getModeList 模型结构化原始数据(DTO 裁剪),由函数式 router-schema 挂载

所有数据都是 controller 构造函数里的内存数组(3 条商品 mock),增删改真实生效、进程重启还原,无需 MySQL 即可演示完整 CRUD。

3.5.2 ModelService 的两个查询口径

  • getModelGroupList():返回给项目列表页的摘要 DTO ,每个 project 只暴露 key/name/desc/homePage,不把整份菜单树下发到卡片页;
  • getProjectByKey(projKey):跨模型按项目 key 查找,返回继承后的完整 DSL给工作台。

3.5.3 函数式 router-schema 与内核兼容改造

本节给 router-schema loader 增加了「格式 A」:模块可以直接导出函数,拿到 @koa/router 实例自行注册:

javascript 复制代码
// app/router-schema/project.js
module.exports = (app, router) => {
  const { project: projectController } = app.controllers;
  router.get('/api/project/model_list',
    projectController.methods.getModeList.bind(projectController));
};

elpis-core/loader/router-schema.js 识别 typeof === 'function' 后收集到 app._funcRouterSchemas,在内核装配路由阶段统一调用。对象式 AJV schema(格式 B)继续按原逻辑挂载,两种格式并存。

同时 middleware loader 兼容了三种中间件导出格式,其中工厂函数通过形参个数区分 :module.exports = (app) => async (ctx, next) => {}(length === 1)会被注入 app 后取返回值,默认 priority 500。本节把签名中间件改写为工厂格式,正好走这条通道。


3.6 前端工作台运行时:dashboard.vue

页面入口 entry.dashboard.js 与第二节的 SPA 页不同,调用 boot(App, { enableRouter: false })------工作台不启用 vue-router ,所有导航状态由 URL query 自管,页面本身是一个独立 MPA 入口(自动被 webpack 的 app/pages/**/entry.*.js glob 收集)。

3.6.1 URL 即状态机

工作台把三个状态全部放进 query,刷新 / 复制链接即可恢复现场:

参数 含义 示例
proj_key 当前项目 key(决定加载哪份 DSL) jd
key 当前激活的顶部菜单项 product
key2 sider 模块下激活的二级菜单项 categories

更新状态统一走 window.history.replaceState(不刷新页面、不增加历史栈),解析为手写的 getQueryParams()(decodeURIComponent 逐对拆分)。

3.6.2 启动与菜单激活的兜底链

onMounted 调用 GET /api/proj/config 拿到 DSL 后,按以下优先级确定激活模块:

vbnet 复制代码
URL 的 key 参数  →  homePage 中正则提取的 key(/schema?proj_key=jd&key=product)
                →  菜单第一个模块(group 则取其 subMenu 第一项)
sider 模块再定二级菜单:URL 的 key2 → siderConfig.menu 第一项

projKey 变化时 watch 重新加载配置,支持不刷新页面切换项目。

3.6.3 菜单树解析

  • 顶部菜单用两个 computed 按 menuType 拆成 groupMenuList(渲染为 el-sub-menu)与 moduleMenuList(渲染为 el-menu-item),分组与叶子平级摆放;
  • findMenuItem(menu, key) 递归 查找菜单项,递归路径同时覆盖 subMenu 与 siderConfig.menu;
  • 点击 group 本身不渲染内容,自动激活其第一个子项;点击 sider 模块自动激活第一个二级项;
  • 三级计算属性收敛「当前到底渲染谁」:
javascript 复制代码
currentModule      // 顶部激活项(可能是 sider)
currentSiderModule // sider 内激活项
activeViewModule   // 真正渲染的视图:sider 时取二级项,否则取 currentModule

3.6.4 四种视图的分发渲染

模板按 activeViewModule.moduleType 分发,sider 模式下在 sider-container 的插槽内再做一次同样的分发:

moduleType 渲染内容
schema schema-search-bar + table-panel,搜索条件通过 extra-query 下传,分别持有 mainSchemaPanelRef / siderSchemaPanelRef 两个面板引用
iframe 整张卡片内嵌 <iframe :src="iframeConfig.path">
custom 动态组件 <component :is="activeCustomComponent" />
sider sider-container 接收 siderConfig.menu,右侧插槽内按二级菜单的 moduleType 渲染 schema/iframe/custom

custom 视图通过路径 → 异步组件 的静态映射表解析,异步组件一律使用 Vue 3 的 defineAsyncComponent(项目约定,禁止用 markRaw 包裹组件):

javascript 复制代码
const customComponents = {
  '/todo': defineAsyncComponent(() => import('./todo/todo.vue')),
  '/complex-view': defineAsyncComponent(() => import('./complex-view/complex-view.vue'))
}

以后新增自定义视图,只需写一个 SFC 并在此映射表注册 customConfig.path,工作台本体无需改动。

3.6.5 搜索联动

搜索栏 @search 把参数整体写入响应式对象 searchQueryExtra(reset 时清空),随后在 nextTick 里调用当前可见面板 的 initData()(重置到第 1 页并重新拉数)。切换顶部 / 侧边菜单时也会清空搜索条件,避免条件串模块。


3.7 Schema 渲染三件套与 CRUD 编排

3.7.1 schema-search-bar:字段定义 → 搜索控件

schema-search-bar.vue 从 schema.properties 中筛出含 searchOption 的字段,通过组件注册表 search-item-config.js 把 comType 映射到具体控件:

comType 组件 行为要点
input input.vue 普通文本
select select.vue 渲染静态 enumList
dynamicSelect dynamic-select.vue 下拉面板首次展开(visible-change)才请求 searchOption.api 加载选项,带 loading 与缓存,避免页面初始化时发无谓请求
date-range date-range.vue 搜索时把数组拆为 {字段}_start / {字段}_end 两个 query 参数提交

控件统一使用 v-model + field props 协议,新增搜索控件类型只需在注册表加一行。

3.7.2 schema-table:自请求、自分页、自适应响应

schema-table.vue 是数据表格的内核:

  • 列生成 :含 tableOption 的字段生成列,透传 width / fixed / show-overflow-tooltip;toFixed 控制数字小数位;空值统一显示 -;
  • 自请求 :表格内部直接用 curl 请求 ${api}/list,query 为 { page, size, ...extraQuery },不需要父组件喂数据;
  • 响应兼容 :同时兼容数组协议与 res.items / res.list / res.data / res.data.items 以及 total / pagination.total,对接不同后端不用改组件;
  • 自动刷新 :deep + immediate watch [api, schema, extraQuery],任一变化即 initData()(重置第 1页);loadTableData() 带 100ms 去抖,分页、搜索、CRUD 后的刷新都收敛到它;
  • 行参数解析 :行按钮点击时把 eventOption.params(或根级 params)中的 schema::xxx 模板解析成当前行的字段值后再抛出;
  • expose 协议 :对外暴露 initData / loadTableData / fetchTableData / showLoading / hideLoading / currentPage / total 等,父组件用 ref 编排;
  • 同时保留受控模式(传入 data 数组时退化为纯展示表格)。

双 /list 的由来 :DSL 里 api = '/api/proj/product/list' 表示「商品列表资源」,schema-table 按 REST 约定再拼 /list,实际请求 /api/proj/product/list/list。后端专门提供该路由保持组件协议通用;新增/修改/删除则请求去掉末尾 /list 的资源根 /api/proj/product。

3.7.3 table-panel:CRUD 事件编排

table-panel.vue 包住 schema-table,是「按钮事件 → REST 请求 → UI 反馈」的编排层:

css 复制代码
header-button / row-button / operate 事件
        ↓ 统一进入 operationHandler
   读 btn.eventKey
        ├─ showComponent → 打开 el-drawer + schema-form
        │                   无 row = 新增(POST),有 row = 修改(PUT)
        └─ remove        → ElMessageBox 确认 → DELETE → Notification → loadTableData()

关键实现细节:

  • REST 基址计算 :_apiRest = api.replace(/\/list\/?$/, ''),列表用 api,写操作用资源根;
  • DELETE 入参坑 :curl 封装里 DELETE 的第二参会被当作 query,因此删除特意走 curl.request({ method:'DELETE', url, data }) 把 { product_id } 放进 body,与后端 ctx.request.body.product_id 对齐;
  • 删除值多级兜底 :优先取 rowData[removeKey],其次解析 schema::xxx 模板,最后再回退行数据,保证数字 0 不误删、空字符串能拦截;
  • 表单提交 :先 schemaFormRef.validate(),通过后取 getFormValue() 按 add/edit 发 POST/PUT,成功后关闭抽屉并刷新表格。

3.7.4 schema-form:同一份 Schema 渲染弹窗表单

schema-form.vue 按字段类型映射 Element Plus 控件:string / comType==='input' → el-input,number → el-input-number,select/dynamicSelect → el-select,date-picker/date-range → 日期组件;校验规则由 required / maxLength 自动生成。

表单值管理有两个细节:

  1. blankForm() 按类型给初始值(date-range 给 []、number 给 null),避免响应式空引用问题;
  2. dynamicSelect 的选项拉取用 字段::api 做 key 的 Set 去重,每个字段只请求一次;
  3. resetFields() 语义对齐 Element Plus:重置为「打开抽屉时传入的行数据」而非清空,保证修改场景预填值不丢失。

3.8 项目入口:分组卡片页 + Pinia store

project-list.vue 挂载时并行请求「分组列表接口」与「项目 store 加载」,按模型分组(电商系统 / 课程系统)渲染网格卡片(项目名 + 描述 + 进入入口)。点击卡片:

javascript 复制代码
function enterProject(proj) {
  projectStore.setCurrentProjKey(proj.key)
  const homePage = proj.homePage || '/schema'
  const sep = homePage.includes('?') ? '&' : '?'
  window.location.href = `/view/dashboard${homePage}${sep}proj_key=${proj.key}`
}

京东的实际跳转结果:/view/dashboard/schema?proj_key=jd&key=product&proj_key=jd,命中后端通配路由 /view/dashboard/(.*),由 Koa 渲染同一个 entry.dashboard.tpl,工作台启动后从 query 恢复状态。

store/project.js 提供:

  • loadProjectList():把分组接口的 projects 平铺缓存(有缓存不重复请求);
  • currentProject / currentProjName getter;
  • handleProjectCommand(key):页头项目切换下拉复用同一段跳转逻辑。

布局壳 header-container.vue 通过三个具名插槽(menu / info-content / 默认内容区)与业务解解耦,项目列表页与工作台共用同一页头;sider-container.vue 接收 menu 数组后自行管理二级激活态,并在首项变化时通过 menu-select 事件通知父组件。


3.9 端到端数据流

ini 复制代码
① /view/project-list
   project-list.vue → GET /api/proj/model-group-list
                     ModelService.getModelGroupList()(摘要 DTO)
   渲染模型分组 + 项目卡片
② 点击「京东」卡片
   Pinia 记录 currentProjKey → 跳转 /view/dashboard/schema?proj_key=jd&key=product
③ Koa 通配路由渲染 entry.dashboard.tpl → boot(App, { enableRouter:false })
④ dashboard.vue → GET /api/proj/config?proj_key=jd
                   ModelService.getProjectByKey('jd')(热加载 + 继承后的完整 DSL)
   菜单树解析 → activeViewModule = product(schema 类型)
⑤ schema-table 自主请求 GET /api/proj/product/list/list?page=1&size=50&...
   (curl 自动附加 s-sign/s-st 签名头,见第二节)
⑥ 搜索:schema-search-bar emit search → extraQuery → 面板 initData()(带条件重拉)
⑦ 新增/修改:eventKey=showComponent → 抽屉 schema-form → POST/PUT /api/proj/product → 刷新
   删除:eventKey=remove → schema::product_id 取行值 → 确认框 → DELETE → 刷新
⑧ 切菜单:replaceState 更新 key/key2,activeViewModule 切换,iframe/custom/sider 各走各的渲染分支

3.10 工程配套改动

  • Webpack 多入口收集 :webpack.base.js 的 entry 改为 glob 扫描 app/pages/**/entry.*.js,按文件名自动注册(entry.dashboard / entry.project-list / entry.page),新增页面无需改构建配置;
  • 通配页面路由 :app/router/view.js 新增 /view/dashboard/(.*),所有工作台子路径都返回同一个 dashboard 入口;
  • 首页落地 :index.js 的 homepage 改为 /view/project-list;
  • 接口测试 :test/project.test.js 用 supertest 启动真实 app(autoListen:false),带签名请求 /api/project/model_list,断言每个模型有 key/name、每个项目有 key/name,为继承体系提供回归保护;
  • 别名落地 :$widgets、$common webpack 别名在本节组件中大量使用,跨目录引用不再出现 ../../../../ 长路径。

3.11 关键约定与踩坑记录

  1. 异步组件必须用 defineAsyncComponent :不能用 markRaw 包裹组件对象,否则无法正常加载与渲染(custom 视图映射表统一遵守)。
  2. 避免 <template v-for> 嵌套 <template> :顶部菜单没有在一个 template 里递归,而是先用 computed 按 menuType 拆成两组真实元素再 v-for,规避 Vue 3 模板嵌套的渲染问题。
  3. 数组继承必须按 key 对齐合并 :菜单是数组,绝不能用 lodash 默认的下标合并(基类第 2 项与项目第 2 项语义不同会互相覆盖);自定义 mergeWith 以 key 为同一身份标识递归处理。
  4. DSL 热加载要连入口模块一起清缓存 :只清具体 project 文件不够,model/index.js 本身也在 cache 中,因此按 MODEL_DIR 前缀整体清除,并做 / 分隔符归一化兼容 Windows。
  5. REST 路径双 /list 是协议而非笔误 :DSL 的 api 是「列表资源」语义,组件统一拼 /list;写操作资源根在前端裁掉末尾 /list,后端分别提供路由。
  6. DELETE 的参数位置 :项目 fetch 封装中 DELETE 第二参是 query,需要 body 传参时必须走 curl.request({ method:'DELETE', data }),否则后端 ctx.request.body 取不到 product_id。
  7. schema::字段名 是行数据引用协议 :DSL 不写死值,只声明取值路径;解析端(schema-table、table-panel)都要兼容 params 与 eventOption.params 两种摆放位置,并对 0/'' 区别处理。
  8. URL 是工作台唯一状态源 :proj_key/key/key2 全部进 query 且用 replaceState 更新,保证刷新可恢复、链接可分享;代码内不另存一份会与 URL 漂移的激活态。
  9. dynamicSelect 懒加载:选项在下拉首次展开时才请求并缓存,多个项目共用同一枚举接口时不会随页面初始化打出 N 个请求。
  10. 菜单 key 应使用稳定英文标识 :bilibili.js 中出现了中文 key(课程资料),当前可工作,但作为 URL 参数 / 缓存键 / 日志字段时存在编码与可读性隐患,新增配置应统一使用英文 key。
  11. 迭代留存组件 :dashboard/ 下的 header-view.vue / sider-view.vue / schema-view.vue / iframe-view.vue / SubMenu.vue 是早期分组件方案,最终运行时未引用(dashboard.vue 内聚了视图分发);table-button-config.js 与 row-button.vue 是行按钮扩展点预留,当前行按钮由 schema-table 直接渲染。阅读代码时以「是否被 dashboard.vue 引用」为准,避免误判生效链路。

3.12 本节新增 / 修改关键文件清单

文件路径 新增 / 修改 作用
docs/dashboard-model.md 新增 DSL 协议规范(菜单 / 模块 / 字段 Schema / 按钮事件)
model/index.js 新增 模型装载器:目录扫描、归类、按 key 继承合并
model/buiness/model.js 新增 电商系统基类(商品 / 订单 / 客户)
model/buiness/project/{jd,pdd,taobao}.js 新增 三个电商项目 DSL(schema/iframe/custom/sider 全覆盖)
model/course/model.js 新增 课程系统基类(视频 / 用户)
model/course/project/{bilibili,douyin}.js 新增 两个课程项目 DSL(继承基类 + 专属 sider)
app/service/model.js 新增 模型服务:摘要 DTO、按 key 查 DSL、开发态热加载
app/service/project.js 重构 瘦身为模型装载入口(保留 getList/getModeList 等)
app/controller/project.js 重构 DSL 接口 + 商品内存 REST CRUD + 枚举接口
app/router/project.js 修改 新增 /api/proj/* 系列路由
app/router/view.js 修改 新增 /view/dashboard/(.*) 通配路由
app/router-schema/project.js 修改 函数式 schema 挂载 /api/project/model_list
elpis-core/loader/router-schema.js 修改 支持函数式(格式 A)与对象式(格式 B)并存
elpis-core/loader/middleware.js 修改 兼容直接函数 / 对象 / 工厂函数三种中间件导出
elpis-core/index.js 修改 注册 _funcRouterSchemas、适配中间件 meta
app/pages/dashboard/entry.dashboard.js 新增 工作台 MPA 入口(boot,不启用 router)
app/pages/dashboard/dashboard.vue 新增 DSL 运行时:URL 状态机、菜单解析、四视图分发
app/pages/dashboard/table-panel.vue 新增 CRUD 编排:eventKey、REST、抽屉表单、删除确认
app/pages/dashboard/hook.js 新增 useProjectConfig / useMenuActive / useUrlQuery 组合式函数
app/pages/dashboard/todo/todo.vue 新增 custom 视图:待办事项
app/pages/dashboard/complex-view/complex-view.vue 新增 custom 视图:复杂布局示例
app/pages/project-list/entry.project-list.js 新增 项目列表页 MPA 入口
app/pages/project-list/project-list.vue 新增 模型分组 + 项目卡片 + 进入工作台
app/pages/store/project.js 新增 Pinia 项目 store(缓存 / 当前项目 / 跳转)
app/pages/widgets/header-container/header-container.vue 新增 深色页头布局壳(插槽协议)
app/pages/widgets/sider-container/sider-container.vue 新增 侧边菜单布局壳(含独立调试 boot.js)
app/pages/widgets/schema-search-bar/** 新增 搜索栏 + 控件注册表 + 4 个搜索控件
app/pages/widgets/schema-table/schema-table.vue 新增 自请求表格(列生成 / 分页 / 响应兼容 / 行参数解析)
app/pages/widgets/schema-form/schema-form.vue 新增 字段 Schema → 弹窗表单(校验 / 动态枚举)
test/project.test.js 新增 带签名的模型结构接口测试

3.13 后续可演进方向

  • DSL Schema 化校验:用 AJV 为 dashboard DSL 增加启动期校验(moduleType 与 config 块匹配、key 唯一性、api 合法性),配置写错在启动时即报错;
  • sider 多级递归渲染 :当前模板对 group/sider 各展开一层,findMenuItem 已支持递归,可补一个递归菜单渲染组件彻底打通任意层级;
  • custom 视图注册机制 :把 customComponents 静态映射改为页面级注册表 / glob 自动收集,新增 custom 视图零改动工作台;
  • 字段级权限与按钮级权限 :DSL 中声明 permissions,运行时结合登录态过滤菜单与行按钮;
  • DSL 持久化到 DB:model/project 从文件迁移到数据库 + 后台可视化编辑,热加载改为发布订阅刷新;
  • 真实 REST 后端:商品 CRUD 当前是内存 mock,接入 MySQL(项目已内置 knex 依赖)并补 mocha 单测;
  • 搜索栏日期范围后端过滤 :create_time_start/end 已在前端出参,后端列表接口补上时间区间过滤;
  • 清理早期迭代组件 :确认无引用后移除 header-view/sider-view/schema-view/iframe-view/SubMenu,让「DSL 渲染只有一条链路」;
  • 表格按钮扩展点落地 :启用 table-button-config.js 注册表,支持自定义行按钮组件。

文档归属:Elpis 学习项目第三节总结

对应分支:feature/dashboard-subview

对应提交:183941d feat: 前端部分领域模型架构开发。

最后更新:2026-09-30

相关推荐
热爱2331 小时前
dsh里使用chatgpt plus或pro会员而不是apikey,其实很简单。
前端·openai
linux_cfan1 小时前
videojs v10 源代码系列解读:32 · Reactor 模型:信号驱动的状态机
前端·javascript·音视频
福兮说1 小时前
Base64 遇上中文和 emoji:btoa 报错、解出一串 %E7、URL 里加号变空格,六个坑一次说清
前端·javascript·base64·编码
IMPYLH2 小时前
HTML 的 <title> 元素
前端·javascript·html
开开心心就好2 小时前
视频模糊怎么修复?免费工具支持批量处理
java·前端·人工智能·智能手机·pdf·excel
变与不变8062 小时前
jspost请求详解
前端·javascript
派小心.2 小时前
React StrictMode埋点双执行的验收方法
前端·前端框架
IT_陈寒3 小时前
Python的GIL锁让我把多线程代码全重写了!
前端·人工智能·后端
百度一下吧3 小时前
umi后台管理项目实战:从工程搭建到生产构建
java·前端·javascript