四、elpis 基于 vue3 完成动态组件库建设并不只看表面做法,关键还要理解相关条件、限制和后续影响。
开发一些组件来建设组件库以及扩展 DSL
表格内容:
对于交互,需要知道事件回调以及动态参数值。事件回调函数可通过事件名确定,对于动态参数,可能来源于表格,也可能来源于外部,比如获取当前时间,所以可以添加一些专有标识,在组件内部分辨获取
DSL 设计:我们在 properties.key 中通过 tableOption 设置当前列字段在表格中的配置,然后在外部 tableConfig 中对表格整体设置,比如 headerButtons 以及每行的操作按钮 rowButtons
// schema-table dslconst schemaConfig = { api: string; schema: { type: object, properties: { [key]: { // ...标准 json-schema 配置tableOption: { // ...ElTableColumnConfig,visible: boolean, // 是否展示 }, }, }, }, tableConfig: { headerButtons: [ { // ...elButtonConfig,eventKey: string; // 触发的事件名,如remove showComponenteventOption: { // 当 eventKey = showComponentcomName: string; // "组件名"// 当 eventKey = remove// 比如说删除某条数据时,向后端传递参数 time,该字段值来源于表格中 createTime 字段params: { /** * paramKey: {string} 参数名称 * rowValueKey: {string} 如果 rowValueKey 是 `schema::${rowValueKey}` 这样的格式,表示从表格中获取值 */ [paramKey: string]: rowValueKey, } } } ], // 同 headerButtonsrowButtons: [], },};搜索组件内容:
DSL 设计:我们在 properties.key 中通过 searchOption 设置表单项的组件类型以及默认值,然后在外部 searchConfig 中对搜索组件整体设置,比如 searchButtons
const schemaConfig = { schema: { type: object, properties: { [key]: { // ...标准 json-schema 配置searchOption: { // ...xxComponentConfig, // 包括 elementui、自定义组件、其他组件visible: boolean, // 是否展示comType: "", // 组件类型default: "", // 默认值 }, }, }, }, searchConfig: { searchButtons: [ { // ...elButtonConfig,label: '搜索', eventKey: string; // 触发的事件名 searchvisible: true, }, { // ...elButtonConfig,label: '清空', eventKey: string; // 触发的事件名 clearvisible: false, }, ], },};表单内容:
DSL 设计:我们在 properties.key 中通过 xxxFormOption 表示某个表单项的配置,然后在外部 componentConfig 下设置多个表单(xxxForm)的整体配置,类似的,对于同类型组件,通过 xxx${compType} 设置。
const schemaConfig = { schema: { type: object, properties: { [key]: { // ...标准 json-schema 配置xxxFormOption: { // ...elFormItemConfig,visible: boolean, // 是否展示comType: "", // 组件类型default: "", // 默认值disabled: false, // 根据组件类型扩展传递该组件必要的内容,如当 comType = 'select' 时,需要枚举列表enumList: [], api: "", // 动态select }, }, }, }, componentConfig: { xxxForm: { title: string; // 表单标题saveBtnText: string; // 保存按钮mainKey: string; // 表单标识 } }};对于组件来说应该只传递必要的内容,而 key 不一定在所有组件中都渲染
在将 schema 配置传递给组件前,先筛选出当前渲染组件的 schema 和 config,将 xxxOption 改为 option,清除噪音,代码更加直观
// app/pages/dashboard/complex-view/schema-view/hooks/schema.jsimport { ref, watch, onMounted, nextTick } from"vue";import { useRoute } from"vue-router";import { useMenuStore } from"$store/menu.js";exportconst useSchema = function () { 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 相关配置,输送给 schemaViewconstbuildData = () => { const { sider_key: sideKey, key } = route.query; const mItem = menuStore.findMenuItem({ key: "key", value: sideKey ?? key, }); if (mItem && mItem.schemaConfig) { const { schemaConfig: sConfig } = mItem; // 对象的深拷贝实现const configSchema = JSON.parse(JSON.stringify(sConfig.schema)); api.value = sConfig.api ?? ""; tableSchema.value = {}; tableConfig.value = undefined; searchSchema.value = {}; searchConfig.value = undefined; components.value = {}; nextTick(() => { // 构造 tableSchema 和 tableConfig tableSchema.value = buildDtoSchema(configSchema, "table"); tableConfig.value = sConfig?.tableConfig || {}; // 构造 searchSchema 和 searchConfigconst dtoSearchSchema = buildDtoSchema(configSchema, "search"); for (const key in dtoSearchSchema.properties) { if (route.query[key] !== undefined) { // 设置默认值 dtoSearchSchema.properties[key].option.default = route.query[key]; } } searchSchema.value = dtoSearchSchema; searchConfig.value = sConfig?.searchConfig || {}; // 构造 components = { comKey: { schema: {}, config: {} }}const { componentConfig } = sConfig; if (componentConfig && Object.keys(componentConfig).length >0) { const dtoComponents = {}; for (const comName in componentConfig) { dtoComponents[comName] = { schema: buildDtoSchema(configSchema, comName), config: componentConfig[comName], }; } components.value = dtoComponents; } }); } }; // 通用构建 schema 方法(清除噪音 将 xxOption 统一用 option 访问)constbuildDtoSchema = (_schema, compName) => { if (!_schema?.properties) return {}; const dtoSchema = { type: "object", properties: {}, }; for (const key in _schema.properties) { const props = _schema.properties[key]; if (props[`${compName}Option`]) { let dtoProps = {}; // 提取 props 中非 option 的部分,存放到 dtoProps 中for (const pKey in props) { if (pKey.indexOf("Option") < 0) { dtoProps[pKey] = props[pKey]; } } // 处理 compName Option dtoProps = Object.assign({}, dtoProps, { option: props[`${compName}Option`], }); // 处理 required 字段const { required } = _schema; if (required && required.find((pk) => pk == key)) { dtoProps.option.required = true; } dtoSchema.properties[key] = dtoProps; } } return dtoSchema; }; watch( [ () => route.query.key, () => route.query.sider_key, () => menuStore.menuList, ], () => { buildData(); }, { deep: true, }, ); onMounted(() => { buildData(); }); return { api, tableSchema, tableConfig, searchSchema, searchConfig, components, };};// app/pages/widgets/schema-table/schema-table.vue<template> <div class="schema-table"> <el-table v-if="schema && schema.properties" v-loading="loading" class="table" :data="tableData" > <template v-for="(schemaItem, key) in schema.properties"> <el-table-column v-if="schemaItem.option.visible !== false" :key="key" :prop="key" :label="schemaItem.label" v-bind="schemaItem.option" /> </template> <el-table-column v-if="buttons?.length > 0" label="操作" fixed="right" :width="operationWidth" :prop="key" v-bind="schema.option" > <template #default="scope"> <el-button v-for="btnItem in buttons" :key="btnItem.label" v-bind="btnItem" link @click=" operationHandler({ btnConfig: btnItem, rowData: scope.row }) " > {{ btnItem.label }} </el-button> </template> </el-table-column> </el-table> <el-row justify="end" class="pagination"> <el-pagination :current-page="currentPage" :page-size="pageSize" :page-sizes="[10, 20, 50, 100, 200]" layout="prev, pager, next, jumper, ->, total" :total="total" @size-change="onPageSizeChange" @current-change="onCurrentPageChange" /> </el-row> </div></template><script setup>import { ref, toRefs, computed, watch, nextTick, onMounted } from "vue";import $curl from "$common/curl";const props = defineProps({ /** * schema 配置,结构如下: * { * type: "object", * properties: { * key: { * ...schema, // 标准 schema 配置 * type: "", // 字段类型 * label: "", // 字段的中文名 * // 字段在 xxxComp 组件中的相关配置 * option: { * ...elxxxCompColumnConfig, // 标准 el-xxx 配置 * visible: true, // 自定义字段 默认为 true(false 或 不配置时,表示不在表单中显示) * }, * }, * }, * } */ schema: Object, /** * 表格数据源 api */ api: String, /** * buttons 操作按钮相关配置,结构如下: * [ * { * label: '', * eventKey: '', // 按钮事件名 * eventOption: {}, // 按钮具体配置 * ...elButtonConfig, * } * ] * */ apiParams: Object, buttons: Array,});const emit = defineEmits(["operate"]);const { schema, api, apiParams, buttons } = toRefs(props);onMounted(() => { initData();});watch( [schema, api, apiParams], () => { initData(); }, { deep: true },);// 估算按钮长度const operationWidth = computed(() => { return buttons?.value?.length > 0 ? buttons.value.reduce((prev, curr) => { return prev + curr.label.length * 18; }, 50) : 50;});const loading = ref(false);const tableData = ref([]);const currentPage = ref(1);const pageSize = ref(50);const total = ref(0);// 通过节流避免重复请求let timeId = null;const loadTableData = async () => { clearTimeout(timeId); timeId = setTimeout(async () => { await fetchTableData(); timeId = null; // 解除引用 }, 100);};const initData = () => { currentPage.value = 1; pageSize.value = 50; nextTick(async () => { await loadTableData(); });};const fetchTableData = async () => { if (!api.value) return; setLoadingStatus(true); const res = await $curl({ method: "get", url: `${api.value}/list`, query: { page: currentPage.value, size: pageSize.value, ...apiParams.value, }, }); setLoadingStatus(false); if (!res || !res.success || !Array.isArray(res.data)) { tableData.value = []; total.value = 0; return; } tableData.value = buildTableData(res.data); total.value = res.metadata?.total;};const buildTableData = (listData = []) => { if (!schema.value?.properties) return listData; return listData.map((rowData) => { for (const dKey in rowData) { const schemaItem = schema.value.properties[dKey]; if (schemaItem?.option?.toFixed) { rowData[dKey] = rowData[dKey].toFixed && rowData[dKey].toFixed(schemaItem.option.toFixed); } } return rowData; });};const setLoadingStatus = (status) => { loading.value = status;};const operationHandler = ({ btnConfig, rowData }) => { emit("operate", { btnConfig, rowData });};const onPageSizeChange = async (value) => { pageSize.value = value; await loadTableData();};const onCurrentPageChange = async (value) => { currentPage.value = value; await loadTableData();};defineExpose({ setLoadingStatus, initData, loadTableData,});</script><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>app/pages/dashboard/complex-view/schema-view/complex-view/table-panel/table-panel.vue<template><el-cardclass="table-panel"><!-- operation-panel --><el-rowv-if="tableConfig?.headerButtons?.length > 0"justify="end"class="operation-panel" ><el-buttonv-for="item in tableConfig.headerButtons"v-bind="item" @click="operationHandler({ btnConfig: item })" > {{ item.label }} </el-button></el-row><!-- schema-table (组件 widget) --><schema-tableref="schemaTableRef":schema="tableSchema":api="api":api-params="apiParams":buttons="tableConfig?.rowButtons ?? []" @operate="operationHandler" /></el-card></template><scriptsetup>import { ref, inject } from"vue";import { ElMessageBox, ElNotification } from"element-plus";import $curl from"$common/curl";importSchemaTablefrom"$widgets/schema-table/schema-table.vue";const emit = defineEmits(["operate"]);const { api, apiParams, tableSchema, tableConfig } = inject("schemaViewData");const schemaTableRef = ref(null);constremoveData = async ({ btnConfig, rowData }) => { const { eventOption } = btnConfig; if (!eventOption?.params) return; const { params } = eventOption; const removeKey = Object.keys(params)[0]; let removeValue; const removeValueList = params[removeKey].split("::"); if (removeValueList[0] == "schema" && removeValueList[1]) { removeValue = rowData[removeValueList[1]]; } ElMessageBox.confirm( `确认删除 ${removeKey} 为: ${removeValue} 数据?`, "Warning", { type: "warning", confirmButtonText: "确认", cancelButtonText: "取消", }, ).then(async () => { schemaTableRef.value.setLoadingStatus(true); const res = await $curl({ method: "delete", url: api.value, data: { [removeKey]: removeValue, }, errorMessage: "删除失败", }); schemaTableRef.value.setLoadingStatus(false); if (!res || !res.success || !res.data) { return; } ElNotification({ title: "删除成功", message: "删除成功", type: "success", }); // 如果删除后当前页没有数据,就加载第一页// await initTableData();// 重新加载 我感觉这个更合理一点awaitloadTableData(); });};constEventKeyMap = { remove: removeData,};constoperationHandler = async ({ btnConfig, rowData }) => { const { eventKey } = btnConfig; if (EventKeyMap[eventKey]) { awaitEventKeyMap[eventKey]({ btnConfig, rowData }); } else { emit("operate", { btnConfig, rowData }); }};constinitTableData = async () => { await schemaTableRef.value.initData();};constloadTableData = async () => { await schemaTableRef.value.loadTableData();};defineExpose({ loadTableData,});</script><stylelang="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>// pages/dashboard/complex-view/schema-view/schema-view.vue<template> <el-row class="schema-view"> <search-panel v-if=" searchSchema?.properties && Object.keys(searchSchema.properties).length > 0 " @search="onSearch" /> <table-panel ref="tablePanelRef" @operate="onTableOperate" /> <component v-for="(item, key) in components" :key="key" :is="ComponentConfig[key]?.component" ref="comListRef" @command="onComponentCommand" ></component> </el-row></template><script setup>import { ref, 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 "./hooks/schema";import ComponentConfig from "./components/component-config.js";const { api, tableSchema, tableConfig, searchSchema, searchConfig, components,} = useSchema();const apiParams = ref();// 深层访问provide("schemaViewData", { api, apiParams, tableSchema, tableConfig, searchSchema, searchConfig, components,});const tablePanelRef = ref(null);const comListRef = ref([]);const EventhandlerMap = { showComponent: showComponent,};// 响应组件事件const onComponentCommand = (data) => { const { event } = data; if (event == "loadTableData") { tablePanelRef.value.loadTableData(); }};const onSearch = (searchValObj) => { apiParams.value = searchValObj;};const onTableOperate = ({ btnConfig, rowData }) => { const { eventKey } = btnConfig; if (EventhandlerMap[eventKey]) { EventhandlerMap[eventKey]({ btnConfig, rowData }); }};function showComponent({ btnConfig, rowData }) { const { comName } = btnConfig.eventOption; if (!comName) { console.error(`找不到组件:${comName}`); return; } const comRef = comListRef.value.find((item) => item.name == comName); if (!comRef || typeof comRef.show !== "function") { console.error(`找不到组件配置:${comName}`); return; } comRef.show(rowData);}</script><style lang="less" scoped>.schema-view { display: flex; flex-direction: column; height: 100%; width: 100%;}</style>