四、elpis 基于 vue3 完成动态组件库建设

开发一些组件来建设组件库以及扩展 DSL

组件 DSL 设计

schema-table

表格内容

  • 表格组件由列字段控制渲染,表格数据可通过搜索条件筛选
  • 常见的表格头部以及每行末尾可能会出现按钮,这些按钮主要用来交互

对于交互,需要知道事件回调以及动态参数值。事件回调函数可通过事件名确定,对于动态参数,可能来源于表格,也可能来源于外部,比如获取当前时间,所以可以添加一些专有标识,在组件内部分辨获取

DSL 设计 :我们在 properties.key 中通过 tableOption 设置当前列字段在表格中的配置,然后在外部 tableConfig 中对表格整体设置,比如 headerButtons 以及每行的操作按钮 rowButtons

js 复制代码
// schema-table dsl
const schemaConfig = {
  api: string;
  schema: {
    type: object,
    properties: {
      [key]: {
        // ...标准 json-schema 配置

        tableOption: {
          // ...ElTableColumnConfig,
          visible: boolean, // 是否展示
        },
      },
    },
  },
  tableConfig: {
    headerButtons: [
        {
            // ...elButtonConfig,
            eventKey: string; // 触发的事件名,如remove showComponent
            eventOption: {

                // 当 eventKey = showComponent
                comName: string; // "组件名"

                // 当 eventKey = remove
                // 比如说删除某条数据时,向后端传递参数 time,该字段值来源于表格中 createTime 字段
                params: {
                    /**
                     * paramKey: {string} 参数名称
                     * rowValueKey: {string} 如果 rowValueKey 是 `schema::${rowValueKey}` 这样的格式,表示从表格中获取值
                     */
                    [paramKey: string]: rowValueKey,
                }
            }
        }
    ],
    // 同 headerButtons
    rowButtons: [],
  },
};

搜索组件内容

  • 每个搜索表单项会渲染不同的组件,也可以赋予默认值
  • 只用来筛选,最终影响的结果是表格,所以请求不发生在该组件内

DSL 设计 :我们在 properties.key 中通过 searchOption 设置表单项的组件类型以及默认值,然后在外部 searchConfig 中对搜索组件整体设置,比如 searchButtons

js 复制代码
const schemaConfig = {
  schema: {
    type: object,
    properties: {
      [key]: {
        // ...标准 json-schema 配置

        searchOption: {
          // ...xxComponentConfig, // 包括 elementui、自定义组件、其他组件
          visible: boolean, // 是否展示
          comType: "", // 组件类型
          default: "", // 默认值
        },
      },
    },
  },
  searchConfig: {
    searchButtons: [
        {
            // ...elButtonConfig,
            label: '搜索',
            eventKey: string; // 触发的事件名 search
            visible: true,
        },
        {
            // ...elButtonConfig,
            label: '清空',
            eventKey: string; // 触发的事件名 clear
            visible: false,
        },
    ],
  },
};

schema-form

表单内容

  • 每个表单项会渲染不同的组件,也可以赋予默认值,设置 disable 等
  • 一个页面可能会有多个表单,需要避免操作冲突
  • 同一页面存在多个一样的组件的需求还有很多,需要考虑扩展维护问题

DSL 设计 :我们在 properties.key 中通过 xxxFormOption 表示某个表单项的配置,然后在外部 componentConfig 下设置多个表单(xxxForm)的整体配置,类似的,对于同类型组件,通过 xxx${compType} 设置。

js 复制代码
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; // 表单标识
    }
  }
};

schema 标准化处理

对于组件来说应该只传递必要的内容,而 key 不一定在所有组件中都渲染

在将 schema 配置传递给组件前,先筛选出当前渲染组件的 schema 和 config,将 xxxOption 改为 option,清除噪音,代码更加直观

js 复制代码
// app/pages/dashboard/complex-view/schema-view/hooks/schema.js

import { ref, watch, onMounted, nextTick } from "vue";
import { useRoute } from "vue-router";
import { useMenuStore } from "$store/menu.js";

export const 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 相关配置,输送给 schemaView
  const buildData = () => {
    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 和 searchConfig
        const 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 访问)
  const buildDtoSchema = (_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,
  };
};

组件实现

schema-table

vue 复制代码
// 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>

table-panel

xml 复制代码
app/pages/dashboard/complex-view/schema-view/complex-view/table-panel/table-panel.vue

<template>
  <el-card class="table-panel">
    <!-- operation-panel -->
    <el-row
      v-if="tableConfig?.headerButtons?.length > 0"
      justify="end"
      class="operation-panel"
    >
      <el-button
        v-for="item in tableConfig.headerButtons"
        v-bind="item"
        @click="operationHandler({ btnConfig: item })"
      >
        {{ item.label }}
      </el-button>
    </el-row>
    <!-- schema-table (组件 widget) -->
    <schema-table
      ref="schemaTableRef"
      :schema="tableSchema"
      :api="api"
      :api-params="apiParams"
      :buttons="tableConfig?.rowButtons ?? []"
      @operate="operationHandler"
    />
  </el-card>
</template>

<script setup>
import { ref, inject } from "vue";
import { ElMessageBox, ElNotification } from "element-plus";
import $curl from "$common/curl";
import SchemaTable from "$widgets/schema-table/schema-table.vue";

const emit = defineEmits(["operate"]);

const { api, apiParams, tableSchema, tableConfig } = inject("schemaViewData");

const schemaTableRef = ref(null);

const removeData = 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();

    // 重新加载 我感觉这个更合理一点
    await loadTableData();
  });
};

const EventKeyMap = {
  remove: removeData,
};

const operationHandler = async ({ btnConfig, rowData }) => {
  const { eventKey } = btnConfig;

  if (EventKeyMap[eventKey]) {
    await EventKeyMap[eventKey]({ btnConfig, rowData });
  } else {
    emit("operate", { btnConfig, rowData });
  }
};

const initTableData = async () => {
  await schemaTableRef.value.initData();
};

const loadTableData = async () => {
  await schemaTableRef.value.loadTableData();
};

defineExpose({
  loadTableData,
});
</script>

<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-view

vue 复制代码
// 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>
相关推荐
用户298698530141 小时前
如何在 JavaScript 和 React 中下载/导出 Excel 文件
前端·javascript·react.js
anyup1 小时前
uView Pro 正式支持 MCP 协议!让 AI 秒懂你的组件库
前端·uni-app·ai编程
Coffeeee1 小时前
从 Android 15 到 Android 17,谷歌猝不及防的音频改动
android·前端·google
雪隐2 小时前
用Flutter做背单词APP-00前言
前端·人工智能·后端
何时梦醒2 小时前
前端存储全攻略 & JavaScript this 绑定完全指南
前端·javascript·面试
前端百草阁2 小时前
JavaScript 设计模式(23 种)
开发语言·前端·javascript·设计模式
用户69371750013842 小时前
AI 领域的 Harness,到底是什么意思?
android·前端·后端
spider_xcxc2 小时前
Redis 深度实践:安全管控、性能压测与持久化分析(二)
运维·前端·redis·云计算·bootstrap·运维开发
乘风gg2 小时前
代码都已经让 AI 写了,为什么有些人却更慌了?
前端·ai编程·claude