全栈项目总结

42 阅读6分钟

项目整体架构设计

image.png

elpis-core 内核构建

image.png

elpis-core 是一个基于 Koa 的轻量级框架核心。它是整个项目的基础架构和启动引擎。

  1. 应用启动与初始化

    • 提供了 start 方法作为应用的入口点
    • 创建 Koa 实例并配置基本环境
    • 设置应用的基础路径和业务文件路径
    • 启动 HTTP 服务器并监听指定端口
  2. 模块化加载系统,通过一系列 loader 加载不同类型的模块

    • configLoader : 加载应用配置
    • serviceLoader : 加载服务层组件
    • controllerLoader : 加载控制器
    • routerSchemaLoader : 加载路由校验
    • middlewareLoader : 加载中间件
    • extendLoader : 加载扩展功能
    • routerLoader : 加载路由定义
  3. 框架与业务代码分离

    • 实际业务逻辑位于 app 目录

它采用约定优于配置的方式,通过对目录的约定来规范项目结构,减少协作和沟通成本。

  • config/ 中存放不同环境下的配置文件
  • app/service/ 中存放所有服务层文件
  • app/controller/ 中存放所有控制器
  • app/router-schema/ 中存放所有路由接口的校验
  • app/middleware/ 中存放自定义中间件
  • app/extend/ 中存放所有扩展功能文件
  • app/router/ 中存放所有路由接口

开发者可以专注于在 app 目录中编写业务逻辑,而不必关心底层架构的实现细节。

loader 的加载顺序

config -> service -> controller -> router-schema -> middleware -> extend -> router

  • controller 依赖于 service 和 config
  • router 依赖于 router-schema, controller 和 middleware
  • router-schema需要在middleware前加载(API 检验需要)

注意:

  • middleware 中执行返回的是异步函数,而 service 和 controller 中执行返回的是class类

  • koa实例中具有middleware属性。它是一个数组,专门用来存储通过 app.use()方法注册的所有中间件函数

    • 自己手动挂载 middleware 需要变成 app.middlewares
  • middlewareLoader只是加载了自定义的中间件,为了使用生态中的中间件,还需要加载 app/middleware.js文件

模板页面的渲染能力

Nunjucks 引入中间件 koa-nunjucks-2

  • ctx 会增加一个 render 方法

采用 tpl 而不用 html 的原因

  • 有动态数据,而且动态数据是由服务端注入进去的
  • html 往往是静态数据。直接返回 html 无法利用框架的数据绑定能力

Nunjucks模板

<!--  app/controller/view.js
async renderPage(ctx) {
  await ctx.render(`output/entry.${ctx.params.page}`, {
    name: app.options?.name,
    env: app.$env.get(),
    options: JSON.stringify(app.options),
  })
}
-->

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>{{ name }}</title>
  </head>
  <body>
    <h1 style="color: red">Page1</h1>
  </body>

  <script type="text/javascript">
    window.APP_CONFIG = {
      env: '{{env}}',
      options: JSON.parse('{{ options | safe }}'),
    }
  </script>
</html>

  • 使用 {{ ... | safe }}时,Nunjucks 不会对变量内容进行 HTML 转义。
  • 对于简单字符串,直接加引号。 '{{env}}'

前端工程化搭建

image.png

  • 通过自动化构建工具(如Webpack、Vite)处理编译、打包等重复劳动,让开发者能专注于业务逻辑。

  • 通过模块化(将代码拆分为独立功能单元)和组件化(构建可复用的UI部件)来组织代码,使得代码结构清晰、易于复用和维护。同时,利用ESLint、Prettier等工具强制统一代码风格,并结合自动化测试,确保代码的健壮性和可读性。

  • 通过代码分割、懒加载、Tree Shaking、压缩资源等优化手段,显著减少资源体积,提升应用加载速度和运行时性能。

多页面的构建

为每个页面配置独立的入口和应用实例 使用 glob 工具动态扫描约定目录 app/pages/**/entry.*.js,为每个找到的入口文件生成一个配置项。通过 webpack 的 entry 和 HtmlWebpackPlugin,为每个入口生成对应的模板(.tpl)文件并自动注入对应的 chunks。这使得增加新页面只需添加符合约定的文件,无需修改构建配置,实现了“约定大于配置”。

// app/webpack/config/webpack.base.js
/**
 * 动态构造 entry 入口配置和最终渲染的页面模板 HtmlWebpackPlugin
 * entry: {
 *   'entry.xxx': './app/pages/entry.xxx.js',
 * }
 *
 * new HtmlWebpackPlugin({
 *   template: path.resolve(__dirname, '../../view/entry.tpl'),
 *   filename: path.resolve(process.cwd(), 'app/public/dist', 'entry.xxx.tpl')
 *   chunks: ['entry.xxx'],
 * })
 */
const handleFilePath = (entryFilePath, entry = {}, HtmlWebpackPluginList = []) => {
  const entryFileName = path.basename(entryFilePath, '.js')
  entry[entryFileName] = entryFilePath

  HtmlWebpackPluginList.push(
    new HtmlWebpackPlugin({
      // 指定要使用的模板文件
      template: path.resolve(__dirname, '../../view/entry.tpl'),
      // 产物(最终模板)输出路径
      filename: path.resolve(process.cwd(), 'app/public/dist', `${entryFileName}.tpl`),
      // 要注入的代码块, 对应 entry 中的 key
      chunks: [entryFileName],
    })
  )
}

const elpisEntry = {}
const elpisHtmlWebpackPluginList = []
// 获取 elpis/app/pages 目录下的所有入口文件
const elpisEntryFileList = glob.sync(path.resolve(__dirname, '../../pages/**/entry.*.js'))
elpisEntryFileList.forEach(entryFilePath => {
  handleFilePath(entryFilePath, elpisEntry, elpisHtmlWebpackPluginList)
})

const businessEntry = {}
const businessHtmlWebpackPluginList = []
// 获取业务 app/pages 目录下的所有入口文件
const businessEntryFileList = glob.sync(path.resolve(process.cwd(), 'app/pages/**/entry.*.js'))
businessEntryFileList.forEach(entryFilePath => {
  handleFilePath(entryFilePath, businessEntry, businessHtmlWebpackPluginList)
})
  • Koa 挂载静态目录 ,模板引擎(Nunjucks)负责渲染 .tpl 模板文件, controller 选择注入的入口对应模板,形成完整页面。

开发环境的热更新

修改代码并保存后,几乎能立即在浏览器中看到变化,无需手动刷新页面

关键步骤:devServer、source-map、热更新、 内存系统。

image.png

领域模型的架构建设-DSL

DSL全称为Domain-Specific Language (领域特定语言)

DSL 的主要价值在于简化问题的描述和解决方式。设计 DSL 的核心是要体现出意图,而不仅仅是实现功能。通过 DSL,程序员可以将复杂的实现细节隐藏在简洁的语法之下,让使用者专注于表达需求,而非实现细节。

核心在于数据驱动

dashboard

以dashboard为例

image.png

  • DSL都只需要描述数据以及数据相关的配置项,而不用去在意这些配置应该如何的实现。

一份DSL模板-> 不同模型(model) -> 不同项目(project)配合解析引擎落地为具体的一个产品

  • 不同项目属于同一个模型(继承实现)

  • 模型配置描述80%的共同点,项目配置20%的定制化需求

    • 图中绿色部分均为可拓展部分(可定制化部分)

image.png

配置项可用以下的描述来表示

module.exports = {
  mode: 'dashboard', // 模板类型描述, 不同模板类型对应不一样的数据结构

  name: '', // 模板名称
  desc: '', // 模板描述
  icon: '', // icon
  homePage: '', // 首页(项目配置)

  // 头部菜单
  menu: [
    // menuItem[]
    {
      key: '', // 菜单唯一描述,
      name: '', // 菜单名称
      menuType: '', // 菜单类型, 枚举值: 'group' | 'module'

      // 1. 当 menuType == group 时, 可填
      subMenu: [], // menuItem[],

      // 2. 当 menuType == module 时
      moduleType: '', // 模块类型, 枚举值: 'schema' | 'iframe' | 'custom' | 'sider'

      // 2.1 当 moduleType == schema 时
      schemaConfig: {
        api: '', // 数据源 API (遵循 RESTFUL 规范)
        schema: {
          // 模块数据结构
          type: 'object',
          properties: {
            prop: {
              ...schema, // 标准 schema 结构
              type: '', // 字段类型
              label: '', // 字段中文名
              // 字段在 table 中的相关配置
              tableOption: {
                ...elTableColumnConfig, // 标准 el-table-column 配置
                toFixed: 0, // 保留几位小数点
                visible: true, // 控制该字段是否在表格展示, 默认 true(false则不在表格展示)
              },
              // 字段在 search-bar 中的相关配置
              searchOption: {
                ...elComponentConfig, // 标准 el-component 配置
                componentType: '', // 配置组件类型, 枚举值: 'input' | 'select' | 'dynamic-select' | 'date-range'
                default: '', // 默认值

                // componentType === 'select' 时, 可填
                enumList: [{ label: '', value: '' }], // 下拉框选项

                // componentType === 'dynamic-select' 时, 可填
                api: '',
              },
              // 字段在不同动态组件中的相关配置, 前缀对应 componentConfig 中的键值
              // 例如: componentOption.createFormOption, 这里就是 createFormOption
              // 字段在 create-form 中的相关配置
              createFormOption: {
                ...elComponentConfig, // 标准 el-component 配置
                componentType: '', // 配置组件类型, 枚举值: 'input' | 'input-number' | 'select'
                visible: true, // 是否展示, 默认 true(false则不在表格展示)
                disabled: false, // 是否禁用, 默认 false
                default: '', // 默认值

                // componentType === 'select' 时, 可填
                enumList: [{ label: '', value: '' }], // 下拉框选项
              },
              // 字段在 edit-form 中的相关配置
              editFormOption: {
                ...elComponentConfig, // 标准 el-component 配置
                componentType: '', // 配置组件类型, 枚举值: 'input' | 'input-number' | 'select'
                visible: true, // 是否展示, 默认 true(false则不在表格展示)
                disabled: false, // 是否禁用, 默认 false
                default: '', // 默认值

                // componentType === 'select' 时, 可填
                enumList: [{ label: '', value: '' }], // 下拉框选项
              },
              // 字段在 detail-panel 中的相关配置
              detailPanelOption: {
                ...elComponentConfig, // 标准 el-component 配置
              },
            },
          },
          // 哪些字段必填
          required: [],
        },
        // table 相关配置
        tableConfig: {
          headerButtons: [
            {
              ...elButtonConfig, // 标准 el-button 配置
              label: '', // 按钮名称
              eventKey: '', // 按钮事件名称
              // 按钮具体配置
              eventOption: {
                // eventKey === 'showComponent'
                componentName: '', // 组件名称
              },
            },
          ],
          rowButtons: [
            {
              ...elButtonConfig, // 标准 el-button 配置
              label: '', // 按钮名称
              eventKey: '', // 按钮事件名称
              // 按钮具体配置
              eventOption: {
                // eventKey === 'showComponent'
                componentName: '', // 组件名称

                // eventKey === 'remove'
                params: {
                  // paramKey 为参数的键值,
                  // rowValueKey 为参数值(格式为 schema::tableKey 时, 到 table 中找相应字段)
                  paramKey: rowValueKey,
                },
              },
            },
          ],
        },
        // search-bar 相关配置
        searchConfig: {},
        // 动态组件相关配置
        componentConfig: {
          // create-form 相关配置
          createForm: {
            title: '', //表单标题
            saveButtonText: '', // 保存按钮文案
          },
          // edit-form 相关配置
          editForm: {
            mainKey: '', // 表单主键, 唯一标识要修改的数据对象
            title: '', //表单标题
            saveButtonText: '', // 保存按钮文案
          },
          // detail-panel 相关配置
          detailPanel: {
            mainKey: '', // 表单主键, 唯一标识要修改的数据对象
            title: '', //表单标题
          },
        },
      },

      // 2.2 当 moduleType == iframe 时
      iframeConfig: {
        path: '', // iframe 路径
      },

      // 2.3 当 moduleType == custom 时
      customConfig: {
        path: '', // 自定义路由路径
      },

      // 2.4 当 moduleType == sider 时
      siderConfig: {
        menu: [], // menuItem[], 这里 menuItem 的 moduleType 只能是 'schema' | 'iframe' | 'custom'
      },
    },
  ],
}

  • 通过解析 menu,即可渲染出对应的菜单,而每个 menuItem 的数据对应其菜单具体要渲染的页面内容。

以 menuItem 里面的 moduleType == schema 为例(即schema-view)

  • schemConfig.schema.properties 里面包括我们要渲染的每个数据。
  • 通过 tableOption 来控制这个数据在表格中的表现,通过 schemaConfig.tableConfig 来控制整个表格在页面的表现。
  • 通过 searchOption 来控制这个数据在表格中的表现,通过 schemaConfig.searchConfig 来控制整个搜索栏在页面的表现。

通过这样的方式完成了用一份源数据在不同的块进行渲染的目标。

  • 并不局限于给出的 table 和 search,还可以进行额外的拓展(form, dialog, drawer等等)

通过领域模型实现项目配置(子类)

// app/model/index.js
const path = require('path')
const glob = require('glob')
const _ = require('lodash')
const { sep } = path

const projExtendModel = (model, proj) => {
  // 从左到右的顺序依次合并
  return _.mergeWith({}, model, proj, (modelValue, projValue) => {
    // 对数组进行合并,其他属性默认替换
    if (Array.isArray(modelValue) && Array.isArray(projValue)) {
      let res = []

      const moduleTypes = ['schema', 'iframe', 'custom', 'sider']

      for (let i = 0; i < modelValue.length; ++i) {
        modelItem = modelValue[i]
        let projItem = projValue.find(item => item.key === modelItem.key)
        if (projItem) {
          // 1. model 有, proj 有 => 修改
          let mergedItem = projExtendModel(modelItem, projItem)
          // 检查 moduleType 是否修改
          if (modelItem.moduleType && projItem.moduleType && modelItem.moduleType !== projItem.moduleType) {
            // moduleType 修改了,只保留相对应的 Config 属性 `${moduleType}Config`
            const newModuleType = projItem.moduleType
            const configKey = `${newModuleType}Config`
            // 移除其他 moduleType 的 Config 属性
            moduleTypes.forEach(type => {
              const oldConfigKey = `${type}Config`
              if (oldConfigKey !== configKey && mergedItem[oldConfigKey]) {
                delete mergedItem[oldConfigKey]
              }
            })
          }
          res.push(mergedItem)
        } else {
          // 2. model 有, proj 没有 => 保留
          res.push(modelItem)
        }
      }

      // 3.model 没有, proj 有 => 新增
      for (let i = 0; i < projValue.length; ++i) {
        projItem = projValue[i]
        let modelItem = modelValue.find(item => item.key === projItem.key)
        if (!modelItem) res.push(projItem)
      }

      return res
    }
  })
}

/**
 * 解析 model 配置,并返回合并后的数据结构
 *   modelItem: {
 *     model: ${model, modelKey},
 *     project: {
 *       proj1: ${proj1, projKey1, modelKey},
 *       proj2: ${proj2, projKey2, modelKey},
 *      },
 *   },
 *
 * @param {object} app
 * @returns {modelItem[]} modelList
 */
module.exports = app => {
  const modelList = []

  // 遍历当前文件夹, 构造模型数据结构
  modelPath = path.resolve(process.cwd(), 'model')

  const fileList = glob.sync(path.resolve(modelPath, `**/*.js`))
  fileList.forEach(file => {
    // ${modelName}/project/${projectName}.js
    // ${modelName}/model.js
    relativePath = path.relative(modelPath, file)
    if (relativePath == 'index.js') return

    const { dir, name: fileName } = path.parse(relativePath)

    const modelKey = dir.split(sep)[0] // ${modelName}
    let modelItem = modelList.find(item => item.model?.key === modelKey)
    // 不存在则创建 modelItem 并添加到 modelList
    if (!modelItem) {
      modelItem = {}
      modelList.push(modelItem)
    }

    // 区分配置类型(model/project)
    const configType = fileName === 'model' ? 'model' : 'project'
    if (configType === 'model') {
      modelItem.model = require(path.resolve(file)) // 在 modelItem 上注入 model 对象
      modelItem.model.key = modelKey // 注入 modelKey
    }

    if (configType === 'project') {
      const projKey = fileName // ${projectName}

      modelItem.project = modelItem.project || {}
      modelItem.project[projKey] = require(path.resolve(file)) // 在 modelItem.project 上注入 proj 对象
      modelItem.project[projKey].key = projKey // 注入 projKey
      modelItem.project[projKey].modelKey = modelKey // 注入 modelKey
    }
  })

  // 将 modelItem 上的 proj 和 model合并, 即 proj 继承自 model
  modelList.forEach(item => {
    const { model, project } = item
    for (const proj in project) {
      project[proj] = projExtendModel(model, project[proj])
    }
  })

  return modelList
}

对schema配置进行解析

import { nextTick, ref, watch } from 'vue'
import { useRoute } from 'vue-router'

import { useMenuStore } from '$elpisStore/menu'

// 构建 schema 方法, 提取 {optionName}Option
const buildDtoSchema = (_schema, optionName) => {
  if (!_schema.properties) return

  const dtoSchema = {
    type: 'object',
    properties: {},
  }

  // 处理 required 字段
  const required = new Set(_schema.required || [])

  for (const prop in _schema.properties) {
    const schemaProp = _schema.properties[prop]
    if (schemaProp[`${optionName}Option`]) {
      let dtoProp = {}
      for (const key in schemaProp) {
        if (key.endsWith('Option')) continue
        dtoProp[key] = schemaProp[key]
      }
      dtoProp = Object.assign({}, dtoProp, { option: schemaProp[`${optionName}Option`] })

      if (required.has(prop)) dtoProp.option.required = true

      dtoSchema.properties[prop] = dtoProp
    }
  }

  return dtoSchema
}

export const useSchema = () => {
  const route = useRoute()
  const menuStore = useMenuStore()

  const api = ref('')
  const tableSchema = ref({})
  const tableConfig = ref()
  const searchSchema = ref({})
  const searchConfig = ref()
  const components = ref({})

  // 构造 schemaConfig 相关配置, 给 schema-view 解析
  const buildData = () => {
    // 先根据 sider_key, 再根据 key
    const { key, sider_key } = route.query

    const menuItem = menuStore.findMenuItem({
      key: 'key',
      value: sider_key ?? key,
    })

    if (menuItem && menuItem.schemaConfig) {
      const { schemaConfig } = menuItem
      const schema = JSON.parse(JSON.stringify(schemaConfig.schema))

      api.value = schemaConfig.api ?? ''

      // 先清空再赋值
      tableSchema.value = {}
      tableConfig.value = undefined
      searchSchema.value = {}
      searchConfig.value = undefined
      components.value = {}
      // nextTick 确保清空操作完成并等待一个 DOM 更新周期后, 再执行新数据的赋值
      nextTick(() => {
        // 构造 tableSchema, tableConfig
        tableSchema.value = buildDtoSchema(schema, 'table')
        tableConfig.value = schemaConfig.tableConfig
        // 构造 searchSchema, searchConfig
        const dtoSearchSchema = buildDtoSchema(schema, 'search')
        for (const prop in dtoSearchSchema.properties) {
          // 如果路由中存在该字段, 则设置默认值
          if (route.query[prop] !== undefined) dtoSearchSchema.properties[prop].option.default = route.query[prop]
        }
        searchSchema.value = dtoSearchSchema
        searchConfig.value = schemaConfig.searchConfig

        // 构造 components = { componentKey: { schema: {}, config: {} } }
        const { componentConfig } = schemaConfig
        const componentKeys = Object.keys(componentConfig)
        if (componentConfig && componentKeys.length > 0) {
          components.value = componentKeys.reduce((dtoComponents, componentKey) => {
            return {
              ...dtoComponents,
              [componentKey]: {
                schema: buildDtoSchema(schema, componentKey),
                config: componentConfig[componentKey],
              },
            }
          }, {})
        }
      })
    }
  }

  buildData()
  watch(
    [() => route.query.key, () => route.query.sider_key, () => menuStore.menuList],
    () => {
      buildData()
    },
    { deep: true }
  )

  return { api, tableConfig, tableSchema, searchConfig, searchSchema, components }
}

  • config 是针对整个组件的配置, 而 option 是针对字段在组件中的配置(hook 中的 schema),两者需要区分。

npm包的抽离

自定义SSR页面的扩展

在 webpack.base.js 中修改 entry 和 HtmlWebpackPlugin 来获取全部的入口。

webpack里面的loader会在调用方里面的根目录开始找(即在 elpis-demo 的 node_modules)

  • 如果用自己 node_modules 的目录,改为 require.resolve()

    • 如果找不到包 用require.resolve()
  • 当这个 webpack 配置作为 npm 包被其他项目引用时,loader 的解析路径会基于 elpis 包本身,而不是调用方的 node_modules。

elpis-demo/app/pages 目录下写入口文件 entry.xxx.js 和对应组件

自定义 view 页面 dashboard/cutom-view

  1. elpis-demo/app/pages/dashboard/xxx 写页面组件
  2. elpis-demo/app/pages/dashboard/router.js 中进行配置
module.exports = ({routes, siderRoutes}) => {}

当用户没配置 dashboard/router.js

  • try-catch无法解决

    • webpack是静态构建,并不是运行时构建,所以会直接报错
try{
  businessDashboardRouterConfig = require('$businessDashboardRouterConfig')
}catch (e) {}
  • import的方式无法解决 依旧报错
(async () => {
  businessDashboardRouterConfig = await import('$businessDashboardRouterConfig')
})()

解决方案

  1. 创建空文件让 webpack 别名指向它作为 fallback

    1.       alias: (() => {
              const aliasMap = {}
              const blankModulePath = path.resolve(__dirname, '../libs/blankModule.js')
      
              const businessDashboardRouterConfigPath = path.resolve(process.cwd(), 'app/pages/dashboard/router.js')
              aliasMap['$businessDashboardRouterConfig'] = fs.existsSync(businessDashboardRouterConfigPath)
                ? businessDashboardRouterConfigPath
                : blankModulePath
      
              return {
                ...aliasMap,
              }
            })(),
      
    2. 简单、但是需要额外维护文件
  2. 编译时常量 + if 判断

    1.       new webpack.DefinePlugin({
              __HAS_BUSINESS_DASHBOARD_ROUTER__: fs.existsSync() // true/false
            }),
      
          if(__HAS_BUSINESS_DASHBOARD_ROUTER__) {...}
      

      不会往 window 上挂变量;不会在运行时代码里真的出现 __HAS_...__ 这个名字。

      webpack 在构建时会做常量折叠:如果这个宏是 false,整块 if 分支会被摇掉

    2. 无额外文件,但是定义全局变量进行编译期宏替换

  3. 反向依赖——由业务入口调用基础库

动态组件扩展 dashboard/schema-view/components

  1. app/pages/dashboard/complex-view/schema-view/components/ 下写组件
  • components的统一写法
<script setup>
import { ref } from 'vue'

const name = ref('createForm')

const isShow = ref(false)

const show = () => {
  isShow.value = true
}

const close = () => {
  isShow.value = false
}

defineExpose({
  name,
  show,
})
</script>

<template></template>

<style scoped lang="less"></style>
  1. 配置app/pages/dashboard/complex-view/schema-view/components/component-config.js

schema-form 控件扩展

  1. app/pages/widgets/schema-form/complex-view/下写组件
<script setup>

const validate = () => {}

const getValue = () => {}

defineExpose({
  name,
  validate,
  getValue,
})
</script>

<template></template>

<style scoped lang="less"></style>
  1. 配置app/pages/widgets/schema-form/form-item-config.js

schema-search-bar 控件的扩展

  1. app/pages/widgets/schema-search-bar/complex-view/下写组件
<script setup>

const reset = () => {}

const getValue = () => {}

defineExpose({
  reset,
  getValue,
})
</script>

<template></template>

<style scoped lang="less"></style>
  1. 配置app/pages/widgets/schema-search-bar/search-item-config.js

发布

npm config get registry
-> https://registry.npmjs.org/
如果不是, npm config set registry
npm login

npm whoami
-> 会显示自己帐号的名字
npm publish --access public

后续优化方向

接入TS

目前项目都是 JavaScript。随着 Schema 复杂度的增加,没有较好的类型提示。

可视化

虽然实现了 Schema 驱动,但仍需手写复杂的 JSON 配置。 迭代建议:可视化界面搭建。

给每个字段提供属性面板,通过修改属性实时更新配置文件->类似于低代码平台

接入大模型

虽然有了可视化编辑可以拖拽生成,但配置复杂页面仍然需要时间。

JSON Schema 结构非常适合 LLM 理解和生成。

  • 编写系统提示词,告诉 AI 关于 elpis 的 Schema 规则(例如:searchConfig 对应搜索栏,tableConfig 对应表格)。

  • 交互:用户输入“帮我生成一个包含姓名、年龄、注册时间的名为‘用户管理’的页面,支持按姓名搜索”。

  • 输出:AI 直接输出符合 elpis 规范的 JSON 配置,填入页面。