写接口的顺序
这节重点介绍的是通过DSL渲染组件
备注引用: 抖音"哲玄前端"《大前端全栈实践》
- router-schema 定义接口的文档
- router 定义接口的路由
- controller 定义接口返回的逻辑
- service 给 controller 提供数据(对数据库的 crud)
project-list 接口
目的是 传入 例如 taobao 需要将所有 model 分类下的项目文件配置全部返回 例如 taobao、pdd
所以这里的关键核心是 model 文件的目录
例如 通过 buiness 找到其下所有的 project

按照接口的编写顺序依次完成
javascript
"/api/project/list": {
get: {
query: {
type: "object",
properties: {
proj_key: {
type: "string",
},
},
},
},
},
javascript
router.get(
"/api/project/list",
projectController.getList.bind(projectController)
);
javascript
/**
* 获取同一模型下的项目列表(如无projKey 则只去当前同模型下的项目,不传的情况下则取全量)
*/
getList(ctx) {
const { project: projectService } = app.service;
const { proj_key: projKey } = ctx.query;
const projectList = projectService.getList({ projKey });
const dtoProjectList = projectList.map((item) => {
const { modelKey, key, name, desc, homePage } = item;
return { modelKey, key, name, desc, homePage };
});
this.success(ctx, dtoProjectList);
}
只将符合条件的 key 进行返回,这里的意思就是如果 project 中的 key 存在就返回 目的是将所有 modelList 中的符合条件的project 返回成一个新数组,这也就符合传入 pdd 则返回 taobao、pdd
javascript
/**
* 获取🙆🏻♀同一模型下的项目列表(如果无projKey 取全量)
* 例如 传入taobao 那么就需要返回taobao和pdd一起,也就是当前 buiness(父级)下的所有项目
*/
getList({ projKey }) {
return modelList.reduce((preList, item) => {
const { project } = item;
if (projKey && !project[projKey]) {
return preList;
}
for (const key in project) {
preList.push(project[key]);
}
return preList;
}, []);
}
- 验证接口
在启动时获取所有的 modelList 下的 project 并合并到一个数据中
也就是 projectList 目的是通过 modelKey (父级 Id)可以找到所有的子集项目
javascript
const assert = require("assert");
const supertest = require("supertest");
const md5 = require("md5");
const ellisCore = require("../../elpis-core");
const signKey = "前后端对称加密的密钥";
const st = Date.now();
let modelList;
let projectList = [];
describe("测试 project 相关接口", function () {
this.timeout(60000);
let request;
it("启动服务", async () => {
const app = await ellisCore.start();
modelList = require("../../model/index.js")(app);
modelList.forEach((item) => {
const { project } = item;
for (const proKey in project) {
projectList.push(project[proKey]);
}
});
request = supertest(app.listen());
});
/**
* 验证接口 /api/project/list
* 例如 传入taobao 那么就需要返回taobao和pdd一起,也就是当前 buiness(父级)下的所有项目
* 所以关键点是 通过 proj_key 找到对应的 modelKey(父级Key) 然后返回所有 modelKey 相同的对象
*/
it("GET /api/project/list with proj_key", async () => {
// 1. 随机在 projectList 中选择一个对象 并且取 key
const { key: projKey } =
projectList[Math.floor(Math.random() * projectList.length)];
// 2. 通过 key 找到对应的 modelKey(父级Key)
const { modelKey } = projectList.find((item) => item.key === projKey);
let tempRequest = request.get("/api/project/list");
tempRequest = tempRequest.set("s_t", st);
tempRequest = tempRequest.set("s_sign", md5(`${signKey}${st}`));
tempRequest = tempRequest.query({ proj_key: projKey });
const res = await tempRequest;
assert(res.body.success === true);
const resData = res.body.data;
// 3. 验证接口中返回的数据长度 是否和 projectList中modelKey 中的数据长度一致
assert(
projectList.filter((item) => item.modelKey === modelKey).length ===
resData.length
);
for (let i = 0; i < resData.length; i++) {
const item = resData[i];
assert(item.modelKey);
assert(item.key);
assert(item.name);
assert(item.desc !== undefined);
assert(item.homePage !== undefined);
}
});
});
project 接口
传入项目的名称,返回具体的某一项目的配置
这里参照 project-list 的接口配置的方式来即可
javascript
"/api/project": {
get: {
query: {
type: "object",
properties: {
proj_key: {
type: "string",
},
},
required: ["proj_key"],
},
},
},
javascript
router.get("/api/project", projectController.get.bind(projectController));
javascript
/**
* 根据 proj_key 获取项目配置
*/
get(ctx) {
const { project: projectService } = app.service;
const { proj_key: projKey } = ctx.query;
const projConfig = projectService.get(projKey);
if (!projConfig) {
this.fail(ctx, "获取项目失败", 50000);
return;
}
this.success(ctx, projConfig);
}
javascript
/**
* 根据projKey获取项目配置
*/
get(projKey) {
let projConfig;
modelList.forEach((modelItem) => {
if (modelItem.project[projKey]) {
projConfig = modelItem.project[projKey];
}
});
return projConfig;
}
javascript
/**
* 验证接口 /api/project
*/
it("GET /api/project without proj_key", async () => {
let tempRequest = request.get("/api/project");
tempRequest = tempRequest.set("s_t", st);
tempRequest = tempRequest.set("s_sign", md5(`${signKey}${st}`));
const res = await tempRequest;
const resBody = res.body;
assert(resBody.success === false);
assert(resBody.code === 442);
assert(
resBody.message.includes(
`request validate fail: data should have required property 'proj_key'`
)
);
});
it("GET /api/project fail", async () => {
let tempRequest = request.get("/api/project");
tempRequest = tempRequest.set("s_t", st);
tempRequest = tempRequest.set("s_sign", md5(`${signKey}${st}`));
tempRequest = tempRequest.query({ proj_key: "xxxxx" });
const res = await tempRequest;
const resData = res.body;
assert(resData.success === false);
assert(resData.code === 50000);
assert(resData.message === "获取项目失败");
});
it("GET /api/project with proj_key", async () => {
for (const projItem of projectList) {
const { key: projKey } = projItem;
let tempRequest = request.get("/api/project");
tempRequest = tempRequest.set("s_t", st);
tempRequest = tempRequest.set("s_sign", md5(`${signKey}${st}`));
tempRequest = tempRequest.query({ proj_key: projKey });
const res = await tempRequest;
assert(res.body.success === true);
const resData = res.body.data;
console.log("-------------/api/project with proj_key-key", resData.key);
assert(resData.key === projKey);
assert(resData.modelKey);
assert(resData.name);
assert(resData.desc !== undefined);
assert(resData.homePage !== undefined);
const { menu } = resData;
menu.forEach((menuItem) => {
checkMenuItem(menuItem);
});
}
// 检查 menu 菜单
function checkMenuItem(menuItem) {
assert(menuItem.key);
assert(menuItem.name);
assert(menuItem.menuType);
if (menuItem.menuType === "group") {
assert(menuItem.subMenu !== undefined);
menuItem.subMenu.forEach((subMenuItem) => {
checkMenuItem(subMenuItem);
});
}
if (menuItem.menuType === "module") {
checkModule(menuItem);
}
}
// 检查 module 菜单配置
function checkModule(menuItem) {
const { moduleType } = menuItem;
assert(moduleType);
if (moduleType === "sider") {
const { siderConfig } = menuItem;
assert(siderConfig);
assert(siderConfig.menu);
siderConfig.menu.forEach((siderMenuItem) => {
checkMenuItem(siderMenuItem);
});
}
if (moduleType === "iframe") {
const { iframeConfig } = menuItem;
assert(iframeConfig);
assert(iframeConfig.path !== undefined);
}
if (moduleType === "custom") {
const { customConfig } = menuItem;
assert(customConfig);
assert(customConfig.path !== undefined);
}
if (moduleType === "schema") {
const { schemaConfig } = menuItem;
assert(schemaConfig.api !== undefined);
assert(schemaConfig.schema);
}
}
});
dashboard 渲染
- header-view
heanderMenu 始终是渲染的一级,这里在 dashboard 中请求 project/list 以及 dashboard 两个接口 分别渲头部的菜单 和 下拉菜单(同一 menuType 下的项目)通过 store 存储接口数据 子组价进行渲染
这里 SubMenu 组件有个小技巧,如果是有 subMenu 就 再去递归组件自身,直到渲染末级的菜单。

路由渲染

- 点击对于的项目卡片,进入按钮,需要跳转到该项目的首页
- 进入项目后,需要根据项目的配置 menu 来渲染对于的头部路由
- 点击头部路由,需要 router.push 对于的路由页面
- 切换项目,跳转到对于的项目
根据 dashbord 定义的参数来进行渲染路由
- 按照约定 menuType:
group(分组)subMenu 可递归 menuItem
module(模块) moduleType 的值为 sider/inrame/custom/todo..
- 需要 给每个项目配置定义 HomePage /todo?proj_key=taobao&key=product
proj_key: 菜单的唯一描述 例如 taobao
key: 默认显示的也就是进入项目需要默认高亮的菜单项
- 当 query 发送变化后 需要重新 更新页面路径
- 点击菜单,进行路由切换
- 定义路由
步骤一: 定义路由
javascript
import boot from "$pages/boot";
import Dashboard from "./dashboard.vue";
const routes = [];
// 头部菜单路由
routes.push({
path: "/schema",
component: () => import("./complex-view/schema-view/schema-view.vue"),
});
routes.push({
path: "/iframe",
component: () => import("./complex-view/iframe-view/iframe-view.vue"),
});
routes.push({
path: "/todo",
component: () => import("./todo/todo.vue"),
});
// 侧边栏菜单路由
routes.push({
path: "/sider",
component: () => import("./complex-view/sider-view/sider-view.vue"),
children: [
{
path: "iframe",
component: () => import("./complex-view/iframe-view/iframe-view.vue"),
},
{
path: "/schema",
component: () => import("./complex-view/schema-view/schema-view.vue"),
},
{
path: "/todo",
component: () => import("./todo/todo.vue"),
},
],
});
boot(Dashboard,{ routes });
第二步: 创建对应的路由页面

步骤三:dashboard 页面展示 入口文件
vue
<script setup>
import { useMenuStore, useProjectStore } from "$store";
import { computed, onMounted, ref } from "vue";
import $curl from "$common/curl.js";
import { ElConfigProvider } from "element-plus";
import zhCn from "element-plus/es/locale/lang/zh-cn";
import HeaderView from "./complex-view/header-view/header-view.vue";
import { useRoute } from "vue-router";
import { useRouter } from "vue-router";
const menuStore = useMenuStore();
const projectStore = useProjectStore();
const route = useRoute()
const router = useRouter()
// 项目名称
const projName = ref("");
const queryKey = computed(()=>route.query.key)
const queryProjKey = computed(()=>route.query.proj_key)
const getMenuList = async () => {
const res = await $curl({
url: "/api/project",
query: { proj_key: queryProjKey.value },
});
menuStore.setMenuList(res.data?.menu);
projName.value = res.data?.name;
};
const getProject = async () => {
const res = await $curl({
url: "/api/project/list",
query: { proj_key: queryProjKey.value },
});
projectStore.setProjectList(res.data ?? []);
};
/**
* 点击头部菜单,切换路由
* 通过pathMap映射需要跳转的路由
* @param enumItem
*/
const onMenuSelect = (enumItem) => {
const {key,moduleType,customConfig} = enumItem
if (key === queryKey.value) return
const pathMap = {
sider: '/sider',
iframe: '/iframe',
custom: customConfig?.path,
}
router.push({
path:pathMap[moduleType],
query:{
key,
proj_key: queryProjKey.value
}
})
}
onMounted(() => {
getMenuList();
getProject();
});
</script>
<template>
<el-config-provider :locale="zhCn">
<header-view
:proj-name="projName"
@menu-select="onMenuSelect"
>
<template #main-content>
<router-view />
</template>
</header-view>
</el-config-provider>
</template>
<style lang="less" scoped></style>
第四步 渲染头部菜单
由于不同项目的 homePage 不同,这里 proj_key 默认设置是第一个菜单项,也就是需要高亮第一个菜单,所以需要监听路由参数 proj_key 的变化 以及 menuList 的变化,当然也包含加载完成后,都需要将 activeKey 设置默认高亮的菜单。
vue
<script setup>
import { ArrowDown } from "@element-plus/icons-vue";
import HeaderContainer from "$widgets/header-container/header-container.vue";
import SubMenu from "./complex-view/sub-menu/sub-menu.vue";
import { useMenuStore,useProjectStore } from "$store";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
defineProps({
projName: {
type: String,
default: "",
},
});
const emit = defineEmits(['menu-select'])
const menuStore = useMenuStore();
const projectStore = useProjectStore();
const route = useRoute()
// 头部菜单,需要高亮显示的菜单项,默认显示的是homePage中的 proj_key
const activeKey = ref("");
const queryKey = computed(()=>route.query.key)
const setActiveKey = ()=>{
const enumItem = menuStore.findMenuItem({key:'key',value:queryKey.value})
activeKey.value = enumItem?.key
}
watch(()=>queryKey,()=>{
setActiveKey()
})
watch(()=>menuStore.menuList,(val)=>{
setActiveKey()
})
onMounted(()=>{
setActiveKey()
})
const onMenuSelect = (value) => {
const enumItem = menuStore.findMenuItem({key:'key',value})
emit('menu-select',enumItem)
};
/**
* 头部 项目切换区域 下拉显示相同的类型的项目,并点击切换
* 为保证健壮性,和隐私,如果找到才去跳转
* @param value
*/
const handleProjectCommand = (value) => {
const projItem = projectStore.projectList.find(item=>item.key === value)
if (!projItem) return
const {origin,pathname} = window.location
console.log(`${origin}${pathname}#${projItem.homePage}`);
window.location.replace(`${origin}${pathname}#${projItem.homePage}`)
window.location.reload()
}
</script>
<template>
<header-container :title="projName">
<template #menu-content>
<el-menu
:default-active="activeKey"
:ellipsis="false"
mode="horizontal"
@select="onMenuSelect"
>
<template v-for="item in menuStore.menuList">
<SubMenu
v-if="item.subMenu && item.subMenu.length > 0"
:key="item.key"
:menu-item="item"
/>
<el-menu-item
v-else
:index="item.key"
>
{{ item.name }}
</el-menu-item>
</template>
</el-menu>
</template>
<template #setting-content>
<el-dropdown @command="handleProjectCommand">
<span class="project-list">
{{ projName }}
<el-icon>
<arrow-down />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="item in projectStore.projectList"
:key="item.key"
:command="item.key"
:disabled="item.name === projName"
>
{{ item.name }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<template #main-content>
<slot name="main-content"></slot>
</template>
</header-container>
</template>
<style lang="less" scoped>
.project-list {
margin-right: 20px;
cursor: pointer;
color: var(--el-color-primary);
display: flex;
align-items: center;
outline: none;
}
:deep(.el-menu--horizontal.el-menu) {
border-bottom: 0;
}
</style>
这里需要始终都是渲染一级路由,所以这里有递归渲染组件的过程
vue
<script setup>
defineProps({
menuItem: {
type:Object,
required:true
},
});
</script>
<template>
<el-sub-meu :index="menuItem.key">
<template #title>{{ menuItem.name }}</template>
<div
v-for="item in menuItem.subMenu"
:key="item.id"
>
<SubMenu
v-if="item.subMenu && item.subMenu.length > 0"
:menu-item="item"
/>
<el-menu-item
v-else
:index="item.key"
>
{{ item.name }}
</el-menu-item>
</div>
</el-sub-meu>
</template>
<style lang="less" scoped></style>
真正的做到懒加载
- 路由要通过 import 加载 做到路由懒加载 2. 每个路由文件都需要单独打包 3. 只有进入到这个路由页面,才会加载这个 JS
vue
splitChunks: {
chunks: "all", // 对异步和非异步模块进行分割
maxAsyncRequests: 10, // 每次异步加载的最大并行请求数量
maxInitialRequests: 10, // 入口点的最大并行请求数
maxAsyncRequests: 30,
cacheGroups: {
vendor: {
// 第三方依赖库
name: "vendor",
test: /[\\/]node_modules[\\/]/, // 打包 node_modules 中的文件
priority: 20, // 优先级 数字越大 优先级越高
reuseExistingChunk: true, // 复用已有的公共 chunk
},
common: {
// 公共模块
name: "common",
test: /[\\/]common|widgets[\\/]/, // 打包 只打包 common|widgets 中的文件
minChunks: 2, // 被两处引用即被归为公共模块
minSize: 1, // 最小分割文件大小(1 byte)
priority: 10, // 优先级
reuseExistingChunk: true, // 复用已有的公共 chunk
},
},
},
sider-view
条件:moduleType 为 sider 时,点击头部对于的菜单,显示 main-content 左侧的菜单栏。
此时 siderConfig 中 menu 的数据就是左侧的菜单项
widgets 中 创建 sider-container
- 给 sider 创建架子暴露 menu-content(左侧菜单项) 以及 main-content
vue
<script setup></script>
<template>
<el-container class="sider-container">
<el-aside
width="200px"
class="aside"
>
<slot name="menu-content" />
</el-aside>
<el-main class="main">
<slot name="main-content" />
</el-main>
</el-container>
</template>
<style lang="less" scoped>
.sider-container {
height: 100%;
.aside {
border-right: 1px solid #e8e8ee;
}
.main {
overflow: scroll;
}
}
:deep(.el-menu) {
border-right: 0;
}
</style>
complex-view 中创建 sider-view
- 找到 moduleType 为 sider,并将 siderConfig 中的 menu 渲染在左侧菜单项中 + 给左侧菜单高亮,默认选中第一项 + 右侧 main 区域设置路由出口

vue
<script setup>
import SiderContainer from "$widgets/sider-container/sider-container.vue";
import SubMenu from "./complex-view/sub-menu/sub-menu.vue";
import { useMenuStore } from "$store";
import { onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
const menuStore = useMenuStore();
const menuList = ref([]);
const activeKey = ref("");
const setMenuList = () => {
const menuItem = menuStore.findMenuItem({
key: "key",
value: route.query.key,
});
if (menuItem?.siderConfig?.menu?.length) {
menuList.value = menuItem.siderConfig.menu;
}
};
const setActiveKey = () => {
let siderMenuItem = menuStore.findMenuItem({
key: "key",
value: route.query.sider_key,
});
// 如果首次加载 sider-view, 用户未选中左侧菜单,需要默认选中第一个
if (!siderMenuItem) {
const hMenuItem = menuStore.findMenuItem({
key: "key",
value: route.query.key,
});
if (hMenuItem?.siderConfig?.menu) {
const siderMenuList = hMenuItem.siderConfig.menu;
siderMenuItem = menuStore.findFirstMenuItem(siderMenuList);
if (siderMenuItem) {
handleMenuSelect(siderMenuItem.key);
}
}
}
activeKey.value = siderMenuItem?.key;
};
const obMenuSelect = (menuKey) => {
handleMenuSelect(menuKey);
};
const handleMenuSelect = (menuKey) => {
const menuItem = menuStore.findMenuItem({
key: "key",
value: menuKey,
});
const { moduleType,customConfig, key } = menuItem
if (key === route.query.sider_key) {
return
}
const pathMap = {
iframe: '/iframe',
schema:'/schema',
custom:customConfig?.path
}
router.push({
path:`/sider${pathMap[moduleType]}`,
query:{
key:route.query.key,
sider_key:key,
proj_key:route.query.proj_key,
}
})
};
watch(
() => menuStore.menuList,
() => {
setMenuList();
setActiveKey()
}
);
watch(
() => route.query.key,
() => {
setMenuList();
setActiveKey()
}
);
onMounted(() => {
setMenuList();
setActiveKey()
});
</script>
<template>
<sider-container>
<template #menu-content>
<el-menu
:default-active="activeKey"
:ellipsis="false"
@select="obMenuSelect"
>
<template v-for="item in menuList">
<sub-menu
v-if="item?.subMenu?.length"
:menu-item="item"
/>
<el-menu-item
v-else
:index="item.key"
>
{{ item.name }}
</el-menu-item>
</template>
</el-menu>
</template>
<template #main-content>
<router-view />
</template>
</sider-container>
</template>
<style lang="less" scoped></style>
search-table
制定 DLS 规则
表格包含了头部的搜索栏、表格每一列字段信息、以及操作栏的按钮和 api 接口
- api 标识请求的接口地址
- properties 中包含表单所有的字段 也就是表格的每一列
- properties 标示当前列的属性,以及自定义增加的属性,例如 fixed、visible 这些额外的自定义逻辑
- tableConfig 表格的配置 包含表格头按钮、表格操作栏按钮
- rowButton eventKey:标识当前按钮的作用(编辑 | 删除 | 查看)等,eventOption 是事件的参数。
json
{
key: 'product',
name: '商品管理',
menuType: 'module',
moduleType: 'schema',
schemaConfig: {
api: '/api/product',
schema: {
type: 'object',
properties: {
produce_id: {
type: 'string',
label: '商品ID',
tableOption: {
width: 300,
'show-overflow-tooltip': true,
},
},
produce_name: {
type: 'string',
label: '商品名称',
tableOption: {
width: 200,
},
},
price: {
type: 'number',
label: '价格',
tableOption: {
width: 200,
},
},
inventory: {
type: 'number',
label: '库存',
tableOption: {
width: 200,
},
},
create_time: {
type: 'string',
label: '创建时间',
tableOption: {},
},
},
},
tableConfig: {
headerButtons: [
{
label: '新增商品',
eventKey: 'showComponent',
type: 'primary',
plain: true,
},
],
rowButtons: [
{
label: '修改',
eventKey: 'remove',
eventOption: {
params: { product_id: 'schema::product_id' },
},
},
],
},
},
},
二次封装 el-table 主要就是可以根据 properties 来遍历渲染每一列,头部搜索 headerButtons、表格操作rowButtons
数据去污
目的是将 DLS 转化成需要渲染的数据格式,格式按照下图所示 主要就是将 tableOption 变成 option

这里写个 hooks,在 schema-view 初始化以及数据发生变化时,进行格式化
vue
import { useRoute } from 'vue-router'
import { useMenuStore } from '$store'
import { nextTick, ref, watch } from 'vue'
export const useSchema = () => {
const route = useRoute()
const menuStore = useMenuStore()
const api = ref('')
const tableSchema = ref({})
const tableConfig = ref({})
// 构造 schemaConfig 相关配置,输送给 schemaView 解析
const buildData = () => {
const { key, sider_key: siderKey } = route.query
const mItem = menuStore.findMenuItem({
key: 'key',
value: siderKey ?? key,
})
if (mItem?.schemaConfig) {
api.value = mItem.schemaConfig?.api
const { schemaConfig: sConfig } = mItem
// 为了避免直接修改原始数据,这里做了深拷贝
const configSchema = JSON.parse(JSON.stringify(sConfig.schema))
tableSchema.value = {}
tableConfig.value = undefined
nextTick(() => {
tableSchema.value = buildDtoSchema(configSchema, 'table')
tableConfig.value = sConfig.tableConfig
})
}
}
// 通用构造 schema 方法
const buildDtoSchema = (_schema, comName) => {
if (!_schema?.properties) return {}
const dtoSchema = {
type: 'object',
properties: {},
}
// 提取有效的 schema 字段
for (const key in _schema?.properties) {
const props = _schema?.properties[key]
if (props[`${comName}Option`]) {
let dtoProps = {}
// 提取 props中 非option的部分,存放到dtoProps中
for (const pKey in props) {
if (!pKey.includes('Option')) {
dtoProps[pKey] = props[pKey]
}
}
dtoProps = Object.assign({}, dtoProps, {
option: props[`${comName}Option`],
})
dtoSchema.properties[key] = dtoProps
}
}
return dtoSchema
}
watch(
[
() => route.query.key,
() => route.query.sider_key,
() => menuStore.menuList.value,
],
() => {
buildData()
},
{ deep: true, immediate: true },
)
return {
api,
tableSchema,
tableConfig,
}
}
schema-view
使用 useSchema 格式化数据 并通过 provide 注入到所有下文中 下一步就是将数据进行渲染
vue
<script setup>
import { provide } from 'vue'
import SearchPanel from './complex-view/search-panel/search-panel.vue'
import TablePanel from './complex-view/table-panel/table-panel.vue'
import { useSchema } from './hook/useSchema'
const { api, tableConfig, tableSchema } = useSchema()
provide('schemaViewData', { api, tableConfig, tableSchema })
</script>
<template>
<el-row class="schema-view">
<search-panel />
<table-panel />
</el-row>
</template>
<style lang="less" scoped>
.schema-view {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
</style>
table-panel
table-panel 组件包含了表格头部的按钮 以及 table 本身和分页器
所以 这里需要将 table 的渲染额外抽离成 table-schema 进行单独处理 这里渲染的是 headerButtons
- 通过 inject 获取注入的数据,并开始渲染
- EventHandlerMap 处理 操作栏按钮的事件,目的是区分 操作的类型,接下去的操作只要再额外增加 Map 即可。
- 向 schema-table 传递必要的渲染数据
- 对外暴露获取 table 渲染数据的方法
所以下一步就是去处理和渲染 schema-table
vue
<script setup>
import SchemaTable from '$widgets/schema-table/schema-table.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { inject, ref } from 'vue'
const { api, tableConfig, tableSchema } = inject('schemaViewData')
const SchemaTableRef = ref(null)
const emit = defineEmits(['operate'])
const EventHandlerMap = {
remove: removeData,
}
const operationHandle = ({ btnConfig, row }) => {
const { eventKey } = btnConfig
if (EventHandlerMap[eventKey]) {
EventHandlerMap[eventKey]({ btnConfig, row })
} else {
emit('operate', { btnConfig, row })
}
}
function removeData({ btnConfig, row }) {
const { eventOption } = btnConfig
if (!eventOption?.params) return
const { params } = eventOption
const removeKey = Object.keys(params)[0]
let removeValue
const removeValueList = removeValue.split('::')
if (removeValueList[0] === 'schema' && removeValueList[1]) {
removeValue = row[removeValueList[1]]
}
ElMessageBox.confirm(`确认删除${removeKey} 为: ${removeValue}`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(async () => {
await $curl({
url: api.value,
method: 'delete',
data: {
[removeKey]: removeValue,
},
})
await SchemaTableRef.value.initData()
ElMessage.success('删除成功')
})
}
const initTableData = async () => {
await SchemaTableRef.value.initData()
}
const loadTableData = async () => {
await SchemaTableRef.value.loadTableData()
}
defineExpose({
initTableData,
loadTableData,
})
</script>
<template>
<el-card class="table-panel">
<el-row
v-if="tableConfig?.headerButtons?.length > 0"
justify="end"
class="operation-panel"
>
<el-button
v-for="item in tableConfig?.headerButtons"
:key="item.label"
v-bind="item"
>{{ item.label }}</el-button
>
</el-row>
<schema-table
ref="SchemaTableRef"
:schema="tableSchema"
:api="api"
:buttons="tableConfig?.rowButtons ?? []"
@operate="operationHandle"
/>
</el-card>
</template>
<style lang="less" scoped>
.table-panel {
flex: 1;
margin: 10px;
.operation-panel {
margin-bottom: 10px;
}
}
:deep(.el-card__body) {
height: 98%;
display: flex;
flex-direction: column;
}
</style>
schema-table
- 表格通过 schema 中的 properties 渲染 table 每一列数据 + api 是请求数据源地址,在次组件中获取数据 + buildTableData 在获取数据的同时,执行 option 中 额外定义的特殊逻辑 + buttons 操作栏的描述数据,对应 rowBottons + 点击操作栏按钮时,将这一项按钮的 DSL 配置 以及当前 table 行的数据对外 emit + 暴露实例方法
vue
<script setup>
import { computed, ref, toRefs, watch } from 'vue'
import $curl from '$common/curl'
const props = defineProps({
/**
* schema 配置如下
* {
// 板块数据结构 JSON-Schema
type: 'object',
properties: {
key: {
type: '', // 字段类型
label: '', // 字段的中文名
option: {
...ElTableColumnConf, // 标准 el-table-column 配置
visible: true, // 默认为 true (false 或 不配置时,标识不在表单中显示)
},
},
},
}
*/
schema: {
type: Object,
required: true,
},
/**
* 表格数据源 api
*/
api: {
type: String,
required: true,
},
/**
* buttons 按钮相关配置,结构如下
* [
{
label: '', //按钮中文
eventKey: '', //按钮事件
eventOption: {}, // 按钮事件具体配置
...elButtonConfig, // 标准 el-button 配置
},
]
*/
buttons: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['operate'])
const { schema, api, buttons } = toRefs(props)
const loading = ref(false)
const tableData = ref([])
const currentPage = ref(1)
const total = ref(0)
const pageSize = ref(20)
// 操作列宽度
const operationWidth = computed(() => {
if (!buttons.value?.length > 0) return 50
return buttons.value.reduce((pre, cur) => pre + cur.label.length * 18, 50)
})
const showLoading = () => {
loading.value = true
}
const hideLoading = () => {
loading.value = false
}
const initData = async () => {
currentPage.value = 1
pageSize.value = 20
await loadTableData()
}
let timer = null
const loadTableData = async () => {
clearTimeout(timer)
timer = setTimeout(async () => {
await fetchTableData()
timer = null
}, 100)
}
const fetchTableData = async () => {
if (!api.value) return
showLoading()
try {
const res = await $curl({
method: 'GET',
url: `${api.value}/list`,
query: {
page: currentPage.value,
pageSize: pageSize.value,
},
})
tableData.value = buildTableData(res.data ?? [])
total.value = res.metadata?.total ?? 0
} catch (error) {
console.log(error)
} finally {
hideLoading()
}
}
const operationHandle = ({ btnConfig, row }) => {
emit('operate', { btnConfig, row })
}
const onSizeChange = async (pageSize) => {
pageSize.value = pageSize
await loadTableData()
}
const onCurrentChange = async (currentPage) => {
currentPage.value = currentPage
await loadTableData()
}
/**
* 对后端返回的数据进行渲染前的预处理
* 例如 option 中增加 toFixed 属性 用于处理小数点位数
* @param listData data 列表数据
*/
const buildTableData = (listData) => {
if (!schema.value?.properties) return []
const tableFormat = listData.map((rowData) => {
for (const key in rowData) {
// 找到对应的 schemaItem 并 将额外的逻辑进行处理
const schemaItem = schema.value.properties[key]
if (schemaItem?.option?.toFixed) {
rowData[key] = rowData[key].toFixed(schemaItem.option.toFixed)
}
}
return rowData
})
return tableFormat
}
watch(
[schema, api],
() => {
initData()
},
{ immediate: true, deep: true },
)
defineExpose({
initData,
showLoading,
hideLoading,
})
</script>
<template>
<div class="schema-table">
<el-table
v-if="schema.properties"
v-loading="loading"
class="table"
:data="tableData"
>
<template v-for="schemaItem in schema.properties">
<el-table-column
v-if="schemaItem.option?.visible !== false"
:prop="schemaItem.prop"
:label="schemaItem.label"
v-bind="schemaItem.option"
>
</el-table-column>
</template>
<el-table-column
v-if="buttons?.length > 0"
label="操作"
fixed="right"
:width="operationWidth"
>
<template #default="{ row }">
<el-button
v-for="(button, index) in buttons"
:key="index"
link
v-bind="button"
@click="operationHandle({ button, row })"
>
{{ button.label }}
</el-button>
</template>
</el-table-column>
</el-table>
<el-row justify="end" class="pagination">
<el-pagination
:current-page="currentPage"
:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="onSizeChange"
@current-change="onCurrentChange"
></el-pagination>
</el-row>
</div>
</template>
<style lang="less" scoped>
.schema-table {
flex: 1;
display: flex;
flex-direction: column;
overflow: auto;
.table {
flex: 1;
}
.pagination {
margin: 10px 0;
text-align: right;
}
}
</style>
重构 schema-table 给不同的项目添加具体的 projKey
目前的接口,不论是 pdd、taobao、jd 所有的 table 数据都是相同的,因为在 controller 中 统一返回了固定的数据,那么应该如何做区分?
projKey 区分项目
在 dsl 中 homePage 通过 projKey 区分不同的项目,这里的方案有很多,可以是将 projKey 放在请求头中或者请求参数中,也可以是请求体,但是这些都需要重新的去写 projKey 如果是 projKey 发生变化,那么请求的每一处的 projKey 都需要修改。
存储 projKey 的方式
所以这里最好是在进入当前这个路由时,也就是在 renderPage 的时候,将 projKey 注入到 window 中,并且 挂载到 ctx 上下文中。
这样做的好处是,在 ctx 上下文中直接通过 ctx.projKey 即可获取当前的项目信息,这时候就可以通过 projKey 来进行区分数据返回了。
完成 search-panel
这里的核心理念是通过 SchemaItemConfig 管理头部所有的组件,并且通过在 shcmea 中指定的 type 来去渲染对应的组件,从而实现头部组件 input select 等等的渲染。
javascript
import input from "./complex-view/input/input.vue";
import select from "./complex-view/select/select.vue";
import dynamicSelect from "./complex-view/dynamic-select/dynamic-select.vue";
import dateRange from "./complex-view/date-range/date-range.vue";
const SchemaItemConfig = {
input: {
component: input,
},
select: {
component: select,
},
dynamicSelect: {
component: dynamicSelect,
},
dateRange: {
component: dateRange,
},
... 其他组件
};
export default SchemaItemConfig;
所以这里的 dsl 设计应该是
javascript
searchOption: {
...elComponentConfig,
comType: "", //配置组件类型 input/select
default: "", //默认值
// comType === select
enumList: [],
// comType === dynamic-select
api: "",
},
最后通过动态组件 的方式 去渲染,并且监听表单的数据 当点击搜索时,将表单中渲染的组件数据进行合并到一个对象中,并发送搜索请求即可。