开发一些组件来建设组件库以及扩展 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: [],
},
};
schema-search-bar
搜索组件内容:
- 每个搜索表单项会渲染不同的组件,也可以赋予默认值
- 只用来筛选,最终影响的结果是表格,所以请求不发生在该组件内
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>